diff --git a/.github/workflows/autofix_pr.yml b/.github/workflows/autofix_pr.yml index 308849ccbeed0be7f9ab5c8f7e5846ed61a8724d..1591ba2a9a98b8587814d25858f4e0d78d9f7d34 100644 --- a/.github/workflows/autofix_pr.yml +++ b/.github/workflows/autofix_pr.yml @@ -9,26 +9,23 @@ on: description: pr_number required: true type: string + run_clippy: + description: run_clippy + type: boolean + default: 'true' jobs: run_autofix: runs-on: namespace-profile-16x32-ubuntu-2204 steps: - - id: get-app-token - name: autofix_pr::run_autofix::authenticate_as_zippy - uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1 - with: - app-id: ${{ secrets.ZED_ZIPPY_APP_ID }} - private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} - - name: steps::checkout_repo_with_token + - name: steps::checkout_repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: clean: false - token: ${{ steps.get-app-token.outputs.token }} - name: autofix_pr::run_autofix::checkout_pr run: gh pr checkout ${{ inputs.pr_number }} shell: bash -euxo pipefail {0} env: - GITHUB_TOKEN: ${{ steps.get-app-token.outputs.token }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: steps::setup_cargo_config run: | mkdir -p ./../.cargo @@ -58,26 +55,74 @@ jobs: run: cargo fmt --all shell: bash -euxo pipefail {0} - name: autofix_pr::run_autofix::run_clippy_fix + if: ${{ inputs.run_clippy }} run: cargo clippy --workspace --release --all-targets --all-features --fix --allow-dirty --allow-staged shell: bash -euxo pipefail {0} - - name: autofix_pr::run_autofix::commit_and_push + - id: create-patch + name: autofix_pr::run_autofix::create_patch run: | if git diff --quiet; then echo "No changes to commit" + echo "has_changes=false" >> "$GITHUB_OUTPUT" else - git add -A - git commit -m "Autofix" - git push + git diff > autofix.patch + echo "has_changes=true" >> "$GITHUB_OUTPUT" fi shell: bash -euxo pipefail {0} + - name: upload artifact autofix-patch + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: autofix-patch + path: autofix.patch + if-no-files-found: ignore + retention-days: '1' + - name: steps::cleanup_cargo_config + if: always() + run: | + rm -rf ./../.cargo + shell: bash -euxo pipefail {0} + outputs: + has_changes: ${{ steps.create-patch.outputs.has_changes }} + commit_changes: + needs: + - run_autofix + if: needs.run_autofix.outputs.has_changes == 'true' + runs-on: namespace-profile-2x4-ubuntu-2404 + steps: + - id: get-app-token + name: autofix_pr::commit_changes::authenticate_as_zippy + uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1 + with: + app-id: ${{ secrets.ZED_ZIPPY_APP_ID }} + private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} + - name: steps::checkout_repo_with_token + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + token: ${{ steps.get-app-token.outputs.token }} + - name: autofix_pr::commit_changes::checkout_pr + run: gh pr checkout ${{ inputs.pr_number }} + shell: bash -euxo pipefail {0} + env: + GITHUB_TOKEN: ${{ steps.get-app-token.outputs.token }} + - name: autofix_pr::download_patch_artifact + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 + with: + name: autofix-patch + - name: autofix_pr::commit_changes::apply_patch + run: git apply autofix.patch + shell: bash -euxo pipefail {0} + - name: autofix_pr::commit_changes::commit_and_push + run: | + git commit -am "Autofix" + git push + shell: bash -euxo pipefail {0} env: GIT_COMMITTER_NAME: Zed Zippy GIT_COMMITTER_EMAIL: 234243425+zed-zippy[bot]@users.noreply.github.com GIT_AUTHOR_NAME: Zed Zippy GIT_AUTHOR_EMAIL: 234243425+zed-zippy[bot]@users.noreply.github.com GITHUB_TOKEN: ${{ steps.get-app-token.outputs.token }} - - name: steps::cleanup_cargo_config - if: always() - run: | - rm -rf ./../.cargo - shell: bash -euxo pipefail {0} +concurrency: + group: ${{ github.workflow }}-${{ inputs.pr_number }} + cancel-in-progress: true diff --git a/.github/workflows/community_champion_auto_labeler.yml b/.github/workflows/community_champion_auto_labeler.yml index 93f1d5602331bb76fe5d678098ab8c087b1f3d52..d73b38320731e0a2f9a52ff863de5095eddb7b6a 100644 --- a/.github/workflows/community_champion_auto_labeler.yml +++ b/.github/workflows/community_champion_auto_labeler.yml @@ -34,6 +34,7 @@ jobs: CharlesChen0823 chbk cppcoffee + davidbarsky davewa ddoemonn djsauble diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7afac285b5a34df2aadd04952400809059e12222..155b38666f4bd73e68e9ea326db9a6862288a1fe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -74,6 +74,12 @@ jobs: - name: steps::clippy run: ./script/clippy shell: bash -euxo pipefail {0} + - name: steps::trigger_autofix + if: failure() && github.event_name == 'pull_request' && github.actor != 'zed-zippy[bot]' + run: gh workflow run autofix_pr.yml -f pr_number=${{ github.event.pull_request.number }} -f run_clippy=true + shell: bash -euxo pipefail {0} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: steps::cargo_install_nextest uses: taiki-e/install-action@nextest - name: steps::clear_target_dir_if_large diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index 9584d7a0cb70469820bf40d76beb6154f2a53b1e..a9a46b7a797faae793c87601d306a2aea80e6592 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -77,6 +77,15 @@ jobs: - name: ./script/prettier run: ./script/prettier shell: bash -euxo pipefail {0} + - name: steps::cargo_fmt + run: cargo fmt --all -- --check + shell: bash -euxo pipefail {0} + - name: steps::trigger_autofix + if: failure() && github.event_name == 'pull_request' && github.actor != 'zed-zippy[bot]' + run: gh workflow run autofix_pr.yml -f pr_number=${{ github.event.pull_request.number }} -f run_clippy=false + shell: bash -euxo pipefail {0} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: ./script/check-todos run: ./script/check-todos shell: bash -euxo pipefail {0} @@ -87,9 +96,6 @@ jobs: uses: crate-ci/typos@2d0ce569feab1f8752f1dde43cc2f2aa53236e06 with: config: ./typos.toml - - name: steps::cargo_fmt - run: cargo fmt --all -- --check - shell: bash -euxo pipefail {0} timeout-minutes: 60 run_tests_windows: needs: @@ -160,6 +166,12 @@ jobs: - name: steps::clippy run: ./script/clippy shell: bash -euxo pipefail {0} + - name: steps::trigger_autofix + if: failure() && github.event_name == 'pull_request' && github.actor != 'zed-zippy[bot]' + run: gh workflow run autofix_pr.yml -f pr_number=${{ github.event.pull_request.number }} -f run_clippy=true + shell: bash -euxo pipefail {0} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: steps::cargo_install_nextest uses: taiki-e/install-action@nextest - name: steps::clear_target_dir_if_large diff --git a/.mailmap b/.mailmap index db4632d6ca34346d3e8fa289222d7f310b7bdfe5..1e956c52cf76589fc016e1410122ccd94e4818ae 100644 --- a/.mailmap +++ b/.mailmap @@ -141,6 +141,9 @@ Uladzislau Kaminski Uladzislau Kaminski Vitaly Slobodin Vitaly Slobodin +Yara +Yara +Yara Will Bradley Will Bradley WindSoilder diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9cbac4af2b57f0350fa9f5665e110e0d6e7f6341..f7aceadce18788ae2b8bb9d0fe4b5f16225e70d2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/Cargo.lock b/Cargo.lock index a6f92205fdf177f0e03db4e00307c38defb56ada..907bb049b5e067e1ad7dd98c825a4dfa74a6d839 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", @@ -1431,9 +1441,9 @@ dependencies = [ [[package]] name = "aws-config" -version = "1.8.8" +version = "1.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37cf2b6af2a95a20e266782b4f76f1a5e12bf412a9db2de9c1e9123b9d8c0ad8" +checksum = "1856b1b48b65f71a4dd940b1c0931f9a7b646d4a924b9828ffefc1454714668a" dependencies = [ "aws-credential-types", "aws-runtime", @@ -1497,9 +1507,9 @@ dependencies = [ [[package]] name = "aws-runtime" -version = "1.5.12" +version = "1.5.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa006bb32360ed90ac51203feafb9d02e3d21046e1fd3a450a404b90ea73e5d" +checksum = "9f2402da1a5e16868ba98725e5d73f26b8116eaa892e56f2cd0bf5eec7985f70" dependencies = [ "aws-credential-types", "aws-sigv4", @@ -1522,9 +1532,9 @@ dependencies = [ [[package]] name = "aws-sdk-bedrockruntime" -version = "1.109.0" +version = "1.112.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbfdfd941dcb253c17bf70baddbf1e5b22f19e29d313d2e049bad4b1dadb2011" +checksum = "c06c037e6823696d752702ec2bad758d3cf95d1b92b712c8ac7e93824b5e2391" dependencies = [ "aws-credential-types", "aws-runtime", @@ -1604,9 +1614,9 @@ dependencies = [ [[package]] name = "aws-sdk-sso" -version = "1.86.0" +version = "1.88.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a0abbfab841446cce6e87af853a3ba2cc1bc9afcd3f3550dd556c43d434c86d" +checksum = "d05b276777560aa9a196dbba2e3aada4d8006d3d7eeb3ba7fe0c317227d933c4" dependencies = [ "aws-credential-types", "aws-runtime", @@ -1626,9 +1636,9 @@ dependencies = [ [[package]] name = "aws-sdk-ssooidc" -version = "1.88.0" +version = "1.90.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a68d675582afea0e94d38b6ca9c5aaae4ca14f1d36faa6edb19b42e687e70d7" +checksum = "f9be14d6d9cd761fac3fd234a0f47f7ed6c0df62d83c0eeb7012750e4732879b" dependencies = [ "aws-credential-types", "aws-runtime", @@ -1648,9 +1658,9 @@ dependencies = [ [[package]] name = "aws-sdk-sts" -version = "1.88.0" +version = "1.90.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d30990923f4f675523c51eb1c0dec9b752fb267b36a61e83cbc219c9d86da715" +checksum = "98a862d704c817d865c8740b62d8bbeb5adcb30965e93b471df8a5bcefa20a80" dependencies = [ "aws-credential-types", "aws-runtime", @@ -1671,9 +1681,9 @@ dependencies = [ [[package]] name = "aws-sigv4" -version = "1.3.5" +version = "1.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bffc03068fbb9c8dd5ce1c6fb240678a5cffb86fb2b7b1985c999c4b83c8df68" +checksum = "c35452ec3f001e1f2f6db107b6373f1f48f05ec63ba2c5c9fa91f07dad32af11" dependencies = [ "aws-credential-types", "aws-smithy-eventstream", @@ -1730,9 +1740,9 @@ dependencies = [ [[package]] name = "aws-smithy-eventstream" -version = "0.60.12" +version = "0.60.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9656b85088f8d9dc7ad40f9a6c7228e1e8447cdf4b046c87e152e0805dea02fa" +checksum = "e29a304f8319781a39808847efb39561351b1bb76e933da7aa90232673638658" dependencies = [ "aws-smithy-types", "bytes 1.10.1", @@ -1741,9 +1751,9 @@ dependencies = [ [[package]] name = "aws-smithy-http" -version = "0.62.4" +version = "0.62.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3feafd437c763db26aa04e0cc7591185d0961e64c61885bece0fb9d50ceac671" +checksum = "445d5d720c99eed0b4aa674ed00d835d9b1427dd73e04adaf2f94c6b2d6f9fca" dependencies = [ "aws-smithy-eventstream", "aws-smithy-runtime-api", @@ -1751,6 +1761,7 @@ dependencies = [ "bytes 1.10.1", "bytes-utils", "futures-core", + "futures-util", "http 0.2.12", "http 1.3.1", "http-body 0.4.6", @@ -1762,9 +1773,9 @@ dependencies = [ [[package]] name = "aws-smithy-http-client" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1053b5e587e6fa40ce5a79ea27957b04ba660baa02b28b7436f64850152234f1" +checksum = "623254723e8dfd535f566ee7b2381645f8981da086b5c4aa26c0c41582bb1d2c" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", @@ -1792,9 +1803,9 @@ dependencies = [ [[package]] name = "aws-smithy-json" -version = "0.61.6" +version = "0.61.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cff418fc8ec5cadf8173b10125f05c2e7e1d46771406187b2c878557d4503390" +checksum = "2db31f727935fc63c6eeae8b37b438847639ec330a9161ece694efba257e0c54" dependencies = [ "aws-smithy-types", ] @@ -1820,9 +1831,9 @@ dependencies = [ [[package]] name = "aws-smithy-runtime" -version = "1.9.3" +version = "1.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ab99739082da5347660c556689256438defae3bcefd66c52b095905730e404" +checksum = "0bbe9d018d646b96c7be063dd07987849862b0e6d07c778aad7d93d1be6c1ef0" dependencies = [ "aws-smithy-async", "aws-smithy-http", @@ -1844,9 +1855,9 @@ dependencies = [ [[package]] name = "aws-smithy-runtime-api" -version = "1.9.1" +version = "1.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3683c5b152d2ad753607179ed71988e8cfd52964443b4f74fd8e552d0bbfeb46" +checksum = "ec7204f9fd94749a7c53b26da1b961b4ac36bf070ef1e0b94bb09f79d4f6c193" dependencies = [ "aws-smithy-async", "aws-smithy-types", @@ -1861,9 +1872,9 @@ dependencies = [ [[package]] name = "aws-smithy-types" -version = "1.3.3" +version = "1.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f5b3a7486f6690ba25952cabf1e7d75e34d69eaff5081904a47bc79074d6457" +checksum = "25f535879a207fce0db74b679cfc3e91a3159c8144d717d55f5832aea9eef46e" dependencies = [ "base64-simd", "bytes 1.10.1", @@ -1887,18 +1898,18 @@ dependencies = [ [[package]] name = "aws-smithy-xml" -version = "0.60.11" +version = "0.60.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9c34127e8c624bc2999f3b657e749c1393bedc9cd97b92a804db8ced4d2e163" +checksum = "eab77cdd036b11056d2a30a7af7b775789fb024bf216acc13884c6c97752ae56" dependencies = [ "xmlparser", ] [[package]] name = "aws-types" -version = "1.3.9" +version = "1.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2fd329bf0e901ff3f60425691410c69094dc2a1f34b331f37bfc4e9ac1565a1" +checksum = "d79fb68e3d7fe5d4833ea34dc87d2e97d26d3086cb3da660bb6b1f76d98680b6" dependencies = [ "aws-credential-types", "aws-smithy-async", @@ -1997,7 +2008,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 +2667,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 +2679,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 +2691,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 +2709,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 +2719,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 +2731,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 +3624,7 @@ dependencies = [ "serde", "serde_json", "settings", + "slotmap", "smol", "tempfile", "terminal", @@ -3924,20 +3936,38 @@ dependencies = [ "libc", ] +[[package]] +name = "cranelift-assembler-x64" +version = "0.120.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +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.116.1" +version = "0.120.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e15d04a0ce86cb36ead88ad68cf693ffd6cda47052b9e0ac114bc47fd9cd23c4" +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 +3975,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 +3989,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 +4004,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 +4042,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 +4054,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" @@ -8780,6 +8821,7 @@ dependencies = [ "regex", "rpc", "schemars", + "semver", "serde", "serde_json", "settings", @@ -9016,6 +9058,7 @@ dependencies = [ "regex", "rope", "rust-embed", + "semver", "serde", "serde_json", "serde_json_lenient", @@ -12429,6 +12472,7 @@ dependencies = [ "context_server", "dap", "dap_adapters", + "db", "extension", "fancy-regex", "fs", @@ -12802,13 +12846,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", ] @@ -13307,9 +13350,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", @@ -14257,6 +14300,7 @@ dependencies = [ "schemars", "serde", "serde_json", + "settings", "theme", ] @@ -17312,9 +17356,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", @@ -18392,6 +18436,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.227.1" @@ -18449,23 +18503,37 @@ dependencies = [ "semver", ] +[[package]] +name = "wasmparser" +version = "0.229.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc3b1f053f5d41aa55640a1fa9b6d1b8a9e4418d118ce308d20e24ff3575a8c" +dependencies = [ + "bitflags 2.9.4", + "hashbrown 0.15.5", + "indexmap", + "semver", + "serde", +] + [[package]] name = "wasmprinter" -version = "0.221.3" +version = "0.229.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7343c42a97f2926c7819ff81b64012092ae954c5d83ddd30c9fcdefd97d0b283" +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", @@ -18473,7 +18541,7 @@ dependencies = [ "cc", "cfg-if", "encoding_rs", - "hashbrown 0.14.5", + "hashbrown 0.15.5", "indexmap", "libc", "log", @@ -18481,12 +18549,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", @@ -18494,7 +18561,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", @@ -18511,18 +18578,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", @@ -18533,9 +18600,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", @@ -18543,9 +18610,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", @@ -18553,20 +18620,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", @@ -18576,22 +18643,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", @@ -18608,22 +18676,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", @@ -18631,9 +18699,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", @@ -18643,24 +18711,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", @@ -18669,9 +18737,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", @@ -18686,30 +18754,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-wasi-io" +version = "33.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a835790dcecc3d7051ec67da52ba9e04af25e1bc204275b9391e3f0042b10797" +dependencies = [ + "anyhow", + "async-trait", + "bytes 1.10.1", + "futures 0.3.31", + "wasmtime", +] + [[package]] name = "wasmtime-winch" -version = "29.0.1" +version = "33.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdbabfb8f20502d5e1d81092b9ead3682ae59988487aafcd7567387b7a43cf8f" +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", @@ -18717,14 +18798,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]] @@ -19018,14 +19099,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", @@ -19033,24 +19114,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", @@ -19091,18 +19171,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", ] @@ -19908,9 +19989,9 @@ dependencies = [ [[package]] name = "wit-parser" -version = "0.221.3" +version = "0.227.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "896112579ed56b4a538b07a3d16e562d101ff6265c46b515ce0c701eef16b2ac" +checksum = "ddf445ed5157046e4baf56f9138c124a0824d4d1657e7204d71886ad8ce2fc11" dependencies = [ "anyhow", "id-arena", @@ -19921,14 +20002,14 @@ dependencies = [ "serde_derive", "serde_json", "unicode-xid", - "wasmparser 0.221.3", + "wasmparser 0.227.1", ] [[package]] name = "wit-parser" -version = "0.227.1" +version = "0.229.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddf445ed5157046e4baf56f9138c124a0824d4d1657e7204d71886ad8ce2fc11" +checksum = "459c6ba62bf511d6b5f2a845a2a736822e38059c1cfa0b644b467bbbfae4efa6" dependencies = [ "anyhow", "id-arena", @@ -19939,7 +20020,7 @@ dependencies = [ "serde_derive", "serde_json", "unicode-xid", - "wasmparser 0.227.1", + "wasmparser 0.229.0", ] [[package]] @@ -20381,7 +20462,7 @@ dependencies = [ [[package]] name = "zed" -version = "0.218.0" +version = "0.219.0" dependencies = [ "acp_tools", "activity_indicator", diff --git a/Cargo.toml b/Cargo.toml index 76f52fa6b63047d0a490c15679195dab9c2736fd..87c0ed97cd85418b88b207c00444f997ddfd6370 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -445,15 +445,15 @@ async-task = "4.7" async-trait = "0.1" async-tungstenite = "0.31.0" async_zip = { version = "0.0.18", features = ["deflate", "deflate64"] } -aws-config = { version = "1.6.1", features = ["behavior-version-latest"] } -aws-credential-types = { version = "1.2.2", features = [ +aws-config = { version = "1.8.10", features = ["behavior-version-latest"] } +aws-credential-types = { version = "1.2.8", features = [ "hardcoded-credentials", ] } -aws-sdk-bedrockruntime = { version = "1.80.0", features = [ +aws-sdk-bedrockruntime = { version = "1.112.0", features = [ "behavior-version-latest", ] } -aws-smithy-runtime-api = { version = "1.7.4", features = ["http-1x", "client"] } -aws-smithy-types = { version = "1.3.0", features = ["http-body-1-x"] } +aws-smithy-runtime-api = { version = "1.9.2", features = ["http-1x", "client"] } +aws-smithy-types = { version = "1.3.4", features = ["http-body-1-x"] } backtrace = "0.3" base64 = "0.22" bincode = "1.2.1" @@ -653,7 +653,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" } @@ -687,7 +687,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", @@ -696,7 +696,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" diff --git a/Dockerfile-collab b/Dockerfile-collab index 68f898618a5d0cd1ad9999e5482c53dc0cb26da6..188e7daddfb471c41b237ca75469355cfc866ae3 100644 --- a/Dockerfile-collab +++ b/Dockerfile-collab @@ -1,6 +1,6 @@ # syntax = docker/dockerfile:1.2 -FROM rust:1.91.1-bookworm as builder +FROM rust:1.92-bookworm as builder WORKDIR app COPY . . diff --git a/REVIEWERS.conl b/REVIEWERS.conl index 45155ba3468f29062b58aa9094defc7f86110885..bca694d7a06fe1112f7f8bab158dad63a365ea74 100644 --- a/REVIEWERS.conl +++ b/REVIEWERS.conl @@ -28,7 +28,7 @@ ai = @rtfeldman audio - = @dvdsk + = @yara-blue crashes = @p1n3appl3 @@ -53,7 +53,7 @@ extension git = @cole-miller = @danilo-leal - = @dvdsk + = @yara-blue = @kubkon = @Anthony-Eid = @cameron1024 @@ -76,7 +76,7 @@ languages linux = @cole-miller - = @dvdsk + = @yara-blue = @p1n3appl3 = @probably-neb = @smitbarmase @@ -92,7 +92,7 @@ multi_buffer = @SomeoneToIgnore pickers - = @dvdsk + = @yara-blue = @p1n3appl3 = @SomeoneToIgnore diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 38ef7d092d534163ead569c522227b089f84af99..f09ac0a812c3e875618c57da15bcf16e1f983d6e 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -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", }, }, { @@ -262,9 +264,9 @@ { "context": "AgentPanel > Markdown", "bindings": { - "copy": "markdown::Copy", - "ctrl-insert": "markdown::Copy", - "ctrl-c": "markdown::Copy", + "copy": "markdown::CopyAsMarkdown", + "ctrl-insert": "markdown::CopyAsMarkdown", + "ctrl-c": "markdown::CopyAsMarkdown", }, }, { @@ -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", diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 8a0e3dfdcddbd448e6a6b9bf66f3731153208120..1d489771febc770e300b63e265024ffca3d14a90 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -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", }, }, { @@ -303,7 +306,7 @@ "context": "AgentPanel > Markdown", "use_key_equivalents": true, "bindings": { - "cmd-c": "markdown::Copy", + "cmd-c": "markdown::CopyAsMarkdown", }, }, { @@ -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", diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index e344ea356fb171fb07474f498056df73c73d8307..9154cc43afb86c287329229c6f0d699f59a82b36 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -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", }, }, { @@ -265,7 +267,7 @@ "context": "AgentPanel > Markdown", "use_key_equivalents": true, "bindings": { - "ctrl-c": "markdown::Copy", + "ctrl-c": "markdown::CopyAsMarkdown", }, }, { @@ -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", diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 0097480e2775a1048452b2a5e8ec826525da3f2e..6e5d3423872a7dd83234b28e67c5082b36bd858f 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -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", diff --git a/assets/settings/default.json b/assets/settings/default.json index 2912d4ce4aa5c2ac11660b71d542572c7f03f3a7..d98416e60f47550709791ae3bc1b4036209115a3 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -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 diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 53294a963d9d230c9b06372c26591ede0434ab28..2ec6347fd4aa088d7ae2cc8f5a7b6cef37d3b202 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -43,6 +43,7 @@ pub struct UserMessage { pub content: ContentBlock, pub chunks: Vec, pub checkpoint: Option, + pub indented: bool, } #[derive(Debug)] @@ -73,6 +74,7 @@ impl UserMessage { #[derive(Debug, PartialEq)] pub struct AssistantMessage { pub chunks: Vec, + 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, chunk: acp::ContentBlock, cx: &mut Context, + ) { + 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, + chunk: acp::ContentBlock, + indented: bool, + cx: &mut Context, ) { 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.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, ) { 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, ); diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index eb3b844d08c2da25c2c55ecc790c956726a4aac2..598d0428174eb2fc124739a18ddeff1098521cb7 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -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 + } } /// Icon for a model in the model selector. @@ -248,6 +254,10 @@ impl AgentModelList { AgentModelList::Grouped(groups) => groups.is_empty(), } } + + pub fn is_flat(&self) -> bool { + matches!(self, AgentModelList::Flat(_)) + } } #[cfg(feature = "test-support")] diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 3bb22824548665a0981e707213fe391e384d153a..cb44da382ef576aa69a50b244eeeac8d8ec275fc 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -5,12 +5,12 @@ mod legacy_thread; mod native_agent_server; pub mod outline; mod templates; -mod thread; -mod tools; - #[cfg(test)] mod tests; +mod thread; +mod tools; +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, AgentModelIcon, AgentModelSelector}; +use acp_thread::{AcpThread, AgentModelIcon, 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; @@ -257,12 +256,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)) @@ -271,16 +282,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, @@ -349,6 +358,9 @@ impl NativeAgent { pending_save: Task::ready(()), }, ); + + self.update_available_commands(cx); + acp_thread } @@ -419,10 +431,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, }), @@ -616,6 +625,99 @@ impl NativeAgent { } } + fn handle_context_server_store_updated( + &mut self, + _store: Entity, + _event: &project::context_server_store::Event, + cx: &mut Context, + ) { + self.update_available_commands(cx); + } + + fn handle_context_server_registry_event( + &mut self, + _registry: Entity, + event: &ContextServerRegistryEvent, + cx: &mut Context, + ) { + match event { + ContextServerRegistryEvent::ToolsChanged => {} + ContextServerRegistryEvent::PromptsChanged => { + self.update_available_commands(cx); + } + } + } + + fn update_available_commands(&self, cx: &mut Context) { + 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 { + 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, @@ -714,6 +816,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, + original_content: Vec, + cx: &mut Context, + ) -> Task> { + 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 @@ -848,6 +1046,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 { + 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, @@ -938,6 +1169,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 { @@ -1013,6 +1248,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| { @@ -1609,3 +1885,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) + } + } +} diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index 5a581c5db80a4c4f527efc8b1711fbf16c8097f8..45028902e467fe67945ddf444c9ae417dcaed654 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -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" + ); + }); +} diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index dbf29c68766cfe28d0bce1d82ed53536446326e2..ef3ca23c3caf816a28e91e9e75b21f2cc80451e7 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -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 { 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 { + 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,20 +1169,64 @@ impl Thread { where T: Into, { + let content = content.into_iter().map(Into::into).collect::>(); + log::debug!("Thread::send content: {:?}", content); + + self.messages + .push(Message::User(UserMessage { id, content })); + cx.notify(); + + self.send_existing(cx) + } + + pub fn send_existing( + &mut self, + cx: &mut Context, + ) -> Result>> { 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::>(); - log::debug!("Thread::send content: {:?}", content); + 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, + path_style: PathStyle, + cx: &mut Context, + ) { + let content = blocks + .into_iter() + .map(|block| UserMessageContent::from_content_block(block, path_style)) + .collect::>(); self.messages .push(Message::User(UserMessage { id, content })); cx.notify(); + } - log::debug!("Total messages in thread: {}", self.messages.len()); - self.run_turn(cx) + pub fn push_acp_agent_block(&mut self, block: acp::ContentBlock, cx: &mut Context) { + 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")] @@ -1650,6 +1725,10 @@ impl Thread { self.pending_summary_generation.is_some() } + pub fn is_generating_title(&self) -> bool { + self.pending_title_generation.is_some() + } + pub fn summary(&mut self, cx: &mut Context) -> Shared>> { if let Some(summary) = self.summary.as_ref() { return Task::ready(Some(summary.clone())).shared(); @@ -1717,7 +1796,7 @@ impl Thread { task } - fn generate_title(&mut self, cx: &mut Context) { + pub fn generate_title(&mut self, cx: &mut Context) { let Some(model) = self.summarization_model.clone() else { return; }; @@ -1966,6 +2045,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, diff --git a/crates/agent/src/tools.rs b/crates/agent/src/tools.rs index 62a52998a705e11d1c9e69cbade7f427cc9cfc32..358903a32baa5ead9b073642015e6829501307a2 100644 --- a/crates/agent/src/tools.rs +++ b/crates/agent/src/tools.rs @@ -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, diff --git a/crates/agent/src/tools/context_server_registry.rs b/crates/agent/src/tools/context_server_registry.rs index 03a0ef84e73d4cbca83d61077d568ec58cd7ae2b..3b01b2feb7dd36615a8ba7c63d81a81694e0d268 100644 --- a/crates/agent/src/tools/context_server_registry.rs +++ b/crates/agent/src/tools/context_server_registry.rs @@ -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 for ContextServerRegistry {} + pub struct ContextServerRegistry { server_store: Entity, registered_servers: HashMap, @@ -16,7 +28,10 @@ pub struct ContextServerRegistry { struct RegisteredContextServer { tools: BTreeMap>, + prompts: BTreeMap, load_tools: Task>, + load_prompts: Task>, + _tools_updated_subscription: Option, } 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 { + 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 { + &self.server_store + } + + fn get_or_register_server( + &mut self, + server_id: &ContextServerId, + cx: &mut Context, + ) -> &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, + cx: &mut Context, + ) -> 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) { 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::(()) @@ -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) { + 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::(()) + .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, + server_id: &ContextServerId, + prompt_name: &str, + arguments: HashMap, + cx: &mut AsyncApp, +) -> Task> { + 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::PromptsGetParams { + name: prompt_name, + arguments: (!arguments.is_empty()).then(|| arguments), + meta: None, + }, + ) + .await?; + + Ok(response) + }) +} diff --git a/crates/agent/src/tools/edit_file_tool.rs b/crates/agent/src/tools/edit_file_tool.rs index 0ab99426e2e9645adf3f837d21c28dc285ab6ea2..3acb7f5951f3ca4b682dcabc62a0d54c35ab08d6 100644 --- a/crates/agent/src/tools/edit_file_tool.rs +++ b/crates/agent/src/tools/edit_file_tool.rs @@ -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 + ); } } diff --git a/crates/agent/src/tools/restore_file_from_disk_tool.rs b/crates/agent/src/tools/restore_file_from_disk_tool.rs new file mode 100644 index 0000000000000000000000000000000000000000..f5723f6ee3ee46144152dd3ed2939ab2cfaca9c0 --- /dev/null +++ b/crates/agent/src/tools/restore_file_from_disk_tool.rs @@ -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, +} + +pub struct RestoreFileFromDiskTool { + project: Entity, +} + +impl RestoreFileFromDiskTool { + pub fn new(project: Entity) -> Self { + Self { project } + } +} + +impl AgentTool for RestoreFileFromDiskTool { + type Input = RestoreFileFromDiskToolInput; + type Output = String; + + fn name() -> &'static str { + "restore_file_from_disk" + } + + fn kind() -> acp::ToolKind { + acp::ToolKind::Other + } + + fn initial_title( + &self, + input: Result, + _cx: &mut App, + ) -> SharedString { + match input { + Ok(input) if input.paths.len() == 1 => "Restore file from disk".into(), + Ok(input) => format!("Restore {} files from disk", input.paths.len()).into(), + Err(_) => "Restore files from disk".into(), + } + } + + fn run( + self: Arc, + input: Self::Input, + _event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> { + let project = self.project.clone(); + let input_paths = input.paths; + + cx.spawn(async move |cx| { + let mut buffers_to_reload: FxHashSet> = FxHashSet::default(); + + let mut restored_paths: Vec = Vec::new(); + let mut clean_paths: Vec = Vec::new(); + let mut not_found_paths: Vec = Vec::new(); + let mut open_errors: Vec<(PathBuf, String)> = Vec::new(); + let mut dirty_check_errors: Vec<(PathBuf, String)> = Vec::new(); + let mut reload_errors: Vec = 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 = 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 + } +} diff --git a/crates/agent/src/tools/save_file_tool.rs b/crates/agent/src/tools/save_file_tool.rs new file mode 100644 index 0000000000000000000000000000000000000000..429352200109c52303c9f6f94a28a49136af1a61 --- /dev/null +++ b/crates/agent/src/tools/save_file_tool.rs @@ -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, +} + +pub struct SaveFileTool { + project: Entity, +} + +impl SaveFileTool { + pub fn new(project: Entity) -> Self { + Self { project } + } +} + +impl AgentTool for SaveFileTool { + type Input = SaveFileToolInput; + type Output = String; + + fn name() -> &'static str { + "save_file" + } + + fn kind() -> acp::ToolKind { + acp::ToolKind::Other + } + + fn initial_title( + &self, + input: Result, + _cx: &mut App, + ) -> SharedString { + match input { + Ok(input) if input.paths.len() == 1 => "Save file".into(), + Ok(input) => format!("Save {} files", input.paths.len()).into(), + Err(_) => "Save files".into(), + } + } + + fn run( + self: Arc, + input: Self::Input, + _event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> { + let project = self.project.clone(); + let input_paths = input.paths; + + cx.spawn(async move |cx| { + let mut buffers_to_save: FxHashSet> = FxHashSet::default(); + + let mut saved_paths: Vec = Vec::new(); + let mut clean_paths: Vec = Vec::new(); + let mut not_found_paths: Vec = Vec::new(); + let mut open_errors: Vec<(PathBuf, String)> = Vec::new(); + let 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(|| "".to_string()), + Err(error) => { + save_errors.push(("".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 = 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}" + ); + } +} diff --git a/crates/agent_settings/Cargo.toml b/crates/agent_settings/Cargo.toml index 8ddcac24fe054d1226f2bbac49498fd35d6ed1c3..0d7163549f0a4b172773c9ac95dcbc84b7212667 100644 --- a/crates/agent_settings/Cargo.toml +++ b/crates/agent_settings/Cargo.toml @@ -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 diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs index 25ca5c78d6b76145a1b1b5d19ac86246ff419d1d..b513ec1a70b6f7ab02382dfa312ea2d4d6a47234 100644 --- a/crates/agent_settings/src/agent_settings.rs +++ b/crates/agent_settings/src/agent_settings.rs @@ -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, pub thread_summary_model: Option, pub inline_alternatives: Vec, + pub favorite_models: Vec, pub default_profile: AgentProfileId, pub default_view: DefaultAgentView, pub profiles: IndexMap, @@ -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 { + 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 diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs index 5e9c55cc56868ac2e7db65043d13eb46efcd89a6..308230a24c6d2ba7fb0c3995b886e9e924d8e1b7 100644 --- a/crates/agent_ui/src/acp/message_editor.rs +++ b/crates/agent_ui/src/acp/message_editor.rs @@ -1365,7 +1365,7 @@ mod tests { cx, ); }); - message_editor.read(cx).focus_handle(cx).focus(window); + message_editor.read(cx).focus_handle(cx).focus(window, cx); message_editor.read(cx).editor().clone() }); @@ -1587,7 +1587,7 @@ mod tests { cx, ); }); - message_editor.read(cx).focus_handle(cx).focus(window); + message_editor.read(cx).focus_handle(cx).focus(window, cx); let editor = message_editor.read(cx).editor().clone(); (message_editor, editor) }); @@ -2315,7 +2315,7 @@ mod tests { cx, ); }); - message_editor.read(cx).focus_handle(cx).focus(window); + message_editor.read(cx).focus_handle(cx).focus(window, cx); let editor = message_editor.read(cx).editor().clone(); (message_editor, editor) }); diff --git a/crates/agent_ui/src/acp/model_selector.rs b/crates/agent_ui/src/acp/model_selector.rs index 3dcfdf9b6b38c6a00d11bbfe70b95391529d706d..216604fa5574631d3ae5467806a15da93c2351ad 100644 --- a/crates/agent_ui/src/acp/model_selector.rs +++ b/crates/agent_ui/src/acp/model_selector.rs @@ -1,25 +1,26 @@ use std::{cmp::Reverse, rc::Rc, sync::Arc}; use acp_thread::{AgentModelIcon, 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, IntoElement, KeyBinding, ListItem, - ListItemSpacing, prelude::*, -}; +use settings::Settings; +use ui::{DocumentationAside, DocumentationEdge, DocumentationSide, IntoElement, prelude::*}; use util::ResultExt; use zed_actions::agent::OpenSettings; -use crate::ui::HoldForDefault; +use crate::ui::{HoldForDefault, ModelSelectorFooter, ModelSelectorHeader, ModelSelectorListItem}; pub type AcpModelSelector = Picker; @@ -41,7 +42,7 @@ pub fn acp_model_selector( enum AcpModelPickerEntry { Separator(SharedString), - Model(AgentModelInfo), + Model(AgentModelInfo, bool), } pub struct AcpModelPickerDelegate { @@ -118,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>) { + 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 = match models { + AgentModelList::Flat(list) => list, + AgentModelList::Grouped(index_map) => index_map + .into_values() + .flatten() + .collect::>(), + }; + + let favorite_models = all_models + .iter() + .filter(|model| favorites.contains(&model.id)) + .unique_by(|model| &model.id) + .cloned() + .collect::>(); + + 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 { @@ -143,7 +205,7 @@ impl PickerDelegate for AcpModelPickerDelegate { _cx: &mut Context>, ) -> bool { match self.filtered_entries.get(ix) { - Some(AcpModelPickerEntry::Model(_)) => true, + Some(AcpModelPickerEntry::Model(_, _)) => true, Some(AcpModelPickerEntry::Separator(_)) | None => false, } } @@ -158,6 +220,12 @@ impl PickerDelegate for AcpModelPickerDelegate { window: &mut Window, cx: &mut Context>, ) -> 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| { @@ -174,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 @@ -182,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 @@ -198,7 +266,7 @@ impl PickerDelegate for AcpModelPickerDelegate { } fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context>) { - 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() { @@ -241,79 +309,60 @@ impl PickerDelegate for AcpModelPickerDelegate { cx: &mut Context>, ) -> Option { match self.filtered_entries.get(ix)? { - AcpModelPickerEntry::Separator(title) => Some( - div() - .px_2() - .pb_1() - .when(ix > 1, |this| { - this.mt_1() - .pt_2() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - }) - .child( - Label::new(title) - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - .into_any_element(), - ), - AcpModelPickerEntry::Model(model_info) => { + AcpModelPickerEntry::Separator(title) => { + Some(ModelSelectorHeader::new(title, ix > 1).into_any_element()) + } + 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 model_icon_color = if is_selected { - Color::Accent - } else { - Color::Muted + 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( - ListItem::new(ix) - .inset(true) - .spacing(ListItemSpacing::Sparse) - .toggle_state(selected) - .child( - h_flex() - .w_full() - .gap_1p5() - .map(|this| match &model_info.icon { - Some(icon) => this.child( - match icon { - AgentModelIcon::Path(path) => Icon::from_external_svg(path.clone()), - AgentModelIcon::Named(icon) => Icon::new(*icon) - } - .color(model_icon_color) - .size(IconSize::Small) - ), - None => this, - }) - .child(Label::new(model_info.name.clone()).truncate()), - ) - .end_slot(div().pr_3().when(is_selected, |this| { - this.child( - Icon::new(IconName::Check) - .color(Color::Accent) - .size(IconSize::Small), - ) - })), + ModelSelectorListItem::new(ix, model_info.name.clone()) + .map(|this| match &model_info.icon { + Some(AgentModelIcon::Path(path)) => this.icon_path(path.clone()), + Some(AgentModelIcon::Named(icon)) => this.icon(*icon), + None => this, + }) + .is_selected(is_selected) + .is_focused(selected) + .when(supports_favorites, |this| { + this.is_favorite(is_favorite) + .on_toggle_favorite(handle_action_click) + }), ) - .into_any_element() + .into_any_element(), ) } } @@ -347,7 +396,7 @@ impl PickerDelegate for AcpModelPickerDelegate { fn render_footer( &self, _window: &mut Window, - cx: &mut Context>, + _cx: &mut Context>, ) -> Option { let focus_handle = self.focus_handle.clone(); @@ -355,43 +404,57 @@ impl PickerDelegate for AcpModelPickerDelegate { return None; } - Some( - h_flex() - .w_full() - .p_1p5() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .child( - Button::new("configure", "Configure") - .full_width() - .style(ButtonStyle::Outlined) - .key_binding( - KeyBinding::for_action_in(&OpenSettings, &focus_handle, cx) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click(|_, window, cx| { - window.dispatch_action(OpenSettings.boxed_clone(), cx); - }), - ) - .into_any(), - ) + Some(ModelSelectorFooter::new(OpenSettings.boxed_clone(), focus_handle).into_any_element()) } } fn info_list_to_picker_entries( model_list: AgentModelList, -) -> impl Iterator { + favorites: Arc>, +) -> Vec { + 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) => { - itertools::Either::Left(list.into_iter().map(AcpModelPickerEntry::Model)) + 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) => { - 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)) - })) + 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( @@ -513,6 +576,170 @@ mod tests { } } + fn create_favorites(models: Vec<&str>) -> Arc> { + 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![ diff --git a/crates/agent_ui/src/acp/model_selector_popover.rs b/crates/agent_ui/src/acp/model_selector_popover.rs index 875749266b4328d92bd5f1859be0cdac32068850..a15c01445dd8e9845f6744e795ed90a1ede6c7fc 100644 --- a/crates/agent_ui/src/acp/model_selector_popover.rs +++ b/crates/agent_ui/src/acp/model_selector_popover.rs @@ -3,15 +3,15 @@ use std::sync::Arc; use acp_thread::{AgentModelIcon, 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.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") @@ -95,9 +141,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, ) diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index aa02e22635c1585003fbfc540b50687ae0930ecd..9e9af499727ad8478fa5fc1d46dc3b3bf8e20a71 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -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)] @@ -253,7 +253,7 @@ impl ThreadFeedbackState { editor }); - editor.read(cx).focus_handle(cx).focus(window); + editor.read(cx).focus_handle(cx).focus(window, cx); editor } } @@ -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::().is_some(); @@ -671,7 +682,7 @@ impl AcpThreadView { }) }); - this.message_editor.focus_handle(cx).focus(window); + this.message_editor.focus_handle(cx).focus(window, cx); cx.notify(); } @@ -773,7 +784,7 @@ impl AcpThreadView { _subscription: subscription, }; if this.message_editor.focus_handle(cx).is_focused(window) { - this.focus_handle.focus(window) + this.focus_handle.focus(window, cx) } cx.notify(); }) @@ -793,7 +804,7 @@ impl AcpThreadView { ThreadState::LoadError(LoadError::Other(format!("{:#}", err).into())) } if self.message_editor.focus_handle(cx).is_focused(window) { - self.focus_handle.focus(window) + self.focus_handle.focus(window, cx) } cx.notify(); } @@ -1259,7 +1270,7 @@ impl AcpThreadView { } }) }; - self.focus_handle(cx).focus(window); + self.focus_handle(cx).focus(window, cx); cx.notify(); } @@ -1311,11 +1322,11 @@ impl AcpThreadView { .await?; this.update_in(cx, |this, window, cx| { this.send_impl(message_editor, window, cx); - this.focus_handle(cx).focus(window); + this.focus_handle(cx).focus(window, cx); })?; anyhow::Ok(()) }) - .detach(); + .detach_and_log_err(cx); } fn open_edited_buffer( @@ -1454,7 +1465,7 @@ impl AcpThreadView { self.thread_retry_status.take(); self.thread_state = ThreadState::LoadError(error.clone()); if self.message_editor.focus_handle(cx).is_focused(window) { - self.focus_handle.focus(window) + self.focus_handle.focus(window, cx) } } AcpThreadEvent::TitleUpdated => { @@ -1887,6 +1898,17 @@ impl AcpThreadView { }) } + pub fn has_user_submitted_prompt(&self, cx: &App) -> bool { + self.thread().is_some_and(|thread| { + thread.read(cx).entries().iter().any(|entry| { + matches!( + entry, + AgentThreadEntry::UserMessage(user_message) if user_message.id.is_some() + ) + }) + }) + } + fn authorize_tool_call( &mut self, tool_call_id: acp::ToolCallId, @@ -1940,6 +1962,16 @@ impl AcpThreadView { window: &mut Window, cx: &Context, ) -> 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 +2004,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 +2052,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 +2149,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 +2186,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 +2196,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 { @@ -4051,6 +4121,8 @@ impl AcpThreadView { .ml_1p5() }); + let full_path = path.display(path_style).to_string(); + let file_icon = FileIcons::get_icon(path.as_std_path(), cx) .map(Icon::from_path) .map(|icon| icon.color(Color::Muted).size(IconSize::Small)) @@ -4084,7 +4156,6 @@ impl AcpThreadView { .relative() .pr_8() .w_full() - .overflow_x_scroll() .child( h_flex() .id(("file-name-path", index)) @@ -4096,7 +4167,14 @@ impl AcpThreadView { .child(file_icon) .children(file_name) .children(file_path) - .tooltip(Tooltip::text("Go to File")) + .tooltip(move |_, cx| { + Tooltip::with_meta( + "Go to File", + None, + full_path.clone(), + cx, + ) + }) .on_click({ let buffer = buffer.clone(); cx.listener(move |this, _, window, cx| { @@ -4234,6 +4312,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 +5079,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 +6506,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::().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::().is_some()), + "Notification should be closed when thread view is dropped" + ); + } + async fn setup_thread_view( agent: impl AgentServer + 'static, cx: &mut TestAppContext, diff --git a/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs b/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs index 02269511bb9a4d9b95fe27b66e3ca0a9e5c498c5..e443df33b4ddcaeba32b9b2623c0fdca85fac51c 100644 --- a/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs +++ b/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs @@ -446,17 +446,17 @@ impl AddLlmProviderModal { }) } - fn on_tab(&mut self, _: &menu::SelectNext, window: &mut Window, _: &mut Context) { - window.focus_next(); + fn on_tab(&mut self, _: &menu::SelectNext, window: &mut Window, cx: &mut Context) { + window.focus_next(cx); } fn on_tab_prev( &mut self, _: &menu::SelectPrevious, window: &mut Window, - _: &mut Context, + cx: &mut Context, ) { - window.focus_prev(); + window.focus_prev(cx); } } @@ -493,7 +493,7 @@ impl Render for AddLlmProviderModal { .on_action(cx.listener(Self::on_tab)) .on_action(cx.listener(Self::on_tab_prev)) .capture_any_mouse_down(cx.listener(|this, _, window, cx| { - this.focus_handle(cx).focus(window); + this.focus_handle(cx).focus(window, cx); })) .child( Modal::new("configure-context-server", None) diff --git a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs index a0f0be886a1bf5e1485a2d36440b9f91648ef0c6..b30f1494f0d4dcbf3ef63cc7f549d16374f4899b 100644 --- a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs +++ b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs @@ -831,7 +831,7 @@ impl Render for ConfigureContextServerModal { }), ) .capture_any_mouse_down(cx.listener(|this, _, window, cx| { - this.focus_handle(cx).focus(window); + this.focus_handle(cx).focus(window, cx); })) .child( Modal::new("configure-context-server", None) diff --git a/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs b/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs index ed00b2b5c716fdf27abc1c9d7c5850b36fce830f..c7f395ebbd813cfd7c28f33a7e69ec32f6d90fca 100644 --- a/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs +++ b/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs @@ -156,7 +156,7 @@ impl ManageProfilesModal { cx.observe_global_in::(window, |this, window, cx| { if matches!(this.mode, Mode::ChooseProfile(_)) { this.mode = Mode::choose_profile(window, cx); - this.focus_handle(cx).focus(window); + this.focus_handle(cx).focus(window, cx); cx.notify(); } }); @@ -173,7 +173,7 @@ impl ManageProfilesModal { fn choose_profile(&mut self, window: &mut Window, cx: &mut Context) { self.mode = Mode::choose_profile(window, cx); - self.focus_handle(cx).focus(window); + self.focus_handle(cx).focus(window, cx); } fn new_profile( @@ -191,7 +191,7 @@ impl ManageProfilesModal { name_editor, base_profile_id, }); - self.focus_handle(cx).focus(window); + self.focus_handle(cx).focus(window, cx); } pub fn view_profile( @@ -209,7 +209,7 @@ impl ManageProfilesModal { delete_profile: NavigableEntry::focusable(cx), cancel_item: NavigableEntry::focusable(cx), }); - self.focus_handle(cx).focus(window); + self.focus_handle(cx).focus(window, cx); } fn configure_default_model( @@ -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(); - - 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, 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(), + }); + } } - } - }); + }); + } + }, + { + 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(), @@ -287,7 +300,7 @@ impl ManageProfilesModal { model_picker, _subscription: dismiss_subscription, }; - self.focus_handle(cx).focus(window); + self.focus_handle(cx).focus(window, cx); } fn configure_mcp_tools( @@ -323,7 +336,7 @@ impl ManageProfilesModal { tool_picker, _subscription: dismiss_subscription, }; - self.focus_handle(cx).focus(window); + self.focus_handle(cx).focus(window, cx); } fn configure_builtin_tools( @@ -364,7 +377,7 @@ impl ManageProfilesModal { tool_picker, _subscription: dismiss_subscription, }; - self.focus_handle(cx).focus(window); + self.focus_handle(cx).focus(window, cx); } fn confirm(&mut self, window: &mut Window, cx: &mut Context) { @@ -938,7 +951,7 @@ impl Render for ManageProfilesModal { .on_action(cx.listener(|this, _: &menu::Cancel, window, cx| this.cancel(window, cx))) .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| this.confirm(window, cx))) .capture_any_mouse_down(cx.listener(|this, _, window, cx| { - this.focus_handle(cx).focus(window); + this.focus_handle(cx).focus(window, cx); })) .on_mouse_down_out(cx.listener(|_this, _, _, cx| cx.emit(DismissEvent))) .child(match &self.mode { diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs index 06fce64819d3ce66b9e39f2b83cbebefb6ba9698..91d345b7ebb9dae5225626d7a054d0de1882dfe0 100644 --- a/crates/agent_ui/src/agent_diff.rs +++ b/crates/agent_ui/src/agent_diff.rs @@ -212,10 +212,10 @@ impl AgentDiffPane { .focus_handle(cx) .contains_focused(window, cx) { - self.focus_handle.focus(window); + self.focus_handle.focus(window, cx); } else if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() { self.editor.update(cx, |editor, cx| { - editor.focus_handle(cx).focus(window); + editor.focus_handle(cx).focus(window, cx); }); } } @@ -874,12 +874,12 @@ impl AgentDiffToolbar { match active_item { AgentDiffToolbarItem::Pane(agent_diff) => { if let Some(agent_diff) = agent_diff.upgrade() { - agent_diff.focus_handle(cx).focus(window); + agent_diff.focus_handle(cx).focus(window, cx); } } AgentDiffToolbarItem::Editor { editor, .. } => { if let Some(editor) = editor.upgrade() { - editor.read(cx).focus_handle(cx).focus(window); + editor.read(cx).focus_handle(cx).focus(window, cx); } } } diff --git a/crates/agent_ui/src/agent_model_selector.rs b/crates/agent_ui/src/agent_model_selector.rs index 0b169a7f5192a8bdcbe239bec0b74a35b4e869e7..856f75c9a0d886c8bb11d20a2684cff308324856 100644 --- a/crates/agent_ui/src/agent_model_selector.rs +++ b/crates/agent_ui/src/agent_model_selector.rs @@ -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, diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 37c771d0e36d088a1fa8bec136ab6cb45dc1c2e8..a050f75120cd73949251c09c8424314e3616c705 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -2,6 +2,7 @@ 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, @@ -287,7 +288,7 @@ impl ActiveView { } } - pub fn native_agent( + fn native_agent( fs: Arc, prompt_store: Option>, history_store: Entity, @@ -442,6 +443,7 @@ pub struct AgentPanel { pending_serialization: Option>>, onboarding: Entity, selected_agent: AgentType, + show_trust_workspace_message: bool, } impl AgentPanel { @@ -692,6 +694,7 @@ impl AgentPanel { history_store, selected_agent: AgentType::default(), loading: false, + show_trust_workspace_message: false, }; // Initial sync of agent servers from extensions @@ -819,7 +822,7 @@ impl AgentPanel { window, cx, ); - text_thread_editor.focus_handle(cx).focus(window); + text_thread_editor.focus_handle(cx).focus(window, cx); } fn external_thread( @@ -885,36 +888,21 @@ 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); - } - - 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, + 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); } @@ -947,7 +935,7 @@ impl AgentPanel { if let Some(thread_view) = self.active_thread_view() { thread_view.update(cx, |view, cx| { view.expand_message_editor(&ExpandMessageEditor, window, cx); - view.focus_handle(cx).focus(window); + view.focus_handle(cx).focus(window, cx); }); } } @@ -1028,12 +1016,12 @@ impl AgentPanel { match &self.active_view { ActiveView::ExternalAgentThread { thread_view } => { - thread_view.focus_handle(cx).focus(window); + thread_view.focus_handle(cx).focus(window, cx); } ActiveView::TextThread { text_thread_editor, .. } => { - text_thread_editor.focus_handle(cx).focus(window); + text_thread_editor.focus_handle(cx).focus(window, cx); } ActiveView::History | ActiveView::Configuration => {} } @@ -1181,7 +1169,7 @@ impl AgentPanel { Self::handle_agent_configuration_event, )); - configuration.focus_handle(cx).focus(window); + configuration.focus_handle(cx).focus(window, cx); } } @@ -1317,7 +1305,7 @@ impl AgentPanel { } if focus { - self.focus_handle(cx).focus(window); + self.focus_handle(cx).focus(window, cx); } } @@ -1477,6 +1465,47 @@ impl AgentPanel { cx, ); } + + fn _external_thread( + &mut self, + server: Rc, + resume_thread: Option, + summarize_thread: Option, + workspace: WeakEntity, + project: Entity, + loading: bool, + ext_agent: ExternalAgent, + window: &mut Window, + cx: &mut Context, + ) { + 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 { @@ -1591,14 +1620,19 @@ impl AgentPanel { let content = match &self.active_view { ActiveView::ExternalAgentThread { thread_view } => { + let is_generating_title = thread_view + .read(cx) + .as_native_thread(cx) + .map_or(false, |t| t.read(cx).is_generating_title()); + if let Some(title_editor) = thread_view.read(cx).title_editor() { - div() + let container = div() .w_full() .on_action({ let thread_view = thread_view.downgrade(); move |_: &menu::Confirm, window, cx| { if let Some(thread_view) = thread_view.upgrade() { - thread_view.focus_handle(cx).focus(window); + thread_view.focus_handle(cx).focus(window, cx); } } }) @@ -1606,12 +1640,25 @@ impl AgentPanel { let thread_view = thread_view.downgrade(); move |_: &editor::actions::Cancel, window, cx| { if let Some(thread_view) = thread_view.upgrade() { - thread_view.focus_handle(cx).focus(window); + thread_view.focus_handle(cx).focus(window, cx); } } }) - .child(title_editor) - .into_any_element() + .child(title_editor); + + if is_generating_title { + container + .with_animation( + "generating_title", + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(pulsating_between(0.4, 0.8)), + |div, delta| div.opacity(delta), + ) + .into_any_element() + } else { + container.into_any_element() + } } else { Label::new(thread_view.read(cx).title(cx)) .color(Color::Muted) @@ -1641,6 +1688,13 @@ impl AgentPanel { Label::new(LOADING_SUMMARY_PLACEHOLDER) .truncate() .color(Color::Muted) + .with_animation( + "generating_title", + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(pulsating_between(0.4, 0.8)), + |label, delta| label.alpha(delta), + ) .into_any_element() } } @@ -1684,6 +1738,25 @@ impl AgentPanel { .into_any() } + fn handle_regenerate_thread_title(thread_view: Entity, cx: &mut App) { + thread_view.update(cx, |thread_view, cx| { + if let Some(thread) = thread_view.as_native_thread(cx) { + thread.update(cx, |thread, cx| { + thread.generate_title(cx); + }); + } + }); + } + + fn handle_regenerate_text_thread_title( + text_thread_editor: Entity, + cx: &mut App, + ) { + text_thread_editor.update(cx, |text_thread_editor, cx| { + text_thread_editor.regenerate_summary(cx); + }); + } + fn render_panel_options_menu( &self, window: &mut Window, @@ -1703,6 +1776,35 @@ impl AgentPanel { let selected_agent = self.selected_agent.clone(); + let text_thread_view = match &self.active_view { + ActiveView::TextThread { + text_thread_editor, .. + } => Some(text_thread_editor.clone()), + _ => None, + }; + let text_thread_with_messages = match &self.active_view { + ActiveView::TextThread { + text_thread_editor, .. + } => text_thread_editor + .read(cx) + .text_thread() + .read(cx) + .messages(cx) + .any(|message| message.role == language_model::Role::Assistant), + _ => false, + }; + + let thread_view = match &self.active_view { + ActiveView::ExternalAgentThread { thread_view } => Some(thread_view.clone()), + _ => None, + }; + let thread_with_messages = match &self.active_view { + ActiveView::ExternalAgentThread { thread_view } => { + thread_view.read(cx).has_user_submitted_prompt(cx) + } + _ => false, + }; + PopoverMenu::new("agent-options-menu") .trigger_with_tooltip( IconButton::new("agent-options-menu", IconName::Ellipsis) @@ -1725,6 +1827,7 @@ impl AgentPanel { move |window, cx| { Some(ContextMenu::build(window, cx, |mut menu, _window, _| { menu = menu.context(focus_handle.clone()); + if let Some(usage) = usage { menu = menu .header_with_link("Prompt Usage", "Manage", account_url.clone()) @@ -1762,6 +1865,38 @@ impl AgentPanel { .separator() } + if thread_with_messages | text_thread_with_messages { + menu = menu.header("Current Thread"); + + if let Some(text_thread_view) = text_thread_view.as_ref() { + menu = menu + .entry("Regenerate Thread Title", None, { + let text_thread_view = text_thread_view.clone(); + move |_, cx| { + Self::handle_regenerate_text_thread_title( + text_thread_view.clone(), + cx, + ); + } + }) + .separator(); + } + + if let Some(thread_view) = thread_view.as_ref() { + menu = menu + .entry("Regenerate Thread Title", None, { + let thread_view = thread_view.clone(); + move |_, cx| { + Self::handle_regenerate_thread_title( + thread_view.clone(), + cx, + ); + } + }) + .separator(); + } + } + menu = menu .header("MCP Servers") .action( @@ -2557,6 +2692,38 @@ impl AgentPanel { } } + fn render_workspace_trust_message(&self, cx: &Context) -> Option { + 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 +2776,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 diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 71918bfd65f5333d73291ec90995f0844c6904a4..54bded7e1d0a0645b8796a1f22f39292c12746b6 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -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. @@ -481,6 +484,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(), diff --git a/crates/agent_ui/src/buffer_codegen.rs b/crates/agent_ui/src/buffer_codegen.rs index 25395278745a9eb18fbbfa1cd920af3e3b26e24d..87ce6d386b38f31a0d7b550aab00bb766ce75010 100644 --- a/crates/agent_ui/src/buffer_codegen.rs +++ b/crates/agent_ui/src/buffer_codegen.rs @@ -441,7 +441,8 @@ impl CodegenAlternative { }) .boxed_local() }; - self.generation = self.handle_stream(model, stream, cx); + self.generation = + self.handle_stream(model, /* strip_invalid_spans: */ true, stream, cx); } Ok(()) @@ -629,6 +630,7 @@ impl CodegenAlternative { pub fn handle_stream( &mut self, model: Arc, + strip_invalid_spans: bool, stream: impl 'static + Future>, cx: &mut Context, ) -> Task<()> { @@ -713,10 +715,16 @@ impl CodegenAlternative { let mut response_latency = None; let request_start = Instant::now(); let diff = async { - let chunks = StripInvalidSpans::new( - stream?.stream.map_err(|error| error.into()), - ); - futures::pin_mut!(chunks); + let raw_stream = stream?.stream.map_err(|error| error.into()); + + let stripped; + let mut chunks: Pin> + Send>> = + if strip_invalid_spans { + stripped = StripInvalidSpans::new(raw_stream); + Box::pin(stripped) + } else { + Box::pin(raw_stream) + }; let mut diff = StreamingDiff::new(selected_text.to_string()); let mut line_diff = LineDiff::default(); @@ -1307,7 +1315,12 @@ impl CodegenAlternative { let Some(task) = codegen .update(cx, move |codegen, cx| { - codegen.handle_stream(model, async { Ok(language_model_text_stream) }, cx) + codegen.handle_stream( + model, + /* strip_invalid_spans: */ false, + async { Ok(language_model_text_stream) }, + cx, + ) }) .ok() else { @@ -1846,6 +1859,7 @@ mod tests { codegen.update(cx, |codegen, cx| { codegen.generation = codegen.handle_stream( model, + /* strip_invalid_spans: */ false, future::ready(Ok(LanguageModelTextStream { message_id: None, stream: chunks_rx.map(Ok).boxed(), diff --git a/crates/agent_ui/src/completion_provider.rs b/crates/agent_ui/src/completion_provider.rs index a2b6e0510e25c12cfbfb98d3e72cb0d2c830887a..206a2b3282b5471e8d5e8d18788519c3853dca55 100644 --- a/crates/agent_ui/src/completion_provider.rs +++ b/crates/agent_ui/src/completion_provider.rs @@ -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::>() diff --git a/crates/agent_ui/src/favorite_models.rs b/crates/agent_ui/src/favorite_models.rs new file mode 100644 index 0000000000000000000000000000000000000000..d8d4db976fc9916973eedd9174925fba75a06b2b --- /dev/null +++ b/crates/agent_ui/src/favorite_models.rs @@ -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) -> 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, + should_be_favorite: bool, + fs: Arc, + 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, + 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); + } + }); +} diff --git a/crates/agent_ui/src/inline_assistant.rs b/crates/agent_ui/src/inline_assistant.rs index 6e3ab7a162bc69a5b0ec081b060b4a2ba08b09aa..052d8598a76d1044c6d97b5378041b5cd12e23b3 100644 --- a/crates/agent_ui/src/inline_assistant.rs +++ b/crates/agent_ui/src/inline_assistant.rs @@ -1197,7 +1197,7 @@ impl InlineAssistant { assist .editor - .update(cx, |editor, cx| window.focus(&editor.focus_handle(cx))) + .update(cx, |editor, cx| window.focus(&editor.focus_handle(cx), cx)) .ok(); } @@ -1209,7 +1209,7 @@ impl InlineAssistant { if let Some(decorations) = assist.decorations.as_ref() { decorations.prompt_editor.update(cx, |prompt_editor, cx| { prompt_editor.editor.update(cx, |editor, cx| { - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); editor.select_all(&SelectAll, window, cx); }) }); diff --git a/crates/agent_ui/src/inline_prompt_editor.rs b/crates/agent_ui/src/inline_prompt_editor.rs index 51e65447b2f888ab70f5942baca108134b239593..8d96d56ea67cc9366df420b23e2221636d3450fb 100644 --- a/crates/agent_ui/src/inline_prompt_editor.rs +++ b/crates/agent_ui/src/inline_prompt_editor.rs @@ -357,7 +357,7 @@ impl PromptEditor { creases = insert_message_creases(&mut editor, &existing_creases, window, cx); if focus { - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); } editor }); @@ -844,26 +844,59 @@ impl PromptEditor { 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 PromptEditor { } fn render_close_button(&self, cx: &mut Context) -> 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() } diff --git a/crates/agent_ui/src/language_model_selector.rs b/crates/agent_ui/src/language_model_selector.rs index e7e77c8f28c96bcdff07526973756475d705aecb..421161b92f2ad4179d15601e746d4a0ace51d4e5 100644 --- a/crates/agent_ui/src/language_model_selector.rs +++ b/crates/agent_ui/src/language_model_selector.rs @@ -1,26 +1,32 @@ use std::{cmp::Reverse, sync::Arc}; -use collections::IndexMap; +use agent_settings::AgentSettings; +use collections::{HashMap, HashSet, IndexMap}; use futures::{StreamExt, channel::mpsc}; use fuzzy::{StringMatch, StringMatchCandidate, match_strings}; use gpui::{Action, AnyElement, App, BackgroundExecutor, DismissEvent, FocusHandle, Task}; use language_model::{ - AuthenticateError, ConfiguredModel, LanguageModel, LanguageModelProvider, + AuthenticateError, ConfiguredModel, LanguageModel, LanguageModelId, LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry, }; use ordered_float::OrderedFloat; use picker::{Picker, PickerDelegate}; -use ui::{KeyBinding, ListItem, ListItemSpacing, prelude::*}; +use settings::Settings; +use ui::prelude::*; use zed_actions::agent::OpenSettings; +use crate::ui::{ModelSelectorFooter, ModelSelectorHeader, ModelSelectorListItem}; + type OnModelChanged = Arc, &mut App) + 'static>; type GetActiveModel = Arc Option + 'static>; +type OnToggleFavorite = Arc, bool, &App) + 'static>; pub type LanguageModelSelector = Picker; pub fn language_model_selector( get_active_model: impl Fn(&App) -> Option + 'static, on_model_changed: impl Fn(Arc, &mut App) + 'static, + on_toggle_favorite: impl Fn(Arc, bool, &App) + 'static, popover_styles: bool, focus_handle: FocusHandle, window: &mut Window, @@ -29,6 +35,7 @@ pub fn language_model_selector( let delegate = LanguageModelPickerDelegate::new( get_active_model, on_model_changed, + on_toggle_favorite, popover_styles, focus_handle, window, @@ -46,9 +53,17 @@ pub fn language_model_selector( } fn all_models(cx: &App) -> GroupedModels { - let providers = LanguageModelRegistry::global(cx) - .read(cx) - .visible_providers(); + let lm_registry = LanguageModelRegistry::global(cx).read(cx); + let providers = lm_registry.visible_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() @@ -56,10 +71,7 @@ fn all_models(cx: &App) -> GroupedModels { provider .recommended_models(cx) .into_iter() - .map(|model| ModelInfo { - model, - icon: ProviderIcon::from_provider(provider.as_ref()), - }) + .map(|model| ModelInfo::new(&**provider, model, &favorites_index)) }) .collect(); @@ -69,16 +81,15 @@ fn all_models(cx: &App) -> GroupedModels { provider .provided_models(cx) .into_iter() - .map(|model| ModelInfo { - model, - icon: ProviderIcon::from_provider(provider.as_ref()), - }) + .map(|model| ModelInfo::new(&**provider, model, &favorites_index)) }) .collect(); GroupedModels::new(all, recommended) } +type FavoritesIndex = HashMap>; + #[derive(Clone)] enum ProviderIcon { Name(IconName), @@ -99,11 +110,31 @@ impl ProviderIcon { struct ModelInfo { model: Arc, icon: ProviderIcon, + is_favorite: bool, +} + +impl ModelInfo { + fn new( + provider: &dyn LanguageModelProvider, + model: Arc, + favorites_index: &FavoritesIndex, + ) -> Self { + let is_favorite = favorites_index + .get(&provider.id()) + .map_or(false, |set| set.contains(&model.id())); + + Self { + model, + icon: ProviderIcon::from_provider(provider), + is_favorite, + } + } } pub struct LanguageModelPickerDelegate { on_model_changed: OnModelChanged, get_active_model: GetActiveModel, + on_toggle_favorite: OnToggleFavorite, all_models: Arc, filtered_entries: Vec, selected_index: usize, @@ -117,6 +148,7 @@ impl LanguageModelPickerDelegate { fn new( get_active_model: impl Fn(&App) -> Option + 'static, on_model_changed: impl Fn(Arc, &mut App) + 'static, + on_toggle_favorite: impl Fn(Arc, bool, &App) + 'static, popover_styles: bool, focus_handle: FocusHandle, window: &mut Window, @@ -132,6 +164,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), _refresh_models_task: { // Create a channel to signal when models need refreshing @@ -249,15 +282,57 @@ impl LanguageModelPickerDelegate { pub fn active_model(&self, cx: &App) -> Option { (self.get_active_model)(cx) } + + pub fn cycle_favorite_models(&mut self, window: &mut Window, cx: &mut Context>) { + 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, recommended: Vec, all: IndexMap>, } impl GroupedModels { pub fn new(all: Vec, recommended: Vec) -> Self { + let favorites = all + .iter() + .filter(|info| info.is_favorite) + .cloned() + .collect(); + let mut all_by_provider: IndexMap<_, Vec> = IndexMap::default(); for model in all { let provider = model.model.provider_id(); @@ -269,6 +344,7 @@ impl GroupedModels { } Self { + favorites, recommended, all: all_by_provider, } @@ -277,13 +353,18 @@ impl GroupedModels { fn entries(&self) -> Vec { 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() { @@ -293,12 +374,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 } } @@ -499,23 +579,9 @@ impl PickerDelegate for LanguageModelPickerDelegate { cx: &mut Context>, ) -> Option { match self.filtered_entries.get(ix)? { - LanguageModelPickerEntry::Separator(title) => Some( - div() - .px_2() - .pb_1() - .when(ix > 1, |this| { - this.mt_1() - .pt_2() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - }) - .child( - Label::new(title) - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - .into_any_element(), - ), + LanguageModelPickerEntry::Separator(title) => { + Some(ModelSelectorHeader::new(title, ix > 1).into_any_element()) + } LanguageModelPickerEntry::Model(model_info) => { let active_model = (self.get_active_model)(cx); let active_provider_id = active_model.as_ref().map(|m| m.provider.id()); @@ -524,40 +590,23 @@ 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 model_icon_color = if is_selected { - Color::Accent - } else { - Color::Muted + 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( - ListItem::new(ix) - .inset(true) - .spacing(ListItemSpacing::Sparse) - .toggle_state(selected) - .child( - h_flex() - .w_full() - .gap_1p5() - .child(match &model_info.icon { - ProviderIcon::Name(icon_name) => Icon::new(*icon_name) - .color(model_icon_color) - .size(IconSize::Small), - ProviderIcon::Path(icon_path) => { - Icon::from_external_svg(icon_path.clone()) - .color(model_icon_color) - .size(IconSize::Small) - } - }) - .child(Label::new(model_info.model.name().0).truncate()), - ) - .end_slot(div().pr_3().when(is_selected, |this| { - this.child( - Icon::new(IconName::Check) - .color(Color::Accent) - .size(IconSize::Small), - ) - })) + ModelSelectorListItem::new(ix, model_info.model.name().0) + .map(|this| match &model_info.icon { + ProviderIcon::Name(icon_name) => this.icon(*icon_name), + ProviderIcon::Path(icon_path) => this.icon_path(icon_path.clone()), + }) + .is_selected(is_selected) + .is_focused(selected) + .is_favorite(is_favorite) + .on_toggle_favorite(handle_action_click) .into_any_element(), ) } @@ -567,7 +616,7 @@ impl PickerDelegate for LanguageModelPickerDelegate { fn render_footer( &self, _window: &mut Window, - cx: &mut Context>, + _cx: &mut Context>, ) -> Option { let focus_handle = self.focus_handle.clone(); @@ -575,26 +624,7 @@ impl PickerDelegate for LanguageModelPickerDelegate { return None; } - Some( - h_flex() - .w_full() - .p_1p5() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .child( - Button::new("configure", "Configure") - .full_width() - .style(ButtonStyle::Outlined) - .key_binding( - KeyBinding::for_action_in(&OpenSettings, &focus_handle, cx) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click(|_, window, cx| { - window.dispatch_action(OpenSettings.boxed_clone(), cx); - }), - ) - .into_any(), - ) + Some(ModelSelectorFooter::new(OpenSettings.boxed_clone(), focus_handle).into_any_element()) } } @@ -693,11 +723,24 @@ mod tests { } fn create_models(model_specs: Vec<(&str, &str)>) -> Vec { + create_models_with_favorites(model_specs, vec![]) + } + + fn create_models_with_favorites( + model_specs: Vec<(&str, &str)>, + favorites: Vec<(&str, &str)>, + ) -> Vec { model_specs .into_iter() - .map(|(provider, name)| ModelInfo { - model: Arc::new(TestLanguageModel::new(name, provider)), - icon: ProviderIcon::Name(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: ProviderIcon::Name(IconName::Ai), + is_favorite, + } }) .collect() } @@ -835,4 +878,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"], + ); + } } diff --git a/crates/agent_ui/src/terminal_inline_assistant.rs b/crates/agent_ui/src/terminal_inline_assistant.rs index 84a74242b80d0b2f8479b3c6dbca1c7d0bb2cb6d..cacbc316bb84e74e5c369451791f777a9bf58e82 100644 --- a/crates/agent_ui/src/terminal_inline_assistant.rs +++ b/crates/agent_ui/src/terminal_inline_assistant.rs @@ -127,7 +127,7 @@ impl TerminalInlineAssistant { if let Some(prompt_editor) = assist.prompt_editor.as_ref() { prompt_editor.update(cx, |this, cx| { this.editor.update(cx, |editor, cx| { - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); editor.select_all(&SelectAll, window, cx); }); }); @@ -292,7 +292,7 @@ impl TerminalInlineAssistant { .terminal .update(cx, |this, cx| { this.clear_block_below_cursor(cx); - this.focus_handle(cx).focus(window); + this.focus_handle(cx).focus(window, cx); }) .log_err(); @@ -369,7 +369,7 @@ impl TerminalInlineAssistant { .terminal .update(cx, |this, cx| { this.clear_block_below_cursor(cx); - this.focus_handle(cx).focus(window); + this.focus_handle(cx).focus(window, cx); }) .is_ok() } diff --git a/crates/agent_ui/src/text_thread_editor.rs b/crates/agent_ui/src/text_thread_editor.rs index f87ca845cab0fe562968ae7893b63a278c4dfdd8..f1253cfe5b142771df0d48c9ccb037aba82d8461 100644 --- a/crates/agent_ui/src/text_thread_editor.rs +++ b/crates/agent_ui/src/text_thread_editor.rs @@ -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, @@ -1325,7 +1341,7 @@ impl TextThreadEditor { if let Some((text, _)) = Self::get_selection_or_code_block(&context_editor_view, cx) { active_editor_view.update(cx, |editor, cx| { editor.insert(&text, window, cx); - editor.focus_handle(cx).focus(window); + editor.focus_handle(cx).focus(window, cx); }) } } @@ -2196,6 +2212,7 @@ 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 { @@ -2210,6 +2227,46 @@ impl TextThreadEditor { .color(color) .size(IconSize::XSmall); + 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") @@ -2226,9 +2283,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, ) @@ -2588,6 +2643,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() diff --git a/crates/agent_ui/src/ui.rs b/crates/agent_ui/src/ui.rs index 6c3d8bc1427092b0d0380cf286da1706337932fe..b484fdb6c6c480f1cffe78eea7a51f635d3906a1 100644 --- a/crates/agent_ui/src/ui.rs +++ b/crates/agent_ui/src/ui.rs @@ -4,8 +4,8 @@ mod burn_mode_tooltip; mod claude_code_onboarding_modal; mod end_trial_upsell; mod hold_for_default; +mod model_selector_components; mod onboarding_modal; - mod usage_callout; pub use acp_onboarding_modal::*; @@ -14,6 +14,6 @@ pub use burn_mode_tooltip::*; pub use claude_code_onboarding_modal::*; pub use end_trial_upsell::*; pub use hold_for_default::*; +pub use model_selector_components::*; pub use onboarding_modal::*; - pub use usage_callout::*; diff --git a/crates/agent_ui/src/ui/acp_onboarding_modal.rs b/crates/agent_ui/src/ui/acp_onboarding_modal.rs index 8433904fb3b540c2d78c8634b7a6755303d6e15c..e48a36bd5af3eff578e230195dc2247900977173 100644 --- a/crates/agent_ui/src/ui/acp_onboarding_modal.rs +++ b/crates/agent_ui/src/ui/acp_onboarding_modal.rs @@ -222,8 +222,8 @@ impl Render for AcpOnboardingModal { acp_onboarding_event!("Canceled", trigger = "Action"); cx.emit(DismissEvent); })) - .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| { - this.focus_handle.focus(window); + .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| { + this.focus_handle.focus(window, cx); })) .child(illustration) .child( diff --git a/crates/agent_ui/src/ui/claude_code_onboarding_modal.rs b/crates/agent_ui/src/ui/claude_code_onboarding_modal.rs index 06980f18977aefe228bb7f09962e69fe2b3a5068..a8f007666d8957a7195fdf36b612b578b16f543c 100644 --- a/crates/agent_ui/src/ui/claude_code_onboarding_modal.rs +++ b/crates/agent_ui/src/ui/claude_code_onboarding_modal.rs @@ -230,8 +230,8 @@ impl Render for ClaudeCodeOnboardingModal { claude_code_onboarding_event!("Canceled", trigger = "Action"); cx.emit(DismissEvent); })) - .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| { - this.focus_handle.focus(window); + .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| { + this.focus_handle.focus(window, cx); })) .child(illustration) .child( diff --git a/crates/agent_ui/src/ui/model_selector_components.rs b/crates/agent_ui/src/ui/model_selector_components.rs new file mode 100644 index 0000000000000000000000000000000000000000..59b104019dd78074f0d953d05a9cbf9f40a0bbed --- /dev/null +++ b/crates/agent_ui/src/ui/model_selector_components.rs @@ -0,0 +1,189 @@ +use gpui::{Action, FocusHandle, prelude::*}; +use ui::{ElevationIndex, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*}; + +#[derive(IntoElement)] +pub struct ModelSelectorHeader { + title: SharedString, + has_border: bool, +} + +impl ModelSelectorHeader { + pub fn new(title: impl Into, has_border: bool) -> Self { + Self { + title: title.into(), + has_border, + } + } +} + +impl RenderOnce for ModelSelectorHeader { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + div() + .px_2() + .pb_1() + .when(self.has_border, |this| { + this.mt_1() + .pt_2() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + }) + .child( + Label::new(self.title) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + } +} + +enum ModelIcon { + Name(IconName), + Path(SharedString), +} + +#[derive(IntoElement)] +pub struct ModelSelectorListItem { + index: usize, + title: SharedString, + icon: Option, + is_selected: bool, + is_focused: bool, + is_favorite: bool, + on_toggle_favorite: Option>, +} + +impl ModelSelectorListItem { + pub fn new(index: usize, title: impl Into) -> Self { + Self { + index, + title: title.into(), + icon: None, + is_selected: false, + is_focused: false, + is_favorite: false, + on_toggle_favorite: None, + } + } + + pub fn icon(mut self, icon: IconName) -> Self { + self.icon = Some(ModelIcon::Name(icon)); + self + } + + pub fn icon_path(mut self, path: SharedString) -> Self { + self.icon = Some(ModelIcon::Path(path)); + self + } + + pub fn is_selected(mut self, is_selected: bool) -> Self { + self.is_selected = is_selected; + self + } + + pub fn is_focused(mut self, is_focused: bool) -> Self { + 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 { + fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { + let model_icon_color = if self.is_selected { + Color::Accent + } else { + Color::Muted + }; + + let is_favorite = self.is_favorite; + + ListItem::new(self.index) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .toggle_state(self.is_focused) + .child( + h_flex() + .w_full() + .gap_1p5() + .when_some(self.icon, |this, icon| { + this.child(match icon { + ModelIcon::Name(icon_name) => Icon::new(icon_name) + .color(model_icon_color) + .size(IconSize::Small), + ModelIcon::Path(icon_path) => Icon::from_external_svg(icon_path) + .color(model_icon_color) + .size(IconSize::Small), + }) + }) + .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)) + })) + .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)), + ) + } + })) + } +} + +#[derive(IntoElement)] +pub struct ModelSelectorFooter { + action: Box, + focus_handle: FocusHandle, +} + +impl ModelSelectorFooter { + pub fn new(action: Box, focus_handle: FocusHandle) -> Self { + Self { + action, + focus_handle, + } + } +} + +impl RenderOnce for ModelSelectorFooter { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let action = self.action; + let focus_handle = self.focus_handle; + + h_flex() + .w_full() + .p_1p5() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child( + Button::new("configure", "Configure") + .full_width() + .style(ButtonStyle::Outlined) + .key_binding( + KeyBinding::for_action_in(action.as_ref(), &focus_handle, cx) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(move |_, window, cx| { + window.dispatch_action(action.boxed_clone(), cx); + }), + ) + } +} diff --git a/crates/agent_ui/src/ui/onboarding_modal.rs b/crates/agent_ui/src/ui/onboarding_modal.rs index ad404afa784974631f914e6fece2de6b6c7d6a46..b8ec2b00657efca29fede32a5cc23b669ede66e7 100644 --- a/crates/agent_ui/src/ui/onboarding_modal.rs +++ b/crates/agent_ui/src/ui/onboarding_modal.rs @@ -83,8 +83,8 @@ impl Render for AgentOnboardingModal { agent_onboarding_event!("Canceled", trigger = "Action"); cx.emit(DismissEvent); })) - .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| { - this.focus_handle.focus(window); + .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| { + this.focus_handle.focus(window, cx); })) .child( div() diff --git a/crates/anthropic/src/anthropic.rs b/crates/anthropic/src/anthropic.rs index 275486c275ade25d07d10c007093d7730d2b59ef..683cd3140722cd61c0ebb40e4cc77062fe9f22b5 100644 --- a/crates/anthropic/src/anthropic.rs +++ b/crates/anthropic/src/anthropic.rs @@ -1052,6 +1052,71 @@ pub fn parse_prompt_too_long(message: &str) -> Option { .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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub system: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tools: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub thinking: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_choice: Option, +} + +/// 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 { + 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 { diff --git a/crates/bedrock/src/bedrock.rs b/crates/bedrock/src/bedrock.rs index ec0b4070906fdfd31195668312b3e7b425cd28ee..744dde38076a5a12c9bc957a75e2435b1b753d96 100644 --- a/crates/bedrock/src/bedrock.rs +++ b/crates/bedrock/src/bedrock.rs @@ -87,7 +87,7 @@ pub async fn stream_completion( Ok(None) => None, Err(err) => Some(( Err(BedrockError::ClientError(anyhow!( - "{:?}", + "{}", aws_sdk_bedrockruntime::error::DisplayErrorContext(err) ))), stream, diff --git a/crates/buffer_diff/src/buffer_diff.rs b/crates/buffer_diff/src/buffer_diff.rs index 55de3f968bc1cc9ff5d640b0d3ca30221e413632..22525096d3cbca456aa114b5acc9b4239b570dda 100644 --- a/crates/buffer_diff/src/buffer_diff.rs +++ b/crates/buffer_diff/src/buffer_diff.rs @@ -2155,7 +2155,7 @@ mod tests { let range = diff_1.inner.compare(&empty_diff.inner, &buffer).unwrap(); assert_eq!(range.to_point(&buffer), Point::new(0, 0)..Point::new(8, 0)); - // Edit does not affect the diff. + // Edit does affects the diff because it recalculates word diffs. buffer.edit_via_marked_text( &" one @@ -2170,7 +2170,14 @@ mod tests { .unindent(), ); let diff_2 = BufferDiffSnapshot::new_sync(buffer.clone(), base_text.clone(), cx); - assert_eq!(None, diff_2.inner.compare(&diff_1.inner, &buffer)); + assert_eq!( + Point::new(4, 0)..Point::new(5, 0), + diff_2 + .inner + .compare(&diff_1.inner, &buffer) + .unwrap() + .to_point(&buffer) + ); // Edit turns a deletion hunk into a modification. buffer.edit_via_marked_text( diff --git a/crates/collab/src/api/contributors.rs b/crates/collab/src/api/contributors.rs index e09ac4f8b7355cf143b221308204742139308133..ce318b15295ebe5c777597a6d3c6106e57af8e05 100644 --- a/crates/collab/src/api/contributors.rs +++ b/crates/collab/src/api/contributors.rs @@ -111,6 +111,9 @@ struct CopilotSweAgentBot; impl CopilotSweAgentBot { const LOGIN: &'static str = "copilot-swe-agent[bot]"; const USER_ID: i32 = 198982749; + /// The alias of the GitHub copilot user. Although https://api.github.com/users/copilot + /// yields a 404, GitHub still refers to the copilot bot user as @Copilot in some cases. + const NAME_ALIAS: &'static str = "copilot"; /// Returns the `created_at` timestamp for the Dependabot bot user. fn created_at() -> &'static NaiveDateTime { @@ -125,7 +128,9 @@ impl CopilotSweAgentBot { /// Returns whether the given contributor selector corresponds to the Copilot bot user. fn is_copilot_bot(contributor: &ContributorSelector) -> bool { match contributor { - ContributorSelector::GitHubLogin { github_login } => github_login == Self::LOGIN, + ContributorSelector::GitHubLogin { github_login } => { + github_login == Self::LOGIN || github_login == Self::NAME_ALIAS + } ContributorSelector::GitHubUserId { github_user_id } => { github_user_id == &Self::USER_ID } diff --git a/crates/collab/src/tests/remote_editing_collaboration_tests.rs b/crates/collab/src/tests/remote_editing_collaboration_tests.rs index 04403de9fa0883e9d738f3d96b9b2acdf1d66967..5342b0bbd4b11afb24ccbaa6d4bf17df036cec76 100644 --- a/crates/collab/src/tests/remote_editing_collaboration_tests.rs +++ b/crates/collab/src/tests/remote_editing_collaboration_tests.rs @@ -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,261 @@ 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); + project::trusted_worktrees::init(HashMap::default(), None, None, cx); + }); + server_cx.update(|cx| { + release_channel::init(semver::Version::new(0, 0, 0), cx); + project::trusted_worktrees::init(HashMap::default(), None, None, 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::( + 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::>() + }); + 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" + ); +} diff --git a/crates/collab/src/tests/test_server.rs b/crates/collab/src/tests/test_server.rs index 959d54cf0864ccddf7273cca0276d18d4f59308b..3abbd1a014b556db02e70b42c239729100f17eb8 100644 --- a/crates/collab/src/tests/test_server.rs +++ b/crates/collab/src/tests/test_server.rs @@ -761,6 +761,7 @@ impl TestClient { &self, root_path: impl AsRef, ssh: Entity, + init_worktree_trust: bool, cx: &mut TestAppContext, ) -> (Entity, 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, ) }) diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 2f1e2842cbd2f5024df0608578b7cb7f4bbc158d..0ae4ff270bd672ca028d638484b9a23f5981de1a 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -1252,7 +1252,7 @@ impl CollabPanel { context_menu }); - window.focus(&context_menu.focus_handle(cx)); + window.focus(&context_menu.focus_handle(cx), cx); let subscription = cx.subscribe_in( &context_menu, window, @@ -1424,7 +1424,7 @@ impl CollabPanel { context_menu }); - window.focus(&context_menu.focus_handle(cx)); + window.focus(&context_menu.focus_handle(cx), cx); let subscription = cx.subscribe_in( &context_menu, window, @@ -1487,7 +1487,7 @@ impl CollabPanel { }) }); - window.focus(&context_menu.focus_handle(cx)); + window.focus(&context_menu.focus_handle(cx), cx); let subscription = cx.subscribe_in( &context_menu, window, @@ -1521,9 +1521,9 @@ impl CollabPanel { if cx.stop_active_drag(window) { return; } else if self.take_editing_state(window, cx) { - window.focus(&self.filter_editor.focus_handle(cx)); + window.focus(&self.filter_editor.focus_handle(cx), cx); } else if !self.reset_filter_editor_text(window, cx) { - self.focus_handle.focus(window); + self.focus_handle.focus(window, cx); } if self.context_menu.is_some() { @@ -1826,7 +1826,7 @@ impl CollabPanel { }); self.update_entries(false, cx); self.select_channel_editor(); - window.focus(&self.channel_name_editor.focus_handle(cx)); + window.focus(&self.channel_name_editor.focus_handle(cx), cx); cx.notify(); } @@ -1851,7 +1851,7 @@ impl CollabPanel { }); self.update_entries(false, cx); self.select_channel_editor(); - window.focus(&self.channel_name_editor.focus_handle(cx)); + window.focus(&self.channel_name_editor.focus_handle(cx), cx); cx.notify(); } @@ -1900,7 +1900,7 @@ impl CollabPanel { editor.set_text(channel.name.clone(), window, cx); editor.select_all(&Default::default(), window, cx); }); - window.focus(&self.channel_name_editor.focus_handle(cx)); + window.focus(&self.channel_name_editor.focus_handle(cx), cx); self.update_entries(false, cx); self.select_channel_editor(); } diff --git a/crates/collab_ui/src/collab_panel/channel_modal.rs b/crates/collab_ui/src/collab_panel/channel_modal.rs index 9d882562cab710f562145087e5c38474fda4808b..ae5b537f2c66dc273d504a70f2b75cb8bec0be20 100644 --- a/crates/collab_ui/src/collab_panel/channel_modal.rs +++ b/crates/collab_ui/src/collab_panel/channel_modal.rs @@ -642,7 +642,7 @@ impl ChannelModalDelegate { }); menu }); - window.focus(&context_menu.focus_handle(cx)); + window.focus(&context_menu.focus_handle(cx), cx); let subscription = cx.subscribe_in( &context_menu, window, diff --git a/crates/command_palette/src/command_palette.rs b/crates/command_palette/src/command_palette.rs index daf97bf676e27b5dd81ce4882c102dbfdefc502a..038b58ac5f4e90544232ccc8da55d0ca71ec28df 100644 --- a/crates/command_palette/src/command_palette.rs +++ b/crates/command_palette/src/command_palette.rs @@ -588,7 +588,7 @@ impl PickerDelegate for CommandPaletteDelegate { }) .detach_and_log_err(cx); let action = command.action; - window.focus(&self.previous_focus_handle); + window.focus(&self.previous_focus_handle, cx); self.dismissed(window, cx); window.dispatch_action(action, cx); } @@ -784,7 +784,7 @@ mod tests { workspace.update_in(cx, |workspace, window, cx| { workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx); - editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx))) + editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx), cx)) }); cx.simulate_keystrokes("cmd-shift-p"); @@ -855,7 +855,7 @@ mod tests { workspace.update_in(cx, |workspace, window, cx| { workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx); - editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx))) + editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx), cx)) }); // Test normalize (trimming whitespace and double colons) diff --git a/crates/context_server/Cargo.toml b/crates/context_server/Cargo.toml index cb48b7e6f7d000ed7f2db7aaf3cfe4d6317fe278..539b873c3527b5a01f1dfcf7b768f0758dc869b5 100644 --- a/crates/context_server/Cargo.toml +++ b/crates/context_server/Cargo.toml @@ -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"] } diff --git a/crates/context_server/src/client.rs b/crates/context_server/src/client.rs index f891e96250f3334540aa859fe438c87297fc0100..605f24178916faa5173c32c28be6c80ee625cb6c 100644 --- a/crates/context_server/src/client.rs +++ b/crates/context_server/src/client.rs @@ -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, name: Arc, - notification_handlers: Arc>>, + subscription_set: Arc>, response_handlers: Arc>>>, #[allow(clippy::type_complexity)] #[allow(dead_code)] @@ -191,21 +192,20 @@ impl Client { let (outbound_tx, outbound_rx) = channel::unbounded::(); 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, - notification_handlers: Arc>>, + subscription_set: Arc>, request_handlers: Arc>>, response_handlers: Arc>>>, cx: &mut AsyncApp, @@ -282,10 +282,11 @@ impl Client { handler(Ok(message.to_string())); } } else if let Ok(notification) = serde_json::from_str::(&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, - ) { - 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)>, + handlers: SlotMap, +} + +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>, +} + +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() + }); + } +} diff --git a/crates/context_server/src/context_server.rs b/crates/context_server/src/context_server.rs index 553e845df87a2fec30b1afbffa05b970d5d672f6..92804549c69b01dd3729efb3a0b47905cd73d813 100644 --- a/crates/context_server/src/context_server.rs +++ b/crates/context_server/src/context_server.rs @@ -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, - )>, - 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 { Ok(match &self.configuration { ContextServerTransport::Stdio(command, working_directory) => Client::stdio( diff --git a/crates/context_server/src/protocol.rs b/crates/context_server/src/protocol.rs index 5355f20f620b5bed76bf945e863fdb5cbcc2ff43..a218a8a3e0e6352997e4152214077cb3851317b3 100644 --- a/crates/context_server/src/protocol.rs +++ b/crates/context_server/src/protocol.rs @@ -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, - ) { - self.inner.on_notification(method, f); + ) -> NotificationSubscription { + self.inner.on_notification(method, f) } } diff --git a/crates/context_server/src/types.rs b/crates/context_server/src/types.rs index 03aca4f3caf7995091bbc8e049494b324674a9d3..81a427a289347ad50bf6a11674c4c5867073a274 100644 --- a/crates/context_server/src/types.rs +++ b/crates/context_server/src/types.rs @@ -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, diff --git a/crates/copilot/src/copilot.rs b/crates/copilot/src/copilot.rs index 45f0796bf53acfef1fb1e81146c0de7c5187fb99..f248fbdb43ec37b19ca951992df6a7ddbc4f7313 100644 --- a/crates/copilot/src/copilot.rs +++ b/crates/copilot/src/copilot.rs @@ -1246,7 +1246,10 @@ async fn get_copilot_lsp(fs: Arc, node_runtime: NodeRuntime) -> anyhow:: .await; if should_install { node_runtime - .npm_install_packages(paths::copilot_dir(), &[(PACKAGE_NAME, &latest_version)]) + .npm_install_packages( + paths::copilot_dir(), + &[(PACKAGE_NAME, &latest_version.to_string())], + ) .await?; } diff --git a/crates/copilot/src/copilot_edit_prediction_delegate.rs b/crates/copilot/src/copilot_edit_prediction_delegate.rs index 0e0cfe6cdca78d2a8b382269ce1ca9a340d1e69c..bbda32e1102f096e96a41cbc59268f597b1629ba 100644 --- a/crates/copilot/src/copilot_edit_prediction_delegate.rs +++ b/crates/copilot/src/copilot_edit_prediction_delegate.rs @@ -753,7 +753,7 @@ mod tests { editor .update(cx, |editor, window, cx| { use gpui::Focusable; - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); }) .unwrap(); let copilot_provider = cx.new(|_| CopilotEditPredictionDelegate::new(copilot)); @@ -1000,7 +1000,7 @@ mod tests { editor .update(cx, |editor, window, cx| { use gpui::Focusable; - window.focus(&editor.focus_handle(cx)) + window.focus(&editor.focus_handle(cx), cx) }) .unwrap(); let copilot_provider = cx.new(|_| CopilotEditPredictionDelegate::new(copilot)); diff --git a/crates/copilot/src/sign_in.rs b/crates/copilot/src/sign_in.rs index 20e31525a8fdb09fce04934d3445d51ba4226a2e..4f71a34408e23f099d4d3c145d86af24e607e3c3 100644 --- a/crates/copilot/src/sign_in.rs +++ b/crates/copilot/src/sign_in.rs @@ -435,8 +435,8 @@ impl Render for CopilotCodeVerification { .on_action(cx.listener(|_, _: &menu::Cancel, _, cx| { cx.emit(DismissEvent); })) - .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _| { - window.focus(&this.focus_handle); + .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| { + window.focus(&this.focus_handle, cx); })) .child( Vector::new(VectorName::ZedXCopilot, rems(8.), rems(4.)) diff --git a/crates/debugger_ui/src/debugger_panel.rs b/crates/debugger_ui/src/debugger_panel.rs index 104a85dc097c575e7a4cd8f4a66a98a8bb6b0d69..35ce80d3f64e362735c1c020363dbbfc2703a101 100644 --- a/crates/debugger_ui/src/debugger_panel.rs +++ b/crates/debugger_ui/src/debugger_panel.rs @@ -577,7 +577,7 @@ impl DebugPanel { menu }); - window.focus(&context_menu.focus_handle(cx)); + window.focus(&context_menu.focus_handle(cx), cx); let subscription = cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| { this.context_menu.take(); cx.notify(); @@ -1052,7 +1052,7 @@ impl DebugPanel { cx: &mut Context, ) { debug_assert!(self.sessions_with_children.contains_key(&session_item)); - session_item.focus_handle(cx).focus(window); + session_item.focus_handle(cx).focus(window, cx); session_item.update(cx, |this, cx| { this.running_state().update(cx, |this, cx| { this.go_to_selected_stack_frame(window, cx); diff --git a/crates/debugger_ui/src/new_process_modal.rs b/crates/debugger_ui/src/new_process_modal.rs index 8aaa61aad6380752a7bdd62ee35635ebb6d160e4..68e391562b57d530a21624b0626173eeb7a67c16 100644 --- a/crates/debugger_ui/src/new_process_modal.rs +++ b/crates/debugger_ui/src/new_process_modal.rs @@ -574,7 +574,7 @@ impl Render for NewProcessModal { NewProcessMode::Launch => NewProcessMode::Task, }; - this.mode_focus_handle(cx).focus(window); + this.mode_focus_handle(cx).focus(window, cx); })) .on_action( cx.listener(|this, _: &pane::ActivatePreviousItem, window, cx| { @@ -585,7 +585,7 @@ impl Render for NewProcessModal { NewProcessMode::Launch => NewProcessMode::Attach, }; - this.mode_focus_handle(cx).focus(window); + this.mode_focus_handle(cx).focus(window, cx); }), ) .child( @@ -602,7 +602,7 @@ impl Render for NewProcessModal { NewProcessMode::Task.to_string(), cx.listener(|this, _, window, cx| { this.mode = NewProcessMode::Task; - this.mode_focus_handle(cx).focus(window); + this.mode_focus_handle(cx).focus(window, cx); cx.notify(); }), ) @@ -611,7 +611,7 @@ impl Render for NewProcessModal { NewProcessMode::Debug.to_string(), cx.listener(|this, _, window, cx| { this.mode = NewProcessMode::Debug; - this.mode_focus_handle(cx).focus(window); + this.mode_focus_handle(cx).focus(window, cx); cx.notify(); }), ) @@ -629,7 +629,7 @@ impl Render for NewProcessModal { cx, ); } - this.mode_focus_handle(cx).focus(window); + this.mode_focus_handle(cx).focus(window, cx); cx.notify(); }), ) @@ -638,7 +638,7 @@ impl Render for NewProcessModal { NewProcessMode::Launch.to_string(), cx.listener(|this, _, window, cx| { this.mode = NewProcessMode::Launch; - this.mode_focus_handle(cx).focus(window); + this.mode_focus_handle(cx).focus(window, cx); cx.notify(); }), ) @@ -840,17 +840,17 @@ impl ConfigureMode { } } - fn on_tab(&mut self, _: &menu::SelectNext, window: &mut Window, _: &mut Context) { - window.focus_next(); + fn on_tab(&mut self, _: &menu::SelectNext, window: &mut Window, cx: &mut Context) { + window.focus_next(cx); } fn on_tab_prev( &mut self, _: &menu::SelectPrevious, window: &mut Window, - _: &mut Context, + cx: &mut Context, ) { - window.focus_prev(); + window.focus_prev(cx); } fn render( @@ -923,7 +923,7 @@ impl AttachMode { window, cx, ); - window.focus(&modal.focus_handle(cx)); + window.focus(&modal.focus_handle(cx), cx); modal }); diff --git a/crates/debugger_ui/src/onboarding_modal.rs b/crates/debugger_ui/src/onboarding_modal.rs index 18205209983421691046e8a9d93eb6de32cd4563..b6f1ab944183c4f44d2bc5f6855731abb65ce1f7 100644 --- a/crates/debugger_ui/src/onboarding_modal.rs +++ b/crates/debugger_ui/src/onboarding_modal.rs @@ -83,8 +83,8 @@ impl Render for DebuggerOnboardingModal { debugger_onboarding_event!("Canceled", trigger = "Action"); cx.emit(DismissEvent); })) - .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| { - this.focus_handle.focus(window); + .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| { + this.focus_handle.focus(window, cx); })) .child( div() diff --git a/crates/debugger_ui/src/session/running.rs b/crates/debugger_ui/src/session/running.rs index 4898ec95ca3c5b55669896b3c1d898326851c0c3..422207d3cbf4880e0c8e3c02e01dbe373800ea62 100644 --- a/crates/debugger_ui/src/session/running.rs +++ b/crates/debugger_ui/src/session/running.rs @@ -604,7 +604,7 @@ impl DebugTerminal { let focus_handle = cx.focus_handle(); let focus_subscription = cx.on_focus(&focus_handle, window, |this, window, cx| { if let Some(terminal) = this.terminal.as_ref() { - terminal.focus_handle(cx).focus(window); + terminal.focus_handle(cx).focus(window, cx); } }); diff --git a/crates/debugger_ui/src/session/running/breakpoint_list.rs b/crates/debugger_ui/src/session/running/breakpoint_list.rs index 2c7e2074678290356b7669228dcf29008f1cc36b..f154757429a2bbfe153ee40c2c513dd06f05aa03 100644 --- a/crates/debugger_ui/src/session/running/breakpoint_list.rs +++ b/crates/debugger_ui/src/session/running/breakpoint_list.rs @@ -310,7 +310,7 @@ impl BreakpointList { fn dismiss(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context) { if self.input.focus_handle(cx).contains_focused(window, cx) { - self.focus_handle.focus(window); + self.focus_handle.focus(window, cx); } else if self.strip_mode.is_some() { self.strip_mode.take(); cx.notify(); @@ -364,9 +364,9 @@ impl BreakpointList { } } } - self.focus_handle.focus(window); + self.focus_handle.focus(window, cx); } else { - handle.focus(window); + handle.focus(window, cx); } return; @@ -627,7 +627,7 @@ impl BreakpointList { .on_click({ let focus_handle = focus_handle.clone(); move |_, window, cx| { - focus_handle.focus(window); + focus_handle.focus(window, cx); window.dispatch_action(ToggleEnableBreakpoint.boxed_clone(), cx) } }), @@ -654,7 +654,7 @@ impl BreakpointList { ) .on_click({ move |_, window, cx| { - focus_handle.focus(window); + focus_handle.focus(window, cx); window.dispatch_action(UnsetBreakpoint.boxed_clone(), cx) } }), diff --git a/crates/debugger_ui/src/session/running/console.rs b/crates/debugger_ui/src/session/running/console.rs index 927a57dc8bdf956eb7f7ff63d3ea058500abf6c3..040953bff6e8f0efa6045c1629c964ac98929547 100644 --- a/crates/debugger_ui/src/session/running/console.rs +++ b/crates/debugger_ui/src/session/running/console.rs @@ -105,7 +105,7 @@ impl Console { cx.subscribe(&stack_frame_list, Self::handle_stack_frame_list_events), cx.on_focus(&focus_handle, window, |console, window, cx| { if console.is_running(cx) { - console.query_bar.focus_handle(cx).focus(window); + console.query_bar.focus_handle(cx).focus(window, cx); } }), ]; diff --git a/crates/debugger_ui/src/session/running/memory_view.rs b/crates/debugger_ui/src/session/running/memory_view.rs index 55a8e8429eb23cd0bfcaa7d592d16797c061d2ae..f10e5179e37f87be0e27985b557fcb63cf089a42 100644 --- a/crates/debugger_ui/src/session/running/memory_view.rs +++ b/crates/debugger_ui/src/session/running/memory_view.rs @@ -403,7 +403,7 @@ impl MemoryView { this.set_placeholder_text("Write to Selected Memory Range", window, cx); }); self.is_writing_memory = true; - self.query_editor.focus_handle(cx).focus(window); + self.query_editor.focus_handle(cx).focus(window, cx); } else { self.query_editor.update(cx, |this, cx| { this.clear(window, cx); diff --git a/crates/debugger_ui/src/session/running/variable_list.rs b/crates/debugger_ui/src/session/running/variable_list.rs index 7b23cd685d93e6353d68dc57cd3998099ea56ad7..8329a6baf04061cc33e8130a4e6b3a33b35267b6 100644 --- a/crates/debugger_ui/src/session/running/variable_list.rs +++ b/crates/debugger_ui/src/session/running/variable_list.rs @@ -529,7 +529,7 @@ impl VariableList { fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context) { self.edited_path.take(); - self.focus_handle.focus(window); + self.focus_handle.focus(window, cx); cx.notify(); } @@ -1067,7 +1067,7 @@ impl VariableList { editor.select_all(&editor::actions::SelectAll, window, cx); editor }); - editor.focus_handle(cx).focus(window); + editor.focus_handle(cx).focus(window, cx); editor } diff --git a/crates/diagnostics/src/buffer_diagnostics.rs b/crates/diagnostics/src/buffer_diagnostics.rs index ca28f2805adca78846a66e7b1f4d9f3fc57bb557..ba10f6fbdabf05a095a7fed7c6ae682d4dc177c7 100644 --- a/crates/diagnostics/src/buffer_diagnostics.rs +++ b/crates/diagnostics/src/buffer_diagnostics.rs @@ -175,7 +175,7 @@ impl BufferDiagnosticsEditor { // `BufferDiagnosticsEditor` instance. EditorEvent::Focused => { if buffer_diagnostics_editor.multibuffer.read(cx).is_empty() { - window.focus(&buffer_diagnostics_editor.focus_handle); + window.focus(&buffer_diagnostics_editor.focus_handle, cx); } } EditorEvent::Blurred => { @@ -517,7 +517,7 @@ impl BufferDiagnosticsEditor { .editor .read(cx) .focus_handle(cx) - .focus(window); + .focus(window, cx); } } } @@ -617,7 +617,7 @@ impl BufferDiagnosticsEditor { // not empty, focus on the editor instead, which will allow the user to // start interacting and editing the buffer's contents. if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() { - self.editor.focus_handle(cx).focus(window) + self.editor.focus_handle(cx).focus(window, cx) } } diff --git a/crates/diagnostics/src/diagnostic_renderer.rs b/crates/diagnostics/src/diagnostic_renderer.rs index 72ad7b591413832183bb85d58d188e692d46ffad..521752ff1959fccc12b74857e342ff33a0444f3f 100644 --- a/crates/diagnostics/src/diagnostic_renderer.rs +++ b/crates/diagnostics/src/diagnostic_renderer.rs @@ -315,6 +315,6 @@ impl DiagnosticBlock { editor.change_selections(Default::default(), window, cx, |s| { s.select_ranges([range.start..range.start]); }); - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); } } diff --git a/crates/diagnostics/src/diagnostics.rs b/crates/diagnostics/src/diagnostics.rs index 0999bebdb6aa9ca744e3a5121670a1b7357411a9..d85eb07f68619e15bfe44d26282db3a3e49df4f3 100644 --- a/crates/diagnostics/src/diagnostics.rs +++ b/crates/diagnostics/src/diagnostics.rs @@ -243,7 +243,7 @@ impl ProjectDiagnosticsEditor { match event { EditorEvent::Focused => { if this.multibuffer.read(cx).is_empty() { - window.focus(&this.focus_handle); + window.focus(&this.focus_handle, cx); } } EditorEvent::Blurred => this.close_diagnosticless_buffers(cx, false), @@ -434,7 +434,7 @@ impl ProjectDiagnosticsEditor { fn focus_in(&mut self, window: &mut Window, cx: &mut Context) { if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() { - self.editor.focus_handle(cx).focus(window) + self.editor.focus_handle(cx).focus(window, cx) } } @@ -650,7 +650,7 @@ impl ProjectDiagnosticsEditor { }) }); if this.focus_handle.is_focused(window) { - this.editor.read(cx).focus_handle(cx).focus(window); + this.editor.read(cx).focus_handle(cx).focus(window, cx); } } diff --git a/crates/edit_prediction/src/onboarding_modal.rs b/crates/edit_prediction/src/onboarding_modal.rs index ed7adfc75476afb07f9c56b9c9c03abbbcef1134..97f529ae38df350ef21ffc04b79df6e8e6a7a501 100644 --- a/crates/edit_prediction/src/onboarding_modal.rs +++ b/crates/edit_prediction/src/onboarding_modal.rs @@ -131,8 +131,8 @@ impl Render for ZedPredictModal { onboarding_event!("Cancelled", trigger = "Action"); cx.emit(DismissEvent); })) - .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| { - this.focus_handle.focus(window); + .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| { + this.focus_handle.focus(window, cx); })) .child( div() diff --git a/crates/edit_prediction_cli/src/headless.rs b/crates/edit_prediction_cli/src/headless.rs index 2deb96fdbf19a94c5649d87a7bf2f5fea0b601c2..da96e7ef6520e952e2b7696eee6b82c243e90e4e 100644 --- a/crates/edit_prediction_cli/src/headless.rs +++ b/crates/edit_prediction_cli/src/headless.rs @@ -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}; diff --git a/crates/edit_prediction_cli/src/load_project.rs b/crates/edit_prediction_cli/src/load_project.rs index 38f114d726d3626fac89982b7f3a98c55e92ac07..70daf00b79486fd917556cffaa26b1fd01ed4d28 100644 --- a/crates/edit_prediction_cli/src/load_project.rs +++ b/crates/edit_prediction_cli/src/load_project.rs @@ -179,6 +179,7 @@ async fn setup_project( app_state.languages.clone(), app_state.fs.clone(), None, + false, cx, ) })?; diff --git a/crates/edit_prediction_ui/src/rate_prediction_modal.rs b/crates/edit_prediction_ui/src/rate_prediction_modal.rs index 22e82bc445b394cc122e1cb1aa3604b45c25d1d1..1af65ad58083e3cccfa51ea7b674da01cad810a0 100644 --- a/crates/edit_prediction_ui/src/rate_prediction_modal.rs +++ b/crates/edit_prediction_ui/src/rate_prediction_modal.rs @@ -305,7 +305,7 @@ impl RatePredictionsModal { && prediction.id == prev_prediction.prediction.id { if focus { - window.focus(&prev_prediction.feedback_editor.focus_handle(cx)); + window.focus(&prev_prediction.feedback_editor.focus_handle(cx), cx); } return; } diff --git a/crates/editor/benches/editor_render.rs b/crates/editor/benches/editor_render.rs index 4323c6c973f3729623d8939ca89ecf3ac403bcbf..daaeede790cbd75a7238a81559513c5d3165a054 100644 --- a/crates/editor/benches/editor_render.rs +++ b/crates/editor/benches/editor_render.rs @@ -29,7 +29,7 @@ fn editor_input_with_1000_cursors(bencher: &mut Bencher<'_>, cx: &TestAppContext ); editor }); - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); editor }); @@ -72,7 +72,7 @@ fn open_editor_with_one_long_line(bencher: &mut Bencher<'_>, args: &(String, Tes editor.set_style(editor::EditorStyle::default(), window, cx); editor }); - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); editor }); }); @@ -100,7 +100,7 @@ fn editor_render(bencher: &mut Bencher<'_>, cx: &TestAppContext) { editor.set_style(editor::EditorStyle::default(), window, cx); editor }); - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); editor }); diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index cab5b3686ee2f77dade059b434b1090cf9b2f7e5..413766cb283dfa2c5de0351b3ff10ff9b90a9c56 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -14,8 +14,57 @@ //! - [`DisplayMap`] that adds background highlights to the regions of text. //! Each one of those builds on top of preceding map. //! +//! ## Structure of the display map layers +//! +//! Each layer in the map (and the multibuffer itself to some extent) has a few +//! structures that are used to implement the public API available to the layer +//! above: +//! - a `Transform` type - this represents a region of text that the layer in +//! question is "managing", that it transforms into a more "processed" text +//! for the layer above. For example, the inlay map has an `enum Transform` +//! that has two variants: +//! - `Isomorphic`, representing a region of text that has no inlay hints (i.e. +//! is passed through the map transparently) +//! - `Inlay`, representing a location where an inlay hint is to be inserted. +//! - a `TransformSummary` type, which is usually a struct with two fields: +//! [`input: TextSummary`][`TextSummary`] and [`output: TextSummary`][`TextSummary`]. Here, +//! `input` corresponds to "text in the layer below", and `output` corresponds to the text +//! exposed to the layer above. So in the inlay map case, a `Transform::Isomorphic`'s summary is +//! just `input = output = summary`, where `summary` is the [`TextSummary`] stored in that +//! variant. Conversely, a `Transform::Inlay` always has an empty `input` summary, because it's +//! not "replacing" any text that exists on disk. The `output` is the summary of the inlay text +//! to be injected. - Various newtype wrappers for co-ordinate spaces (e.g. [`WrapRow`] +//! represents a row index, after soft-wrapping (and all lower layers)). +//! - A `Snapshot` type (e.g. [`InlaySnapshot`]) that captures the state of a layer at a specific +//! point in time. +//! - various APIs which drill through the layers below to work with the underlying text. Notably: +//! - `fn text_summary_for_offset()` returns a [`TextSummary`] for the range in the co-ordinate +//! space that the map in question is responsible for. +//! - `fn _point_to__point()` converts a point in co-ordinate space `A` into co-ordinate +//! space `B`. +//! - A [`RowInfo`] iterator (e.g. [`InlayBufferRows`]) and a [`Chunk`] iterator +//! (e.g. [`InlayChunks`]) +//! - A `sync` function (e.g. [`InlayMap::sync`]) that takes a snapshot and list of [`Edit`]s, +//! and returns a new snapshot and a list of transformed [`Edit`]s. Note that the generic +//! parameter on `Edit` changes, since these methods take in edits in the co-ordinate space of +//! the lower layer, and return edits in their own co-ordinate space. The term "edit" is +//! slightly misleading, since an [`Edit`] doesn't tell you what changed - rather it can be +//! thought of as a "region to invalidate". In theory, it would be correct to always use a +//! single edit that covers the entire range. However, this would lead to lots of unnecessary +//! recalculation. +//! +//! See the docs for the [`inlay_map`] module for a more in-depth explanation of how a single layer +//! works. +//! //! [Editor]: crate::Editor //! [EditorElement]: crate::element::EditorElement +//! [`TextSummary`]: multi_buffer::MBTextSummary +//! [`WrapRow`]: wrap_map::WrapRow +//! [`InlayBufferRows`]: inlay_map::InlayBufferRows +//! [`InlayChunks`]: inlay_map::InlayChunks +//! [`Edit`]: text::Edit +//! [`Edit`]: text::Edit +//! [`Chunk`]: language::Chunk #[macro_use] mod dimensions; diff --git a/crates/editor/src/display_map/block_map.rs b/crates/editor/src/display_map/block_map.rs index 9c7f9d8632224208248a6585fc6f94939ee076fe..15bf012cd907da2455c1a2205bcccd363162fd46 100644 --- a/crates/editor/src/display_map/block_map.rs +++ b/crates/editor/src/display_map/block_map.rs @@ -545,7 +545,7 @@ impl BlockMap { { let max_point = wrap_snapshot.max_point(); let edit_start = wrap_snapshot.prev_row_boundary(max_point); - let edit_end = max_point.row() + WrapRow(1); + let edit_end = max_point.row() + WrapRow(1); // this is end of file edits = edits.compose([WrapEdit { old: edit_start..edit_end, new: edit_start..edit_end, @@ -715,6 +715,7 @@ impl BlockMap { let placement = block.placement.to_wrap_row(wrap_snapshot)?; if let BlockPlacement::Above(row) = placement && row < new_start + // this will be true more often now { return None; } diff --git a/crates/editor/src/display_map/inlay_map.rs b/crates/editor/src/display_map/inlay_map.rs index d85f761a82e2f466b6868c4ce28bcb3a4e6b061d..cbdc4b18fee452163c5a11932c968cb7cc500f96 100644 --- a/crates/editor/src/display_map/inlay_map.rs +++ b/crates/editor/src/display_map/inlay_map.rs @@ -1,3 +1,10 @@ +//! The inlay map. See the [`display_map`][super] docs for an overview of how the inlay map fits +//! into the rest of the [`DisplayMap`][super::DisplayMap]. Much of the documentation for this +//! module generalizes to other layers. +//! +//! The core of this module is the [`InlayMap`] struct, which maintains a vec of [`Inlay`]s, and +//! [`InlaySnapshot`], which holds a sum tree of [`Transform`]s. + use crate::{ ChunkRenderer, HighlightStyles, inlays::{Inlay, InlayContent}, @@ -69,7 +76,9 @@ impl sum_tree::Item for Transform { #[derive(Clone, Debug, Default)] struct TransformSummary { + /// Summary of the text before inlays have been applied. input: MBTextSummary, + /// Summary of the text after inlays have been applied. output: MBTextSummary, } diff --git a/crates/editor/src/display_map/wrap_map.rs b/crates/editor/src/display_map/wrap_map.rs index 4d6b79d06170a22aaffafa05e0f144219e4d20a7..879ca11be1a84ffd44daa6e53677b06887172026 100644 --- a/crates/editor/src/display_map/wrap_map.rs +++ b/crates/editor/src/display_map/wrap_map.rs @@ -840,35 +840,62 @@ impl WrapSnapshot { self.tab_point_to_wrap_point(self.tab_snapshot.clip_point(self.to_tab_point(point), bias)) } - #[ztracing::instrument(skip_all, fields(point=?point, ret))] - pub fn prev_row_boundary(&self, mut point: WrapPoint) -> WrapRow { + /// Try to find a TabRow start that is also a WrapRow start + /// Every TabRow start is a WrapRow start + #[ztracing::instrument(skip_all, fields(point=?point))] + pub fn prev_row_boundary(&self, point: WrapPoint) -> WrapRow { if self.transforms.is_empty() { return WrapRow(0); } - *point.column_mut() = 0; + let point = WrapPoint::new(point.row(), 0); let mut cursor = self .transforms .cursor::>(()); - // start + cursor.seek(&point, Bias::Right); - // end if cursor.item().is_none() { cursor.prev(); } - // start + // real newline fake fake + // text: helloworldasldlfjasd\njdlasfalsk\naskdjfasdkfj\n + // dimensions v v v v v + // transforms |-------|-----NW----|-----W------|-----W------| + // cursor ^ ^^^^^^^^^^^^^ ^ + // (^) ^^^^^^^^^^^^^^ + // point: ^ + // point(col_zero): (^) + while let Some(transform) = cursor.item() { - if transform.is_isomorphic() && cursor.start().1.column() == 0 { - return cmp::min(cursor.end().0.row(), point.row()); - } else { - cursor.prev(); + if transform.is_isomorphic() { + // this transform only has real linefeeds + let tab_summary = &transform.summary.input; + // is the wrap just before the end of the transform a tab row? + // thats only if this transform has at least one newline + // + // "this wrap row is a tab row" <=> self.to_tab_point(WrapPoint::new(wrap_row, 0)).column() == 0 + + // Note on comparison: + // We have code that relies on this to be row > 1 + // It should work with row >= 1 but it does not :( + // + // That means that if every line is wrapped we walk back all the + // way to the start. Which invalidates the entire state triggering + // a full re-render. + if tab_summary.lines.row > 1 { + let wrap_point_at_end = cursor.end().0.row(); + return cmp::min(wrap_point_at_end - RowDelta(1), point.row()); + } else if cursor.start().1.column() == 0 { + return cmp::min(cursor.end().0.row(), point.row()); + } } + + cursor.prev(); } - // end - unreachable!() + WrapRow(0) } #[ztracing::instrument(skip_all)] @@ -891,13 +918,11 @@ impl WrapSnapshot { } #[cfg(test)] - #[ztracing::instrument(skip_all)] pub fn text(&self) -> String { self.text_chunks(WrapRow(0)).collect() } #[cfg(test)] - #[ztracing::instrument(skip_all)] pub fn text_chunks(&self, wrap_row: WrapRow) -> impl Iterator { self.chunks( wrap_row..self.max_point().row() + WrapRow(1), @@ -1298,6 +1323,71 @@ mod tests { use text::Rope; use theme::LoadThemes; + #[gpui::test] + async fn test_prev_row_boundary(cx: &mut gpui::TestAppContext) { + init_test(cx); + + fn test_wrap_snapshot( + text: &str, + soft_wrap_every: usize, // font size multiple + cx: &mut gpui::TestAppContext, + ) -> WrapSnapshot { + let text_system = cx.read(|cx| cx.text_system().clone()); + let tab_size = 4.try_into().unwrap(); + let font = test_font(); + let _font_id = text_system.resolve_font(&font); + let font_size = px(14.0); + // this is very much an estimate to try and get the wrapping to + // occur at `soft_wrap_every` we check that it pans out for every test case + let soft_wrapping = Some(font_size * soft_wrap_every * 0.6); + + let buffer = cx.new(|cx| language::Buffer::local(text, cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let buffer_snapshot = buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx)); + let (_inlay_map, inlay_snapshot) = InlayMap::new(buffer_snapshot); + let (_fold_map, fold_snapshot) = FoldMap::new(inlay_snapshot); + let (mut tab_map, _) = TabMap::new(fold_snapshot, tab_size); + let tabs_snapshot = tab_map.set_max_expansion_column(32); + let (_wrap_map, wrap_snapshot) = + cx.update(|cx| WrapMap::new(tabs_snapshot, font, font_size, soft_wrapping, cx)); + + wrap_snapshot + } + + // These two should pass but dont, see the comparison note in + // prev_row_boundary about why. + // + // // 0123 4567 wrap_rows + // let wrap_snapshot = test_wrap_snapshot("1234\n5678", 1, cx); + // assert_eq!(wrap_snapshot.text(), "1\n2\n3\n4\n5\n6\n7\n8"); + // let row = wrap_snapshot.prev_row_boundary(wrap_snapshot.max_point()); + // assert_eq!(row.0, 3); + + // // 012 345 678 wrap_rows + // let wrap_snapshot = test_wrap_snapshot("123\n456\n789", 1, cx); + // assert_eq!(wrap_snapshot.text(), "1\n2\n3\n4\n5\n6\n7\n8\n9"); + // let row = wrap_snapshot.prev_row_boundary(wrap_snapshot.max_point()); + // assert_eq!(row.0, 5); + + // 012345678 wrap_rows + let wrap_snapshot = test_wrap_snapshot("123456789", 1, cx); + assert_eq!(wrap_snapshot.text(), "1\n2\n3\n4\n5\n6\n7\n8\n9"); + let row = wrap_snapshot.prev_row_boundary(wrap_snapshot.max_point()); + assert_eq!(row.0, 0); + + // 111 2222 44 wrap_rows + let wrap_snapshot = test_wrap_snapshot("123\n4567\n\n89", 4, cx); + assert_eq!(wrap_snapshot.text(), "123\n4567\n\n89"); + let row = wrap_snapshot.prev_row_boundary(wrap_snapshot.max_point()); + assert_eq!(row.0, 2); + + // 11 2223 wrap_rows + let wrap_snapshot = test_wrap_snapshot("12\n3456\n\n", 3, cx); + assert_eq!(wrap_snapshot.text(), "12\n345\n6\n\n"); + let row = wrap_snapshot.prev_row_boundary(wrap_snapshot.max_point()); + assert_eq!(row.0, 3); + } + #[gpui::test(iterations = 100)] async fn test_random_wraps(cx: &mut gpui::TestAppContext, mut rng: StdRng) { // todo this test is flaky diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index f4a83f900da68d90803b82c0aec1287fcaa71cd3..7da06c3d8de91709cdcea8cbc923918464021079 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -124,8 +124,9 @@ use language::{ AutoindentMode, BlockCommentConfig, BracketMatch, BracketPair, Buffer, BufferRow, BufferSnapshot, Capability, CharClassifier, CharKind, CharScopeContext, CodeLabel, CursorShape, DiagnosticEntryRef, DiffOptions, EditPredictionsMode, EditPreview, HighlightedText, IndentKind, - IndentSize, Language, LanguageName, LanguageRegistry, OffsetRangeExt, OutlineItem, Point, - Runnable, Selection, SelectionGoal, TextObject, TransactionId, TreeSitterOptions, WordsQuery, + IndentSize, Language, LanguageName, LanguageRegistry, LanguageScope, OffsetRangeExt, + OutlineItem, Point, Runnable, Selection, SelectionGoal, TextObject, TransactionId, + TreeSitterOptions, WordsQuery, language_settings::{ self, LanguageSettings, LspInsertMode, RewrapBehavior, WordsCompletionMode, all_language_settings, language_settings, @@ -2063,46 +2064,34 @@ impl Editor { }) }); }); - let edited_buffers_already_open = { - let other_editors: Vec> = workspace - .read(cx) - .panes() - .iter() - .flat_map(|pane| pane.read(cx).items_of_type::()) - .filter(|editor| editor.entity_id() != cx.entity_id()) - .collect(); - - transaction.0.keys().all(|buffer| { - other_editors.iter().any(|editor| { - let multi_buffer = editor.read(cx).buffer(); - multi_buffer.read(cx).is_singleton() - && multi_buffer.read(cx).as_singleton().map_or( - false, - |singleton| { - singleton.entity_id() == buffer.entity_id() - }, - ) - }) - }) - }; - if !edited_buffers_already_open { - let workspace = workspace.downgrade(); - let transaction = transaction.clone(); - cx.defer_in(window, move |_, window, cx| { - cx.spawn_in(window, async move |editor, cx| { - Self::open_project_transaction( - &editor, - workspace, - transaction, - "Rename".to_string(), - cx, - ) - .await - .ok() - }) - .detach(); - }); - } + + Self::open_transaction_for_hidden_buffers( + workspace, + transaction.clone(), + "Rename".to_string(), + window, + cx, + ); + } + } + + project::Event::WorkspaceEditApplied(transaction) => { + let Some(workspace) = editor.workspace() else { + return; + }; + let Some(active_editor) = workspace.read(cx).active_item_as::(cx) + else { + return; + }; + + if active_editor.entity_id() == cx.entity_id() { + Self::open_transaction_for_hidden_buffers( + workspace, + transaction.clone(), + "LSP Edit".to_string(), + window, + cx, + ); } } @@ -3827,7 +3816,7 @@ impl Editor { ) { if !self.focus_handle.is_focused(window) { self.last_focused_descendant = None; - window.focus(&self.focus_handle); + window.focus(&self.focus_handle, cx); } let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); @@ -3932,7 +3921,7 @@ impl Editor { ) { if !self.focus_handle.is_focused(window) { self.last_focused_descendant = None; - window.focus(&self.focus_handle); + window.focus(&self.focus_handle, cx); } let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); @@ -4802,205 +4791,51 @@ impl Editor { let end = selection.end; let selection_is_empty = start == end; let language_scope = buffer.language_scope_at(start); - let ( - comment_delimiter, - doc_delimiter, - insert_extra_newline, - indent_on_newline, - indent_on_extra_newline, - ) = if let Some(language) = &language_scope { - let mut insert_extra_newline = - insert_extra_newline_brackets(&buffer, start..end, language) - || insert_extra_newline_tree_sitter(&buffer, start..end); - - // Comment extension on newline is allowed only for cursor selections - let comment_delimiter = maybe!({ - if !selection_is_empty { - return None; - } - - if !multi_buffer.language_settings(cx).extend_comment_on_newline { - return None; - } - - let delimiters = language.line_comment_prefixes(); - let max_len_of_delimiter = - delimiters.iter().map(|delimiter| delimiter.len()).max()?; - let (snapshot, range) = - buffer.buffer_line_for_row(MultiBufferRow(start_point.row))?; - - let num_of_whitespaces = snapshot - .chars_for_range(range.clone()) - .take_while(|c| c.is_whitespace()) - .count(); - let comment_candidate = snapshot - .chars_for_range(range.clone()) - .skip(num_of_whitespaces) - .take(max_len_of_delimiter) - .collect::(); - let (delimiter, trimmed_len) = delimiters - .iter() - .filter_map(|delimiter| { - let prefix = delimiter.trim_end(); - if comment_candidate.starts_with(prefix) { - Some((delimiter, prefix.len())) - } else { - None - } - }) - .max_by_key(|(_, len)| *len)?; - - if let Some(BlockCommentConfig { - start: block_start, .. - }) = language.block_comment() - { - let block_start_trimmed = block_start.trim_end(); - if block_start_trimmed.starts_with(delimiter.trim_end()) { - let line_content = snapshot - .chars_for_range(range) - .skip(num_of_whitespaces) - .take(block_start_trimmed.len()) - .collect::(); - - if line_content.starts_with(block_start_trimmed) { - return None; - } + let (comment_delimiter, doc_delimiter, newline_formatting) = + if let Some(language) = &language_scope { + let mut newline_formatting = + NewlineFormatting::new(&buffer, start..end, language); + + // Comment extension on newline is allowed only for cursor selections + let comment_delimiter = maybe!({ + if !selection_is_empty { + return None; } - } - - let cursor_is_placed_after_comment_marker = - num_of_whitespaces + trimmed_len <= start_point.column as usize; - if cursor_is_placed_after_comment_marker { - Some(delimiter.clone()) - } else { - None - } - }); - - let mut indent_on_newline = IndentSize::spaces(0); - let mut indent_on_extra_newline = IndentSize::spaces(0); - let doc_delimiter = maybe!({ - if !selection_is_empty { - return None; - } - - if !multi_buffer.language_settings(cx).extend_comment_on_newline { - return None; - } - - let BlockCommentConfig { - start: start_tag, - end: end_tag, - prefix: delimiter, - tab_size: len, - } = language.documentation_comment()?; - let is_within_block_comment = buffer - .language_scope_at(start_point) - .is_some_and(|scope| scope.override_name() == Some("comment")); - if !is_within_block_comment { - return None; - } - - let (snapshot, range) = - buffer.buffer_line_for_row(MultiBufferRow(start_point.row))?; - - let num_of_whitespaces = snapshot - .chars_for_range(range.clone()) - .take_while(|c| c.is_whitespace()) - .count(); - - // It is safe to use a column from MultiBufferPoint in context of a single buffer ranges, because we're only ever looking at a single line at a time. - let column = start_point.column; - let cursor_is_after_start_tag = { - let start_tag_len = start_tag.len(); - let start_tag_line = snapshot - .chars_for_range(range.clone()) - .skip(num_of_whitespaces) - .take(start_tag_len) - .collect::(); - if start_tag_line.starts_with(start_tag.as_ref()) { - num_of_whitespaces + start_tag_len <= column as usize - } else { - false + if !multi_buffer.language_settings(cx).extend_comment_on_newline + { + return None; } - }; - let cursor_is_after_delimiter = { - let delimiter_trim = delimiter.trim_end(); - let delimiter_line = snapshot - .chars_for_range(range.clone()) - .skip(num_of_whitespaces) - .take(delimiter_trim.len()) - .collect::(); - if delimiter_line.starts_with(delimiter_trim) { - num_of_whitespaces + delimiter_trim.len() <= column as usize - } else { - false - } - }; + return comment_delimiter_for_newline( + &start_point, + &buffer, + language, + ); + }); - let cursor_is_before_end_tag_if_exists = { - let mut char_position = 0u32; - let mut end_tag_offset = None; - - 'outer: for chunk in snapshot.text_for_range(range) { - if let Some(byte_pos) = chunk.find(&**end_tag) { - let chars_before_match = - chunk[..byte_pos].chars().count() as u32; - end_tag_offset = - Some(char_position + chars_before_match); - break 'outer; - } - char_position += chunk.chars().count() as u32; + let doc_delimiter = maybe!({ + if !selection_is_empty { + return None; } - if let Some(end_tag_offset) = end_tag_offset { - let cursor_is_before_end_tag = column <= end_tag_offset; - if cursor_is_after_start_tag { - if cursor_is_before_end_tag { - insert_extra_newline = true; - } - let cursor_is_at_start_of_end_tag = - column == end_tag_offset; - if cursor_is_at_start_of_end_tag { - indent_on_extra_newline.len = *len; - } - } - cursor_is_before_end_tag - } else { - true + if !multi_buffer.language_settings(cx).extend_comment_on_newline + { + return None; } - }; - if (cursor_is_after_start_tag || cursor_is_after_delimiter) - && cursor_is_before_end_tag_if_exists - { - if cursor_is_after_start_tag { - indent_on_newline.len = *len; - } - Some(delimiter.clone()) - } else { - None - } - }); + return documentation_delimiter_for_newline( + &start_point, + &buffer, + language, + &mut newline_formatting, + ); + }); - ( - comment_delimiter, - doc_delimiter, - insert_extra_newline, - indent_on_newline, - indent_on_extra_newline, - ) - } else { - ( - None, - None, - false, - IndentSize::default(), - IndentSize::default(), - ) - }; + (comment_delimiter, doc_delimiter, newline_formatting) + } else { + (None, None, NewlineFormatting::default()) + }; let prevent_auto_indent = doc_delimiter.is_some(); let delimiter = comment_delimiter.or(doc_delimiter); @@ -5010,28 +4845,28 @@ impl Editor { let mut new_text = String::with_capacity( 1 + capacity_for_delimiter + existing_indent.len as usize - + indent_on_newline.len as usize - + indent_on_extra_newline.len as usize, + + newline_formatting.indent_on_newline.len as usize + + newline_formatting.indent_on_extra_newline.len as usize, ); new_text.push('\n'); new_text.extend(existing_indent.chars()); - new_text.extend(indent_on_newline.chars()); + new_text.extend(newline_formatting.indent_on_newline.chars()); if let Some(delimiter) = &delimiter { new_text.push_str(delimiter); } - if insert_extra_newline { + if newline_formatting.insert_extra_newline { new_text.push('\n'); new_text.extend(existing_indent.chars()); - new_text.extend(indent_on_extra_newline.chars()); + new_text.extend(newline_formatting.indent_on_extra_newline.chars()); } let anchor = buffer.anchor_after(end); let new_selection = selection.map(|_| anchor); ( ((start..end, new_text), prevent_auto_indent), - (insert_extra_newline, new_selection), + (newline_formatting.insert_extra_newline, new_selection), ) }) .unzip() @@ -6672,6 +6507,52 @@ impl Editor { } } + fn open_transaction_for_hidden_buffers( + workspace: Entity, + transaction: ProjectTransaction, + title: String, + window: &mut Window, + cx: &mut Context, + ) { + if transaction.0.is_empty() { + return; + } + + let edited_buffers_already_open = { + let other_editors: Vec> = workspace + .read(cx) + .panes() + .iter() + .flat_map(|pane| pane.read(cx).items_of_type::()) + .filter(|editor| editor.entity_id() != cx.entity_id()) + .collect(); + + transaction.0.keys().all(|buffer| { + other_editors.iter().any(|editor| { + let multi_buffer = editor.read(cx).buffer(); + multi_buffer.read(cx).is_singleton() + && multi_buffer + .read(cx) + .as_singleton() + .map_or(false, |singleton| { + singleton.entity_id() == buffer.entity_id() + }) + }) + }) + }; + if !edited_buffers_already_open { + let workspace = workspace.downgrade(); + cx.defer_in(window, move |_, window, cx| { + cx.spawn_in(window, async move |editor, cx| { + Self::open_project_transaction(&editor, workspace, transaction, title, cx) + .await + .ok() + }) + .detach(); + }); + } + } + pub async fn open_project_transaction( editor: &WeakEntity, workspace: WeakEntity, @@ -6831,7 +6712,7 @@ impl Editor { }) }) .on_click(cx.listener(move |editor, _: &ClickEvent, window, cx| { - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); editor.toggle_code_actions( &crate::actions::ToggleCodeActions { deployed_from: Some(crate::actions::CodeActionSource::Indicator( @@ -8724,7 +8605,7 @@ impl Editor { BreakpointEditAction::Toggle }; - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); editor.edit_breakpoint_at_anchor( position, breakpoint.as_ref().clone(), @@ -8916,7 +8797,7 @@ impl Editor { ClickEvent::Mouse(e) => e.down.button == MouseButton::Left, }; - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); editor.toggle_code_actions( &ToggleCodeActions { deployed_from: Some(CodeActionSource::RunMenu(row)), @@ -11331,7 +11212,7 @@ impl Editor { }]; let focus_handle = bp_prompt.focus_handle(cx); - window.focus(&focus_handle); + window.focus(&focus_handle, cx); let block_ids = self.insert_blocks(blocks, None, cx); bp_prompt.update(cx, |prompt, _| { @@ -15534,10 +15415,9 @@ impl Editor { I: IntoIterator, P: AsRef<[u8]>, { - let case_sensitive = self.select_next_is_case_sensitive.map_or_else( - || EditorSettings::get_global(cx).search.case_sensitive, - |value| value, - ); + let case_sensitive = self + .select_next_is_case_sensitive + .unwrap_or_else(|| EditorSettings::get_global(cx).search.case_sensitive); let mut builder = AhoCorasickBuilder::new(); builder.ascii_case_insensitive(!case_sensitive); @@ -18159,7 +18039,7 @@ impl Editor { cx, ); let rename_focus_handle = rename_editor.focus_handle(cx); - window.focus(&rename_focus_handle); + window.focus(&rename_focus_handle, cx); let block_id = this.insert_blocks( [BlockProperties { style: BlockStyle::Flex, @@ -18273,7 +18153,7 @@ impl Editor { ) -> Option { let rename = self.pending_rename.take()?; if rename.editor.focus_handle(cx).is_focused(window) { - window.focus(&self.focus_handle); + window.focus(&self.focus_handle, cx); } self.remove_blocks( @@ -22843,7 +22723,7 @@ impl Editor { .take() .and_then(|descendant| descendant.upgrade()) { - window.focus(&descendant); + window.focus(&descendant, cx); } else { if let Some(blame) = self.blame.as_ref() { blame.update(cx, GitBlame::focus) @@ -23474,76 +23354,256 @@ struct CompletionEdit { snippet: Option, } -fn insert_extra_newline_brackets( +fn comment_delimiter_for_newline( + start_point: &Point, buffer: &MultiBufferSnapshot, - range: Range, - language: &language::LanguageScope, -) -> bool { - let leading_whitespace_len = buffer - .reversed_chars_at(range.start) - .take_while(|c| c.is_whitespace() && *c != '\n') - .map(|c| c.len_utf8()) - .sum::(); - let trailing_whitespace_len = buffer - .chars_at(range.end) - .take_while(|c| c.is_whitespace() && *c != '\n') - .map(|c| c.len_utf8()) - .sum::(); - let range = range.start - leading_whitespace_len..range.end + trailing_whitespace_len; - - language.brackets().any(|(pair, enabled)| { - let pair_start = pair.start.trim_end(); - let pair_end = pair.end.trim_start(); - - enabled - && pair.newline - && buffer.contains_str_at(range.end, pair_end) - && buffer.contains_str_at( - range.start.saturating_sub_usize(pair_start.len()), - pair_start, - ) - }) + language: &LanguageScope, +) -> Option> { + let delimiters = language.line_comment_prefixes(); + let max_len_of_delimiter = delimiters.iter().map(|delimiter| delimiter.len()).max()?; + let (snapshot, range) = buffer.buffer_line_for_row(MultiBufferRow(start_point.row))?; + + let num_of_whitespaces = snapshot + .chars_for_range(range.clone()) + .take_while(|c| c.is_whitespace()) + .count(); + let comment_candidate = snapshot + .chars_for_range(range.clone()) + .skip(num_of_whitespaces) + .take(max_len_of_delimiter) + .collect::(); + let (delimiter, trimmed_len) = delimiters + .iter() + .filter_map(|delimiter| { + let prefix = delimiter.trim_end(); + if comment_candidate.starts_with(prefix) { + Some((delimiter, prefix.len())) + } else { + None + } + }) + .max_by_key(|(_, len)| *len)?; + + if let Some(BlockCommentConfig { + start: block_start, .. + }) = language.block_comment() + { + let block_start_trimmed = block_start.trim_end(); + if block_start_trimmed.starts_with(delimiter.trim_end()) { + let line_content = snapshot + .chars_for_range(range) + .skip(num_of_whitespaces) + .take(block_start_trimmed.len()) + .collect::(); + + if line_content.starts_with(block_start_trimmed) { + return None; + } + } + } + + let cursor_is_placed_after_comment_marker = + num_of_whitespaces + trimmed_len <= start_point.column as usize; + if cursor_is_placed_after_comment_marker { + Some(delimiter.clone()) + } else { + None + } } -fn insert_extra_newline_tree_sitter( +fn documentation_delimiter_for_newline( + start_point: &Point, buffer: &MultiBufferSnapshot, - range: Range, -) -> bool { - let (buffer, range) = match buffer.range_to_buffer_ranges(range).as_slice() { - [(buffer, range, _)] => (*buffer, range.clone()), - _ => return false, + language: &LanguageScope, + newline_formatting: &mut NewlineFormatting, +) -> Option> { + let BlockCommentConfig { + start: start_tag, + end: end_tag, + prefix: delimiter, + tab_size: len, + } = language.documentation_comment()?; + let is_within_block_comment = buffer + .language_scope_at(*start_point) + .is_some_and(|scope| scope.override_name() == Some("comment")); + if !is_within_block_comment { + return None; + } + + let (snapshot, range) = buffer.buffer_line_for_row(MultiBufferRow(start_point.row))?; + + let num_of_whitespaces = snapshot + .chars_for_range(range.clone()) + .take_while(|c| c.is_whitespace()) + .count(); + + // It is safe to use a column from MultiBufferPoint in context of a single buffer ranges, because we're only ever looking at a single line at a time. + let column = start_point.column; + let cursor_is_after_start_tag = { + let start_tag_len = start_tag.len(); + let start_tag_line = snapshot + .chars_for_range(range.clone()) + .skip(num_of_whitespaces) + .take(start_tag_len) + .collect::(); + if start_tag_line.starts_with(start_tag.as_ref()) { + num_of_whitespaces + start_tag_len <= column as usize + } else { + false + } }; - let pair = { - let mut result: Option> = None; - for pair in buffer - .all_bracket_ranges(range.start.0..range.end.0) - .filter(move |pair| { - pair.open_range.start <= range.start.0 && pair.close_range.end >= range.end.0 - }) - { - let len = pair.close_range.end - pair.open_range.start; + let cursor_is_after_delimiter = { + let delimiter_trim = delimiter.trim_end(); + let delimiter_line = snapshot + .chars_for_range(range.clone()) + .skip(num_of_whitespaces) + .take(delimiter_trim.len()) + .collect::(); + if delimiter_line.starts_with(delimiter_trim) { + num_of_whitespaces + delimiter_trim.len() <= column as usize + } else { + false + } + }; - if let Some(existing) = &result { - let existing_len = existing.close_range.end - existing.open_range.start; - if len > existing_len { - continue; + let cursor_is_before_end_tag_if_exists = { + let mut char_position = 0u32; + let mut end_tag_offset = None; + + 'outer: for chunk in snapshot.text_for_range(range) { + if let Some(byte_pos) = chunk.find(&**end_tag) { + let chars_before_match = chunk[..byte_pos].chars().count() as u32; + end_tag_offset = Some(char_position + chars_before_match); + break 'outer; + } + char_position += chunk.chars().count() as u32; + } + + if let Some(end_tag_offset) = end_tag_offset { + let cursor_is_before_end_tag = column <= end_tag_offset; + if cursor_is_after_start_tag { + if cursor_is_before_end_tag { + newline_formatting.insert_extra_newline = true; + } + let cursor_is_at_start_of_end_tag = column == end_tag_offset; + if cursor_is_at_start_of_end_tag { + newline_formatting.indent_on_extra_newline.len = *len; } } + cursor_is_before_end_tag + } else { + true + } + }; - result = Some(pair); + if (cursor_is_after_start_tag || cursor_is_after_delimiter) + && cursor_is_before_end_tag_if_exists + { + if cursor_is_after_start_tag { + newline_formatting.indent_on_newline.len = *len; } + Some(delimiter.clone()) + } else { + None + } +} - result - }; - let Some(pair) = pair else { - return false; - }; - pair.newline_only - && buffer - .chars_for_range(pair.open_range.end..range.start.0) - .chain(buffer.chars_for_range(range.end.0..pair.close_range.start)) - .all(|c| c.is_whitespace() && c != '\n') +#[derive(Debug, Default)] +struct NewlineFormatting { + insert_extra_newline: bool, + indent_on_newline: IndentSize, + indent_on_extra_newline: IndentSize, +} + +impl NewlineFormatting { + fn new( + buffer: &MultiBufferSnapshot, + range: Range, + language: &LanguageScope, + ) -> Self { + Self { + insert_extra_newline: Self::insert_extra_newline_brackets( + buffer, + range.clone(), + language, + ) || Self::insert_extra_newline_tree_sitter(buffer, range), + indent_on_newline: IndentSize::spaces(0), + indent_on_extra_newline: IndentSize::spaces(0), + } + } + + fn insert_extra_newline_brackets( + buffer: &MultiBufferSnapshot, + range: Range, + language: &language::LanguageScope, + ) -> bool { + let leading_whitespace_len = buffer + .reversed_chars_at(range.start) + .take_while(|c| c.is_whitespace() && *c != '\n') + .map(|c| c.len_utf8()) + .sum::(); + let trailing_whitespace_len = buffer + .chars_at(range.end) + .take_while(|c| c.is_whitespace() && *c != '\n') + .map(|c| c.len_utf8()) + .sum::(); + let range = range.start - leading_whitespace_len..range.end + trailing_whitespace_len; + + language.brackets().any(|(pair, enabled)| { + let pair_start = pair.start.trim_end(); + let pair_end = pair.end.trim_start(); + + enabled + && pair.newline + && buffer.contains_str_at(range.end, pair_end) + && buffer.contains_str_at( + range.start.saturating_sub_usize(pair_start.len()), + pair_start, + ) + }) + } + + fn insert_extra_newline_tree_sitter( + buffer: &MultiBufferSnapshot, + range: Range, + ) -> bool { + let (buffer, range) = match buffer.range_to_buffer_ranges(range).as_slice() { + [(buffer, range, _)] => (*buffer, range.clone()), + _ => return false, + }; + let pair = { + let mut result: Option> = None; + + for pair in buffer + .all_bracket_ranges(range.start.0..range.end.0) + .filter(move |pair| { + pair.open_range.start <= range.start.0 && pair.close_range.end >= range.end.0 + }) + { + let len = pair.close_range.end - pair.open_range.start; + + if let Some(existing) = &result { + let existing_len = existing.close_range.end - existing.open_range.start; + if len > existing_len { + continue; + } + } + + result = Some(pair); + } + + result + }; + let Some(pair) = pair else { + return false; + }; + pair.newline_only + && buffer + .chars_for_range(pair.open_range.end..range.start.0) + .chain(buffer.chars_for_range(range.end.0..pair.close_range.start)) + .all(|c| c.is_whitespace() && c != '\n') + } } fn update_uncommitted_diff_for_buffer( @@ -25909,7 +25969,7 @@ impl BreakpointPromptEditor { self.editor .update(cx, |editor, cx| { editor.remove_blocks(self.block_ids.clone(), None, cx); - window.focus(&editor.focus_handle); + window.focus(&editor.focus_handle, cx); }) .log_err(); } diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index dfc8fd7f901bf1f45352511e3b7e69f7f4d4b367..1b84197471bd9ad65dc0ac31bf42c6ddc5ee3bf5 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -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::{ @@ -18199,7 +18201,7 @@ async fn test_on_type_formatting_not_triggered(cx: &mut TestAppContext) { ); editor_handle.update_in(cx, |editor, window, cx| { - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([Point::new(0, 21)..Point::new(0, 20)]) }); @@ -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! {" { ˇ @@ -25954,6 +25972,48 @@ async fn test_indent_on_newline_for_python(cx: &mut TestAppContext) { "}); } +#[gpui::test] +async fn test_python_indent_in_markdown(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let language_registry = Arc::new(language::LanguageRegistry::test(cx.executor())); + let python_lang = languages::language("python", tree_sitter_python::LANGUAGE.into()); + language_registry.add(markdown_lang()); + language_registry.add(python_lang); + + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| { + buffer.set_language_registry(language_registry); + buffer.set_language(Some(markdown_lang()), cx); + }); + + // Test that `else:` correctly outdents to match `if:` inside the Python code block + cx.set_state(indoc! {" + # Heading + + ```python + def main(): + if condition: + pass + ˇ + ``` + "}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("else:", window, cx); + }); + cx.run_until_parked(); + cx.assert_editor_state(indoc! {" + # Heading + + ```python + def main(): + if condition: + pass + else:ˇ + ``` + "}); +} + #[gpui::test] async fn test_tab_in_leading_whitespace_auto_indents_for_bash(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -25980,6 +26040,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 +26058,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 +26093,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 +26136,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 +26171,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 +26187,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 +26205,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 +26223,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 +26239,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 +26260,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 +26283,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 +26303,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 +26330,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 +26344,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 +26359,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 +26374,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 +26388,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 +26402,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 +26419,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 +26435,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 +26449,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 +29408,202 @@ 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| project::trusted_worktrees::init(HashMap::default(), None, None, 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::( + 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()"); +} + +#[gpui::test] +fn test_editor_rendering_when_positioned_above_viewport(cx: &mut TestAppContext) { + // This test reproduces a bug where drawing an editor at a position above the viewport + // (simulating what happens when an AutoHeight editor inside a List is scrolled past) + // causes an infinite loop in blocks_in_range. + // + // The issue: when the editor's bounds.origin.y is very negative (above the viewport), + // the content mask intersection produces visible_bounds with origin at the viewport top. + // This makes clipped_top_in_lines very large, causing start_row to exceed max_row. + // When blocks_in_range is called with start_row > max_row, the cursor seeks to the end + // but the while loop after seek never terminates because cursor.next() is a no-op at end. + init_test(cx, |_| {}); + + let window = cx.add_window(|_, _| gpui::Empty); + let mut cx = VisualTestContext::from_window(*window, cx); + + let buffer = cx.update(|_, cx| MultiBuffer::build_simple("a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n", cx)); + let editor = cx.new_window_entity(|window, cx| build_editor(buffer, window, cx)); + + // Simulate a small viewport (500x500 pixels at origin 0,0) + cx.simulate_resize(gpui::size(px(500.), px(500.))); + + // Draw the editor at a very negative Y position, simulating an editor that's been + // scrolled way above the visible viewport (like in a List that has scrolled past it). + // The editor is 3000px tall but positioned at y=-10000, so it's entirely above the viewport. + // This should NOT hang - it should just render nothing. + cx.draw( + gpui::point(px(0.), px(-10000.)), + gpui::size(px(500.), px(3000.)), + |_, _| editor.clone(), + ); + + // If we get here without hanging, the test passes +} diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 8de660275ba9b455aec610568c41347888654495..85b32324a1c1cc7fb84162fb120e8ef0e4e8b599 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -9164,6 +9164,15 @@ impl Element for EditorElement { let height_in_lines = f64::from(bounds.size.height / line_height); let max_row = snapshot.max_point().row().as_f64(); + // Calculate how much of the editor is clipped by parent containers (e.g., List). + // This allows us to only render lines that are actually visible, which is + // critical for performance when large AutoHeight editors are inside Lists. + let visible_bounds = window.content_mask().bounds; + let clipped_top = (visible_bounds.origin.y - bounds.origin.y).max(px(0.)); + let clipped_top_in_lines = f64::from(clipped_top / line_height); + let visible_height_in_lines = + f64::from(visible_bounds.size.height / line_height); + // The max scroll position for the top of the window let max_scroll_top = if matches!( snapshot.mode, @@ -9220,10 +9229,16 @@ impl Element for EditorElement { let mut scroll_position = snapshot.scroll_position(); // The scroll position is a fractional point, the whole number of which represents // the top of the window in terms of display rows. - let start_row = DisplayRow(scroll_position.y as u32); + // We add clipped_top_in_lines to skip rows that are clipped by parent containers, + // but we don't modify scroll_position itself since the parent handles positioning. let max_row = snapshot.max_point().row(); + let start_row = cmp::min( + DisplayRow((scroll_position.y + clipped_top_in_lines).floor() as u32), + max_row, + ); let end_row = cmp::min( - (scroll_position.y + height_in_lines).ceil() as u32, + (scroll_position.y + clipped_top_in_lines + visible_height_in_lines).ceil() + as u32, max_row.next_row().0, ); let end_row = DisplayRow(end_row); diff --git a/crates/editor/src/hover_links.rs b/crates/editor/src/hover_links.rs index d7e4169a721765e0f93805bf0c157033bf0cafab..1c00acbfa9f1a69cbe01c45758db5a0cd4fee757 100644 --- a/crates/editor/src/hover_links.rs +++ b/crates/editor/src/hover_links.rs @@ -218,7 +218,7 @@ impl Editor { self.hide_hovered_link(cx); if !hovered_link_state.links.is_empty() { if !self.focus_handle.is_focused(window) { - window.focus(&self.focus_handle); + window.focus(&self.focus_handle, cx); } // exclude links pointing back to the current anchor diff --git a/crates/editor/src/jsx_tag_auto_close.rs b/crates/editor/src/jsx_tag_auto_close.rs index e22fde313df4b99b7b650775ad7e7397e3c4f813..1d808c968d579569fb595a5a1a0ddaa4dbc718b3 100644 --- a/crates/editor/src/jsx_tag_auto_close.rs +++ b/crates/editor/src/jsx_tag_auto_close.rs @@ -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. diff --git a/crates/editor/src/mouse_context_menu.rs b/crates/editor/src/mouse_context_menu.rs index 36521d46a6c20223e973346b9d1e9391db3306ca..7314991bd5e4842f395383888a87b4e2db7e0a0c 100644 --- a/crates/editor/src/mouse_context_menu.rs +++ b/crates/editor/src/mouse_context_menu.rs @@ -90,8 +90,8 @@ impl MouseContextMenu { // `true` when the `ContextMenu` is focused. let focus_handle = context_menu_focus.clone(); cx.on_next_frame(window, move |_, window, cx| { - cx.on_next_frame(window, move |_, window, _cx| { - window.focus(&focus_handle); + cx.on_next_frame(window, move |_, window, cx| { + window.focus(&focus_handle, cx); }); }); @@ -100,7 +100,7 @@ impl MouseContextMenu { move |editor, _, _event: &DismissEvent, window, cx| { editor.mouse_context_menu.take(); if context_menu_focus.contains_focused(window, cx) { - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); } } }); @@ -127,7 +127,7 @@ impl MouseContextMenu { } editor.mouse_context_menu.take(); if context_menu_focus.contains_focused(window, cx) { - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); } }, ); @@ -161,7 +161,7 @@ pub fn deploy_context_menu( cx: &mut Context, ) { if !editor.is_focused(window) { - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); } // Don't show context menu for inline editors diff --git a/crates/editor/src/test.rs b/crates/editor/src/test.rs index 5a0652bdd199a638f92234b1d50232071db18e07..1cc619385446502db6a3a0dceb6e70fa4b4e8416 100644 --- a/crates/editor/src/test.rs +++ b/crates/editor/src/test.rs @@ -176,11 +176,9 @@ pub fn block_content_for_tests( } pub fn editor_content_with_blocks(editor: &Entity, cx: &mut VisualTestContext) -> String { - cx.draw( - gpui::Point::default(), - size(px(3000.0), px(3000.0)), - |_, _| editor.clone(), - ); + let draw_size = size(px(3000.0), px(3000.0)); + cx.simulate_resize(draw_size); + cx.draw(gpui::Point::default(), draw_size, |_, _| editor.clone()); let (snapshot, mut lines, blocks) = editor.update_in(cx, |editor, window, cx| { let snapshot = editor.snapshot(window, cx); let text = editor.display_text(cx); diff --git a/crates/editor/src/test/editor_lsp_test_context.rs b/crates/editor/src/test/editor_lsp_test_context.rs index 3afe0e6134221fc69837abd30618f2b74ae069f5..7c4c0e48d36dbb9f74a1c835c63fa2b91c5681d9 100644 --- a/crates/editor/src/test/editor_lsp_test_context.rs +++ b/crates/editor/src/test/editor_lsp_test_context.rs @@ -126,7 +126,7 @@ impl EditorLspTestContext { .read(cx) .nav_history_for_item(&cx.entity()); editor.set_nav_history(Some(nav_history)); - window.focus(&editor.focus_handle(cx)) + window.focus(&editor.focus_handle(cx), cx) }); let lsp = fake_servers.next().await.unwrap(); diff --git a/crates/editor/src/test/editor_test_context.rs b/crates/editor/src/test/editor_test_context.rs index 511629c59d8f61f1c53f5deaa406f113b9dfc3d9..267058691d0070678830ba9d7c40f54a9363737b 100644 --- a/crates/editor/src/test/editor_test_context.rs +++ b/crates/editor/src/test/editor_test_context.rs @@ -78,7 +78,7 @@ impl EditorTestContext { cx, ); - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); editor }); let editor_view = editor.root(cx).unwrap(); @@ -139,7 +139,7 @@ impl EditorTestContext { let editor = cx.add_window(|window, cx| { let editor = build_editor(buffer, window, cx); - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); editor }); @@ -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 = diff --git a/crates/eval/src/instance.rs b/crates/eval/src/instance.rs index 4c71a5a82b3946a9cc6e22ced378ebaabeec5256..8c9da3eefab61e4fa5897f9d76123c3fe1d5fa8b 100644 --- a/crates/eval/src/instance.rs +++ b/crates/eval/src/instance.rs @@ -202,6 +202,7 @@ impl ExampleInstance { app_state.languages.clone(), app_state.fs.clone(), None, + false, cx, ); diff --git a/crates/extension_host/src/wasm_host.rs b/crates/extension_host/src/wasm_host.rs index 5194cafec2601dd2ef17a2e6744488c2326a5f15..7b38ef5ab630ac20a0a94daf4d9a5118059afce8 100644 --- a/crates/extension_host/src/wasm_host.rs +++ b/crates/extension_host/src/wasm_host.rs @@ -47,7 +47,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 { @@ -690,8 +690,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('\\', "/"); @@ -861,11 +861,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 } diff --git a/crates/extension_host/src/wasm_host/llm_provider.rs b/crates/extension_host/src/wasm_host/llm_provider.rs index 4c50870db8fdd340c61635c8b64ca8226d8d6aca..a5c2c286eb4f0876fb0c4994e710dc3c0a02af81 100644 --- a/crates/extension_host/src/wasm_host/llm_provider.rs +++ b/crates/extension_host/src/wasm_host/llm_provider.rs @@ -1508,8 +1508,8 @@ impl Render for OAuthCodeVerificationWindow { .on_action(cx.listener(|_, _: &menu::Cancel, _, cx| { cx.emit(DismissEvent); })) - .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _| { - window.focus(&this.focus_handle); + .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| { + window.focus(&this.focus_handle, cx); })) .child(self.render_icon(cx)) .child(prompt) diff --git a/crates/extension_host/src/wasm_host/wit.rs b/crates/extension_host/src/wasm_host/wit.rs index 84fe2af71c317bab0f944041f5d5be2fee9fa462..42003b7cfbaf778af4b0d898094add29464cba40 100644 --- a/crates/extension_host/src/wasm_host/wit.rs +++ b/crates/extension_host/src/wasm_host/wit.rs @@ -58,7 +58,7 @@ pub fn new_linker( f: impl Fn(&mut Linker, fn(&mut WasmState) -> &mut WasmState) -> Result<()>, ) -> Linker { 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 } diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs index d07a064a5ca76d6cc1db7980499bb6acdefcb843..e9e039002f171199f12a4f209267e53b79301306 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs @@ -1,7 +1,7 @@ use crate::wasm_host::wit::since_v0_8_0::{ dap::{ - AttachRequest, BuildTaskDefinition, BuildTaskDefinitionTemplatePayload, LaunchRequest, - StartDebuggingRequestArguments, TcpArguments, TcpArgumentsTemplate, + BuildTaskDefinition, BuildTaskDefinitionTemplatePayload, StartDebuggingRequestArguments, + TcpArguments, TcpArgumentsTemplate, }, lsp::{CompletionKind, CompletionLabelDetails, InsertTextFormat, SymbolKind}, slash_command::SlashCommandOutputSection, @@ -772,6 +772,7 @@ impl nodejs::Host for WasmState { .node_runtime .npm_package_latest_version(&package_name) .await + .map(|v| v.to_string()) .to_wasmtime_result() } @@ -783,6 +784,7 @@ impl nodejs::Host for WasmState { .node_runtime .npm_package_installed_version(&self.work_dir(), &package_name) .await + .map(|option| option.map(|version| version.to_string())) .to_wasmtime_result() } diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index 050d7a45a1b46e94a195f88e49fd6795ce37f09f..73b21bb828a598d5bbc53c0ecf4511988c30bc65 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -1713,7 +1713,7 @@ impl PickerDelegate for FileFinderDelegate { ui::IconPosition::End, Some(ToggleIncludeIgnored.boxed_clone()), move |window, cx| { - window.focus(&focus_handle); + window.focus(&focus_handle, cx); window.dispatch_action( ToggleIncludeIgnored.boxed_clone(), cx, diff --git a/crates/git/src/git.rs b/crates/git/src/git.rs index 8b8f88ef65b86ea9157e1c3217fa01bb0d6355cb..805d8d181ab7a434b565d38bdb2f802a8a3cda1a 100644 --- a/crates/git/src/git.rs +++ b/crates/git/src/git.rs @@ -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, diff --git a/crates/git_ui/src/branch_picker.rs b/crates/git_ui/src/branch_picker.rs index 79cd89d1485f6d99349b43d92c17261cf8a644e2..4db37e91b8720e51ff0416cc471842483ab1d0ca 100644 --- a/crates/git_ui/src/branch_picker.rs +++ b/crates/git_ui/src/branch_picker.rs @@ -72,32 +72,26 @@ pub fn open( let repository = workspace.project().read(cx).active_repository(cx); let style = BranchListStyle::Modal; workspace.toggle_modal(window, cx, |window, cx| { - BranchList::new( - Some(workspace_handle), - repository, - style, - rems(34.), - window, - cx, - ) + BranchList::new(workspace_handle, repository, style, rems(34.), window, cx) }) } pub fn popover( + workspace: WeakEntity, repository: Option>, window: &mut Window, cx: &mut App, ) -> Entity { cx.new(|cx| { let list = BranchList::new( - None, + workspace, repository, BranchListStyle::Popover, rems(20.), window, cx, ); - list.focus_handle(cx).focus(window); + list.focus_handle(cx).focus(window, cx); list }) } @@ -117,7 +111,7 @@ pub struct BranchList { impl BranchList { fn new( - workspace: Option>, + workspace: WeakEntity, repository: Option>, style: BranchListStyle, width: Rems, @@ -316,23 +310,23 @@ impl Entry { #[derive(Clone, Copy, PartialEq)] enum BranchFilter { - /// Only show local branches - Local, - /// Only show remote branches + /// Show both local and remote branches. + All, + /// Only show remote branches. Remote, } impl BranchFilter { fn invert(&self) -> Self { match self { - BranchFilter::Local => BranchFilter::Remote, - BranchFilter::Remote => BranchFilter::Local, + BranchFilter::All => BranchFilter::Remote, + BranchFilter::Remote => BranchFilter::All, } } } pub struct BranchListDelegate { - workspace: Option>, + workspace: WeakEntity, matches: Vec, all_branches: Option>, default_branch: Option, @@ -360,7 +354,7 @@ enum PickerState { impl BranchListDelegate { fn new( - workspace: Option>, + workspace: WeakEntity, repo: Option>, style: BranchListStyle, cx: &mut Context, @@ -375,7 +369,7 @@ impl BranchListDelegate { selected_index: 0, last_query: Default::default(), modifiers: Default::default(), - branch_filter: BranchFilter::Local, + branch_filter: BranchFilter::All, state: PickerState::List, focus_handle: cx.focus_handle(), } @@ -464,7 +458,7 @@ impl BranchListDelegate { log::error!("Failed to delete branch: {}", e); } - if let Some(workspace) = workspace.and_then(|w| w.upgrade()) { + if let Some(workspace) = workspace.upgrade() { cx.update(|_window, cx| { if is_remote { show_error_toast( @@ -518,7 +512,7 @@ impl PickerDelegate for BranchListDelegate { match self.state { PickerState::List | PickerState::NewRemote | PickerState::NewBranch => { match self.branch_filter { - BranchFilter::Local => "Select branch…", + BranchFilter::All => "Select branch or remote…", BranchFilter::Remote => "Select remote…", } } @@ -560,8 +554,8 @@ impl PickerDelegate for BranchListDelegate { self.editor_position() == PickerEditorPosition::End, |this| { let tooltip_label = match self.branch_filter { - BranchFilter::Local => "Turn Off Remote Filter", - BranchFilter::Remote => "Filter Remote Branches", + BranchFilter::All => "Filter Remote Branches", + BranchFilter::Remote => "Show All Branches", }; this.gap_1().justify_between().child({ @@ -625,40 +619,38 @@ impl PickerDelegate for BranchListDelegate { return Task::ready(()); }; - let display_remotes = self.branch_filter; + let branch_filter = self.branch_filter; cx.spawn_in(window, async move |picker, cx| { + let branch_matches_filter = |branch: &Branch| match branch_filter { + BranchFilter::All => true, + BranchFilter::Remote => branch.is_remote(), + }; + let mut matches: Vec = if query.is_empty() { - all_branches + let mut matches: Vec = all_branches .into_iter() - .filter(|branch| { - if display_remotes == BranchFilter::Remote { - branch.is_remote() - } else { - !branch.is_remote() - } - }) + .filter(|branch| branch_matches_filter(branch)) .map(|branch| Entry::Branch { branch, positions: Vec::new(), }) - .collect() + .collect(); + + // Keep the existing recency sort within each group, but show local branches first. + matches.sort_by_key(|entry| entry.as_branch().is_some_and(|b| b.is_remote())); + + matches } else { let branches = all_branches .iter() - .filter(|branch| { - if display_remotes == BranchFilter::Remote { - branch.is_remote() - } else { - !branch.is_remote() - } - }) + .filter(|branch| branch_matches_filter(branch)) .collect::>(); let candidates = branches .iter() .enumerate() .map(|(ix, branch)| StringMatchCandidate::new(ix, branch.name())) .collect::>(); - fuzzy::match_strings( + let mut matches: Vec = fuzzy::match_strings( &candidates, &query, true, @@ -673,7 +665,12 @@ impl PickerDelegate for BranchListDelegate { branch: branches[candidate.candidate_id].clone(), positions: candidate.positions, }) - .collect() + .collect(); + + // Keep fuzzy-relevance ordering within local/remote groups, but show locals first. + matches.sort_by_key(|entry| entry.as_branch().is_some_and(|b| b.is_remote())); + + matches }; picker .update(cx, |picker, _| { @@ -841,10 +838,13 @@ impl PickerDelegate for BranchListDelegate { Entry::NewUrl { .. } | Entry::NewBranch { .. } | Entry::NewRemoteName { .. } => { Icon::new(IconName::Plus).color(Color::Muted) } - Entry::Branch { .. } => match self.branch_filter { - BranchFilter::Local => Icon::new(IconName::GitBranchAlt).color(Color::Muted), - BranchFilter::Remote => Icon::new(IconName::Screen).color(Color::Muted), - }, + Entry::Branch { branch, .. } => { + if branch.is_remote() { + Icon::new(IconName::Screen).color(Color::Muted) + } else { + Icon::new(IconName::GitBranchAlt).color(Color::Muted) + } + } }; let entry_title = match entry { @@ -874,19 +874,21 @@ impl PickerDelegate for BranchListDelegate { Entry::NewUrl { .. } | Entry::NewBranch { .. } | Entry::NewRemoteName { .. } ); - let delete_branch_button = IconButton::new("delete", IconName::Trash) - .tooltip(move |_, cx| { - Tooltip::for_action_in( - "Delete Branch", - &branch_picker::DeleteBranch, - &focus_handle, - cx, - ) - }) - .on_click(cx.listener(|this, _, window, cx| { - let selected_idx = this.delegate.selected_index(); - this.delegate.delete_at(selected_idx, window, cx); - })); + let deleted_branch_icon = |entry_ix: usize, is_head_branch: bool| { + IconButton::new(("delete", entry_ix), IconName::Trash) + .tooltip(move |_, cx| { + Tooltip::for_action_in( + "Delete Branch", + &branch_picker::DeleteBranch, + &focus_handle, + cx, + ) + }) + .disabled(is_head_branch) + .on_click(cx.listener(move |this, _, window, cx| { + this.delegate.delete_at(entry_ix, window, cx); + })) + }; let create_from_default_button = self.default_branch.as_ref().map(|default_branch| { let tooltip_label: SharedString = format!("Create New From: {default_branch}").into(); @@ -963,12 +965,12 @@ impl PickerDelegate for BranchListDelegate { "No commits found".into(), |subject| { if show_author_name - && author_name.is_some() + && let Some(author) = + author_name { format!( "{} • {}", - author_name.unwrap(), - subject + author, subject ) } else { subject.to_string() @@ -1002,10 +1004,12 @@ impl PickerDelegate for BranchListDelegate { self.editor_position() == PickerEditorPosition::End && !is_new_items, |this| { this.map(|this| { + let is_head_branch = + entry.as_branch().is_some_and(|branch| branch.is_head); if self.selected_index() == ix { - this.end_slot(delete_branch_button) + this.end_slot(deleted_branch_icon(ix, is_head_branch)) } else { - this.end_hover_slot(delete_branch_button) + this.end_hover_slot(deleted_branch_icon(ix, is_head_branch)) } }) }, @@ -1036,8 +1040,8 @@ impl PickerDelegate for BranchListDelegate { ) -> Option { matches!(self.state, PickerState::List).then(|| { let label = match self.branch_filter { - BranchFilter::Local => "Local", - BranchFilter::Remote => "Remote", + BranchFilter::All => "Branches", + BranchFilter::Remote => "Remotes", }; ListHeader::new(label).inset(true).into_any_element() @@ -1230,7 +1234,7 @@ mod tests { use super::*; use git::repository::{CommitSummary, Remote}; - use gpui::{TestAppContext, VisualTestContext}; + use gpui::{AppContext, TestAppContext, VisualTestContext}; use project::{FakeFs, Project}; use rand::{Rng, rngs::StdRng}; use serde_json::json; @@ -1279,35 +1283,47 @@ mod tests { ] } - fn init_branch_list_test( + async fn init_branch_list_test( repository: Option>, branches: Vec, cx: &mut TestAppContext, ) -> (Entity, VisualTestContext) { - let window = cx.add_window(|window, cx| { - let mut delegate = - BranchListDelegate::new(None, repository, BranchListStyle::Modal, cx); - delegate.all_branches = Some(branches); - let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); - let picker_focus_handle = picker.focus_handle(cx); - picker.update(cx, |picker, _| { - picker.delegate.focus_handle = picker_focus_handle.clone(); - }); + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; - let _subscription = cx.subscribe(&picker, |_, _, _, cx| { - cx.emit(DismissEvent); - }); + let workspace = cx.add_window(|window, cx| Workspace::test_new(project, window, cx)); - BranchList { - picker, - picker_focus_handle, - width: rems(34.), - _subscription, - } - }); + let branch_list = workspace + .update(cx, |workspace, window, cx| { + cx.new(|cx| { + let mut delegate = BranchListDelegate::new( + workspace.weak_handle(), + repository, + BranchListStyle::Modal, + cx, + ); + delegate.all_branches = Some(branches); + let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); + let picker_focus_handle = picker.focus_handle(cx); + picker.update(cx, |picker, _| { + picker.delegate.focus_handle = picker_focus_handle.clone(); + }); - let branch_list = window.root(cx).unwrap(); - let cx = VisualTestContext::from_window(*window, cx); + let _subscription = cx.subscribe(&picker, |_, _, _, cx| { + cx.emit(DismissEvent); + }); + + BranchList { + picker, + picker_focus_handle, + width: rems(34.), + _subscription, + } + }) + }) + .unwrap(); + + let cx = VisualTestContext::from_window(*workspace, cx); (branch_list, cx) } @@ -1343,7 +1359,7 @@ mod tests { init_test(cx); let branches = create_test_branches(); - let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx); + let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await; let cx = &mut ctx; branch_list @@ -1419,7 +1435,7 @@ mod tests { .await; cx.run_until_parked(); - let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx); + let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await; let cx = &mut ctx; update_branch_list_matches_with_empty_query(&branch_list, cx).await; @@ -1484,7 +1500,7 @@ mod tests { .await; cx.run_until_parked(); - let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx); + let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await; let cx = &mut ctx; // Enable remote filter branch_list.update(cx, |branch_list, cx| { @@ -1532,7 +1548,7 @@ mod tests { } #[gpui::test] - async fn test_update_remote_matches_with_query(cx: &mut TestAppContext) { + async fn test_branch_filter_shows_all_then_remotes_and_applies_query(cx: &mut TestAppContext) { init_test(cx); let branches = vec![ @@ -1542,39 +1558,54 @@ mod tests { create_test_branch("develop", false, None, Some(700)), ]; - let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx); + let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await; let cx = &mut ctx; update_branch_list_matches_with_empty_query(&branch_list, cx).await; - // Check matches, it should match all existing branches and no option to create new branch - branch_list - .update_in(cx, |branch_list, window, cx| { - branch_list.picker.update(cx, |picker, cx| { - assert_eq!(picker.delegate.matches.len(), 2); - let branches = picker - .delegate - .matches - .iter() - .map(|be| be.name()) - .collect::>(); - assert_eq!( - branches, - ["feature-ui", "develop"] - .into_iter() - .collect::>() - ); + branch_list.update(cx, |branch_list, cx| { + branch_list.picker.update(cx, |picker, _cx| { + assert_eq!(picker.delegate.matches.len(), 4); - // Verify the last entry is NOT the "create new branch" option - let last_match = picker.delegate.matches.last().unwrap(); - assert!(!last_match.is_new_branch()); - assert!(!last_match.is_new_url()); - picker.delegate.branch_filter = BranchFilter::Remote; - picker.delegate.update_matches(String::new(), window, cx) - }) + let branches = picker + .delegate + .matches + .iter() + .map(|be| be.name()) + .collect::>(); + assert_eq!( + branches, + ["origin/main", "fork/feature-auth", "feature-ui", "develop"] + .into_iter() + .collect::>() + ); + + // Locals should be listed before remotes. + let ordered = picker + .delegate + .matches + .iter() + .map(|be| be.name()) + .collect::>(); + assert_eq!( + ordered, + vec!["feature-ui", "develop", "origin/main", "fork/feature-auth"] + ); + + // Verify the last entry is NOT the "create new branch" option + let last_match = picker.delegate.matches.last().unwrap(); + assert!(!last_match.is_new_branch()); + assert!(!last_match.is_new_url()); }) - .await; - cx.run_until_parked(); + }); + + branch_list.update(cx, |branch_list, cx| { + branch_list.picker.update(cx, |picker, _cx| { + picker.delegate.branch_filter = BranchFilter::Remote; + }) + }); + + update_branch_list_matches_with_empty_query(&branch_list, cx).await; branch_list .update_in(cx, |branch_list, window, cx| { @@ -1637,7 +1668,8 @@ mod tests { create_test_branch(FEATURE_BRANCH, false, None, Some(900)), ]; - let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, test_cx); + let (branch_list, mut ctx) = + init_branch_list_test(repository.into(), branches, test_cx).await; let cx = &mut ctx; branch_list @@ -1696,7 +1728,7 @@ mod tests { let repository = init_fake_repository(cx).await; let branches = vec![create_test_branch("main", true, None, Some(1000))]; - let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx); + let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await; let cx = &mut ctx; branch_list @@ -1774,7 +1806,7 @@ mod tests { init_test(cx); let branches = vec![create_test_branch("main_branch", true, None, Some(1000))]; - let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx); + let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await; let cx = &mut ctx; branch_list @@ -1837,7 +1869,7 @@ mod tests { init_test(cx); let branches = vec![create_test_branch("main", true, None, Some(1000))]; - let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx); + let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await; let cx = &mut ctx; let subscription = cx.update(|_, cx| { @@ -1848,7 +1880,12 @@ mod tests { branch_list .update_in(cx, |branch_list, window, cx| { - window.focus(&branch_list.picker_focus_handle); + window.focus(&branch_list.picker_focus_handle, cx); + assert!( + branch_list.picker_focus_handle.is_focused(window), + "Branch picker should be focused when selecting an entry" + ); + branch_list.picker.update(cx, |picker, cx| { picker .delegate @@ -1860,6 +1897,9 @@ mod tests { cx.run_until_parked(); branch_list.update_in(cx, |branch_list, window, cx| { + // Re-focus the picker since workspace initialization during run_until_parked + window.focus(&branch_list.picker_focus_handle, cx); + branch_list.picker.update(cx, |picker, cx| { let last_match = picker.delegate.matches.last().unwrap(); assert!(last_match.is_new_url()); @@ -1893,7 +1933,7 @@ mod tests { .map(|i| create_test_branch(&format!("branch-{:02}", i), i == 0, None, Some(i * 100))) .collect(); - let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx); + let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await; let cx = &mut ctx; update_branch_list_matches_with_empty_query(&branch_list, cx).await; diff --git a/crates/git_ui/src/commit_modal.rs b/crates/git_ui/src/commit_modal.rs index 822b2c8385c2d573ceb2dc2872a685c47ff51379..e154933adc794221159c7f1b28b3d1e33cf1854d 100644 --- a/crates/git_ui/src/commit_modal.rs +++ b/crates/git_ui/src/commit_modal.rs @@ -337,6 +337,7 @@ impl CommitModal { active_repo, is_amend_pending, is_signoff_enabled, + workspace, ) = self.git_panel.update(cx, |git_panel, cx| { let (can_commit, tooltip) = git_panel.configure_commit_button(cx); let title = git_panel.commit_button_title(); @@ -354,6 +355,7 @@ impl CommitModal { active_repo, is_amend_pending, is_signoff_enabled, + git_panel.workspace.clone(), ) }); @@ -375,7 +377,14 @@ impl CommitModal { .style(ButtonStyle::Transparent); let branch_picker = PopoverMenu::new("popover-button") - .menu(move |window, cx| Some(branch_picker::popover(active_repo.clone(), window, cx))) + .menu(move |window, cx| { + Some(branch_picker::popover( + workspace.clone(), + active_repo.clone(), + window, + cx, + )) + }) .with_handle(self.branch_list_handle.clone()) .trigger_with_tooltip( branch_picker_button, @@ -512,7 +521,7 @@ impl CommitModal { fn toggle_branch_selector(&mut self, window: &mut Window, cx: &mut Context) { if self.branch_list_handle.is_focused(window, cx) { - self.focus_handle(cx).focus(window) + self.focus_handle(cx).focus(window, cx) } else { self.branch_list_handle.toggle(window, cx); } @@ -578,8 +587,8 @@ impl Render for CommitModal { .bg(cx.theme().colors().editor_background) .border_1() .border_color(cx.theme().colors().border_variant) - .on_click(cx.listener(move |_, _: &ClickEvent, window, _cx| { - window.focus(&editor_focus_handle); + .on_click(cx.listener(move |_, _: &ClickEvent, window, cx| { + window.focus(&editor_focus_handle, cx); })) .child( div() diff --git a/crates/git_ui/src/commit_view.rs b/crates/git_ui/src/commit_view.rs index 8cb9d82826086371950d2c51fd06381dd013251f..0f5420fec4169f8e3d945dd8bd0987ebbaba8d19 100644 --- a/crates/git_ui/src/commit_view.rs +++ b/crates/git_ui/src/commit_view.rs @@ -3,7 +3,10 @@ use buffer_diff::{BufferDiff, BufferDiffSnapshot}; use editor::display_map::{BlockPlacement, BlockProperties, BlockStyle}; use editor::{Editor, EditorEvent, ExcerptRange, MultiBuffer, multibuffer_context_lines}; use git::repository::{CommitDetails, CommitDiff, RepoPath}; -use git::{GitHostingProviderRegistry, GitRemote, parse_git_remote_url}; +use git::{ + BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, ParsedGitRemote, + parse_git_remote_url, +}; use gpui::{ AnyElement, App, AppContext as _, AsyncApp, AsyncWindowContext, Context, Element, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, @@ -393,13 +396,15 @@ impl CommitView { let remote_info = self.remote.as_ref().map(|remote| { let provider = remote.host.name(); - let url = format!( - "{}/{}/{}/commit/{}", - remote.host.base_url(), - remote.owner, - remote.repo, - commit.sha - ); + let parsed_remote = ParsedGitRemote { + owner: remote.owner.as_ref().into(), + repo: remote.repo.as_ref().into(), + }; + let params = BuildCommitPermalinkParams { sha: &commit.sha }; + let url = remote + .host + .build_commit_permalink(&parsed_remote, params) + .to_string(); (provider, url) }); diff --git a/crates/git_ui/src/file_history_view.rs b/crates/git_ui/src/file_history_view.rs index 4e91fe7e06a5823caac5bf00be8f48cc98dc8da4..f48160719ba5d9b00b8961b75e9ea402c80dd06a 100644 --- a/crates/git_ui/src/file_history_view.rs +++ b/crates/git_ui/src/file_history_view.rs @@ -633,9 +633,9 @@ impl Item for FileHistoryView { &mut self, _workspace: &mut Workspace, window: &mut Window, - _cx: &mut Context, + cx: &mut Context, ) { - window.focus(&self.focus_handle); + window.focus(&self.focus_handle, cx); } fn show_toolbar(&self) -> bool { diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 362423b79fed0e8f3428d6784dd6f15b47708247..cf73406b3851b46ad1a7d056d6cb335666b9ac65 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -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, ] ); @@ -274,6 +279,13 @@ impl GitListEntry { _ => None, } } + + fn directory_entry(&self) -> Option<&GitTreeDirEntry> { + match self { + GitListEntry::Directory(entry) => Some(entry), + _ => None, + } + } } enum GitPanelViewMode { @@ -589,7 +601,7 @@ pub struct GitPanel { tracked_staged_count: usize, update_visible_entries_task: Task<()>, width: Option, - workspace: WeakEntity, + pub(crate) workspace: WeakEntity, context_menu: Option<(Entity, Point, Subscription)>, modal_open: bool, show_placeholders: bool, @@ -890,14 +902,64 @@ impl GitPanel { cx.notify(); } - fn first_status_entry_index(&self) -> Option { - self.entries - .iter() - .position(|entry| entry.status_entry().is_some()) + fn expand_selected_entry( + &mut self, + _: &ExpandSelectedEntry, + window: &mut Window, + cx: &mut Context, + ) { + 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, + ) { + 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) { - if let Some(first_entry) = self.first_status_entry_index() { + let first_entry = match &self.view_mode { + GitPanelViewMode::Flat => self + .entries + .iter() + .position(|entry| entry.status_entry().is_some()), + GitPanelViewMode::Tree(state) => { + let index = self.entries.iter().position(|entry| { + entry.status_entry().is_some() || entry.directory_entry().is_some() + }); + + index.map(|index| state.logical_indices[index]) + } + }; + + if let Some(first_entry) = first_entry { self.selected_entry = Some(first_entry); self.scroll_to_selected_entry(cx); } @@ -914,28 +976,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) { @@ -944,25 +1022,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; + }; + + if selected_entry == item_count - 1 { + return; + } + + 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)] } + }; - self.scroll_to_selected_entry(cx); + 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); } - cx.notify(); + self.scroll_to_selected_entry(cx); } fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context) { @@ -974,20 +1063,18 @@ impl GitPanel { fn focus_editor(&mut self, _: &FocusEditor, window: &mut Window, cx: &mut Context) { self.commit_editor.update(cx, |editor, cx| { - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); }); cx.notify(); } - fn select_first_entry_if_none(&mut self, cx: &mut Context) { + fn select_first_entry_if_none(&mut self, window: &mut Window, cx: &mut Context) { let have_entries = self .active_repository .as_ref() .is_some_and(|active_repository| active_repository.read(cx).status_summary().count > 0); if have_entries && self.selected_entry.is_none() { - self.selected_entry = self.first_status_entry_index(); - self.scroll_to_selected_entry(cx); - cx.notify(); + self.select_first(&SelectFirst, window, cx); } } @@ -997,10 +1084,8 @@ impl GitPanel { window: &mut Window, cx: &mut Context, ) { - self.select_first_entry_if_none(cx); - - self.focus_handle.focus(window); - cx.notify(); + self.focus_handle.focus(window, cx); + self.select_first_entry_if_none(window, cx); } fn get_selected_entry(&self) -> Option<&GitListEntry> { @@ -1021,7 +1106,7 @@ impl GitPanel { .project_path_to_repo_path(&project_path, cx) .as_ref() { - project_diff.focus_handle(cx).focus(window); + project_diff.focus_handle(cx).focus(window, cx); project_diff.update(cx, |project_diff, cx| project_diff.autoscroll(cx)); return None; }; @@ -1031,7 +1116,7 @@ impl GitPanel { ProjectDiff::deploy_at(workspace, Some(entry.clone()), window, cx); }) .ok(); - self.focus_handle.focus(window); + self.focus_handle.focus(window, cx); Some(()) }); @@ -1134,14 +1219,14 @@ impl GitPanel { let prompt = window.prompt( PromptLevel::Warning, &format!( - "Are you sure you want to restore {}?", + "Are you sure you want to discard changes to {}?", entry .repo_path .file_name() .unwrap_or(entry.repo_path.display(path_style).as_ref()), ), None, - &["Restore", "Cancel"], + &["Discard Changes", "Cancel"], cx, ); cx.background_spawn(prompt) @@ -2042,7 +2127,10 @@ impl GitPanel { let commit_message = self.custom_or_suggested_commit_message(window, cx); let Some(mut message) = commit_message else { - self.commit_editor.read(cx).focus_handle(cx).focus(window); + self.commit_editor + .read(cx) + .focus_handle(cx) + .focus(window, cx); return; }; @@ -2376,6 +2464,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) { if !self.can_commit() || !AgentSettings::get_global(cx).enabled(cx) { @@ -2406,6 +2519,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 +2561,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 +2584,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 { @@ -3443,7 +3563,7 @@ impl GitPanel { self.bulk_staging = bulk_staging; } - self.select_first_entry_if_none(cx); + self.select_first_entry_if_none(window, cx); let suggested_commit_message = self.suggest_commit_message(cx); let placeholder_text = suggested_commit_message.unwrap_or("Enter commit message".into()); @@ -4028,7 +4148,7 @@ impl GitPanel { .border_color(cx.theme().colors().border) .cursor_text() .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| { - window.focus(&this.commit_editor.focus_handle(cx)); + window.focus(&this.commit_editor.focus_handle(cx), cx); })) .child( h_flex() @@ -4592,7 +4712,7 @@ impl GitPanel { let restore_title = if entry.status.is_created() { "Trash File" } else { - "Restore File" + "Discard Changes" }; let context_menu = ContextMenu::build(window, cx, |context_menu, _, _| { let is_created = entry.status.is_created(); @@ -4822,7 +4942,7 @@ impl GitPanel { this.open_file(&Default::default(), window, cx) } else { this.open_diff(&Default::default(), window, cx); - this.focus_handle.focus(window); + this.focus_handle.focus(window, cx); } }) }) @@ -4843,7 +4963,7 @@ impl GitPanel { cx.stop_propagation(); }, ) - .child(name_row) + .child(name_row.overflow_x_hidden()) .child( div() .id(checkbox_wrapper_id) @@ -4997,7 +5117,7 @@ impl GitPanel { this.toggle_directory(&key, window, cx); }) }) - .child(name_row) + .child(name_row.overflow_x_hidden()) .child( div() .id(checkbox_wrapper_id) @@ -5264,6 +5384,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)) @@ -5509,10 +5631,14 @@ impl RenderOnce for PanelRepoFooter { .as_ref() .map(|panel| panel.read(cx).project.clone()); - let repo = self + let (workspace, repo) = self .git_panel .as_ref() - .and_then(|panel| panel.read(cx).active_repository.clone()); + .map(|panel| { + let panel = panel.read(cx); + (panel.workspace.clone(), panel.active_repository.clone()) + }) + .unzip(); let single_repo = project .as_ref() @@ -5600,7 +5726,11 @@ impl RenderOnce for PanelRepoFooter { }); let branch_selector = PopoverMenu::new("popover-button") - .menu(move |window, cx| Some(branch_picker::popover(repo.clone(), window, cx))) + .menu(move |window, cx| { + let workspace = workspace.clone()?; + let repo = repo.clone().flatten(); + Some(branch_picker::popover(workspace, repo, window, cx)) + }) .trigger_with_tooltip( branch_selector_button, Tooltip::for_action_title("Switch Branch", &zed_actions::git::Switch), diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index 54adc8130d78e80af5c561541efb8128f1b2a017..5f50e4ef8029d8f57cd159bc7da68b668b628f48 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -817,7 +817,7 @@ impl GitCloneModal { }); let focus_handle = repo_input.focus_handle(cx); - window.focus(&focus_handle); + window.focus(&focus_handle, cx); Self { panel, diff --git a/crates/git_ui/src/onboarding.rs b/crates/git_ui/src/onboarding.rs index d1709e043b92216e974c1a4f451db5c28b98f773..eccb18a5400647ff86e44f4426d271d6c9361164 100644 --- a/crates/git_ui/src/onboarding.rs +++ b/crates/git_ui/src/onboarding.rs @@ -85,8 +85,8 @@ impl Render for GitOnboardingModal { git_onboarding_event!("Cancelled", trigger = "Action"); cx.emit(DismissEvent); })) - .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| { - this.focus_handle.focus(window); + .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| { + this.focus_handle.focus(window, cx); })) .child( div().p_1p5().absolute().inset_0().h(px(160.)).child( diff --git a/crates/git_ui/src/project_diff.rs b/crates/git_ui/src/project_diff.rs index 4d7a27354b1b4b6e972579e73c48bcd4c2448a5c..0e0632d9d049f54a648f65c55a96d639c9103e4d 100644 --- a/crates/git_ui/src/project_diff.rs +++ b/crates/git_ui/src/project_diff.rs @@ -492,7 +492,7 @@ impl ProjectDiff { if editor.focus_handle(cx).contains_focused(window, cx) && self.multibuffer.read(cx).is_empty() { - self.focus_handle.focus(window) + self.focus_handle.focus(window, cx) } } @@ -597,10 +597,10 @@ impl ProjectDiff { .focus_handle(cx) .contains_focused(window, cx) { - self.focus_handle.focus(window); + self.focus_handle.focus(window, cx); } else if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() { self.editor.update(cx, |editor, cx| { - editor.focus_handle(cx).focus(window); + editor.focus_handle(cx).focus(window, cx); }); } if self.pending_scroll.as_ref() == Some(&path_key) { @@ -983,7 +983,7 @@ impl Render for ProjectDiff { cx, )) .on_click(move |_, window, cx| { - window.focus(&keybinding_focus_handle); + window.focus(&keybinding_focus_handle, cx); window.dispatch_action( Box::new(CloseActiveItem::default()), cx, @@ -1153,7 +1153,7 @@ impl ProjectDiffToolbar { fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context) { if let Some(project_diff) = self.project_diff(cx) { - project_diff.focus_handle(cx).focus(window); + project_diff.focus_handle(cx).focus(window, cx); } let action = action.boxed_clone(); cx.defer(move |cx| { diff --git a/crates/git_ui/src/worktree_picker.rs b/crates/git_ui/src/worktree_picker.rs index f6b3e47dec386d906e55e555600a93059d0766d0..875ae55eefae19e24aa26fe75f80d70f8316c82b 100644 --- a/crates/git_ui/src/worktree_picker.rs +++ b/crates/git_ui/src/worktree_picker.rs @@ -421,6 +421,7 @@ async fn open_remote_worktree( app_state.user_store.clone(), app_state.languages.clone(), app_state.fs.clone(), + true, cx, ) })?; diff --git a/crates/go_to_line/src/go_to_line.rs b/crates/go_to_line/src/go_to_line.rs index 461b0be659fc3ffb7b7bc984485dc68ece988500..7c42972a75420ae87bf3c5b9caaf041852efc009 100644 --- a/crates/go_to_line/src/go_to_line.rs +++ b/crates/go_to_line/src/go_to_line.rs @@ -268,7 +268,7 @@ impl GoToLine { cx, |s| s.select_anchor_ranges([start..start]), ); - editor.focus_handle(cx).focus(window); + editor.focus_handle(cx).focus(window, cx); cx.notify() }); self.prev_scroll_position.take(); diff --git a/crates/gpui/examples/focus_visible.rs b/crates/gpui/examples/focus_visible.rs index 737317cabadb7d3358c9c0497b52d4c2ff2e1028..d7c15396f0381ef29b3d6600347fd90a602256f5 100644 --- a/crates/gpui/examples/focus_visible.rs +++ b/crates/gpui/examples/focus_visible.rs @@ -29,7 +29,7 @@ impl Example { ]; let focus_handle = cx.focus_handle(); - window.focus(&focus_handle); + window.focus(&focus_handle, cx); Self { focus_handle, @@ -40,13 +40,13 @@ impl Example { } } - fn on_tab(&mut self, _: &Tab, window: &mut Window, _: &mut Context) { - window.focus_next(); + fn on_tab(&mut self, _: &Tab, window: &mut Window, cx: &mut Context) { + window.focus_next(cx); self.message = SharedString::from("Pressed Tab - focus-visible border should appear!"); } - fn on_tab_prev(&mut self, _: &TabPrev, window: &mut Window, _: &mut Context) { - window.focus_prev(); + fn on_tab_prev(&mut self, _: &TabPrev, window: &mut Window, cx: &mut Context) { + window.focus_prev(cx); self.message = SharedString::from("Pressed Shift-Tab - focus-visible border should appear!"); } diff --git a/crates/gpui/examples/input.rs b/crates/gpui/examples/input.rs index 37115feaa551a787562e7299c9d44bcc97b5fca3..44fae4ffe6bb9e120a8f96c10e0af8f4f8026cdd 100644 --- a/crates/gpui/examples/input.rs +++ b/crates/gpui/examples/input.rs @@ -736,7 +736,7 @@ fn main() { window .update(cx, |view, window, cx| { - window.focus(&view.text_input.focus_handle(cx)); + window.focus(&view.text_input.focus_handle(cx), cx); cx.activate(true); }) .unwrap(); diff --git a/crates/gpui/examples/on_window_close_quit.rs b/crates/gpui/examples/on_window_close_quit.rs index 8fe24001445d94b1629bf766294d850d0918a5e8..9a2b2f2fee43f753aece55d076be647ad8060965 100644 --- a/crates/gpui/examples/on_window_close_quit.rs +++ b/crates/gpui/examples/on_window_close_quit.rs @@ -55,7 +55,7 @@ fn main() { cx.activate(false); cx.new(|cx| { let focus_handle = cx.focus_handle(); - focus_handle.focus(window); + focus_handle.focus(window, cx); ExampleWindow { focus_handle } }) }, @@ -72,7 +72,7 @@ fn main() { |window, cx| { cx.new(|cx| { let focus_handle = cx.focus_handle(); - focus_handle.focus(window); + focus_handle.focus(window, cx); ExampleWindow { focus_handle } }) }, diff --git a/crates/gpui/examples/tab_stop.rs b/crates/gpui/examples/tab_stop.rs index 8dbcbeccb7351fda18e8d36fe38d8f26c4a70cc9..4d99da1a07a123e9a18b3c64a90834c31bd76909 100644 --- a/crates/gpui/examples/tab_stop.rs +++ b/crates/gpui/examples/tab_stop.rs @@ -22,7 +22,7 @@ impl Example { ]; let focus_handle = cx.focus_handle(); - window.focus(&focus_handle); + window.focus(&focus_handle, cx); Self { focus_handle, @@ -31,13 +31,13 @@ impl Example { } } - fn on_tab(&mut self, _: &Tab, window: &mut Window, _: &mut Context) { - window.focus_next(); + fn on_tab(&mut self, _: &Tab, window: &mut Window, cx: &mut Context) { + window.focus_next(cx); self.message = SharedString::from("You have pressed `Tab`."); } - fn on_tab_prev(&mut self, _: &TabPrev, window: &mut Window, _: &mut Context) { - window.focus_prev(); + fn on_tab_prev(&mut self, _: &TabPrev, window: &mut Window, cx: &mut Context) { + window.focus_prev(cx); self.message = SharedString::from("You have pressed `Shift-Tab`."); } } diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index aa1acae33b8fb55fc5e2f8fa8c0f5b8bb91758f3..7bd0daf56a466666b8cf5ae70f6b7cb5597a0d10 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -1900,8 +1900,11 @@ impl App { pub(crate) fn clear_pending_keystrokes(&mut self) { for window in self.windows() { window - .update(self, |_, window, _| { - window.clear_pending_keystrokes(); + .update(self, |_, window, cx| { + if window.pending_input_keystrokes().is_some() { + window.clear_pending_keystrokes(); + window.pending_input_changed(cx); + } }) .ok(); } diff --git a/crates/gpui/src/app/async_context.rs b/crates/gpui/src/app/async_context.rs index f5dcd30ae943954cbc042e1ce02edad39370a04a..805dfced162cd27f0cc785a8282ae3b802c2873a 100644 --- a/crates/gpui/src/app/async_context.rs +++ b/crates/gpui/src/app/async_context.rs @@ -487,7 +487,7 @@ impl VisualContext for AsyncWindowContext { V: Focusable, { self.app.update_window(self.window, |_, window, cx| { - view.read(cx).focus_handle(cx).focus(window); + view.read(cx).focus_handle(cx).focus(window, cx); }) } } diff --git a/crates/gpui/src/app/context.rs b/crates/gpui/src/app/context.rs index 27ccbecaf83cafe7bf7562c32a164268a74a396b..b780ca426c15c99030f24ee48bde978ad38526e7 100644 --- a/crates/gpui/src/app/context.rs +++ b/crates/gpui/src/app/context.rs @@ -285,7 +285,7 @@ impl<'a, T: 'static> Context<'a, T> { /// Focus the given view in the given window. View type is required to implement Focusable. pub fn focus_view(&mut self, view: &Entity, window: &mut Window) { - window.focus(&view.focus_handle(self)); + window.focus(&view.focus_handle(self), self); } /// Sets a given callback to be run on the next frame. @@ -732,7 +732,7 @@ impl<'a, T: 'static> Context<'a, T> { { let view = self.entity(); window.defer(self, move |window, cx| { - view.read(cx).focus_handle(cx).focus(window) + view.read(cx).focus_handle(cx).focus(window, cx) }) } } diff --git a/crates/gpui/src/app/test_context.rs b/crates/gpui/src/app/test_context.rs index 71be5d8e8e6526379f99dfd9a83de88683c6fac6..394ff3163536035391b2b6d607fc745dd794dd5f 100644 --- a/crates/gpui/src/app/test_context.rs +++ b/crates/gpui/src/app/test_context.rs @@ -1059,7 +1059,7 @@ impl VisualContext for VisualTestContext { fn focus(&mut self, view: &Entity) -> Self::Result<()> { self.window .update(&mut self.cx, |_, window, cx| { - view.read(cx).focus_handle(cx).focus(window) + view.read(cx).focus_handle(cx).focus(window, cx) }) .unwrap() } diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 374fd2c55a8e1cd5280d6ea9378a64c265a5c508..cf55edefaf70c080e171a8e21b350fd3c6d82f75 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -654,7 +654,7 @@ pub trait InteractiveElement: Sized { /// Set whether this element is a tab stop. /// /// When false, the element remains in tab-index order but cannot be reached via keyboard navigation. - /// Useful for container elements: focus the container, then call `window.focus_next()` to focus + /// Useful for container elements: focus the container, then call `window.focus_next(cx)` to focus /// the first tab stop inside it while having the container element itself be unreachable via the keyboard. /// Should only be used with `tab_index`. fn tab_stop(mut self, tab_stop: bool) -> Self { @@ -2096,12 +2096,12 @@ impl Interactivity { // This behavior can be suppressed by using `cx.prevent_default()`. if let Some(focus_handle) = self.tracked_focus_handle.clone() { let hitbox = hitbox.clone(); - window.on_mouse_event(move |_: &MouseDownEvent, phase, window, _| { + window.on_mouse_event(move |_: &MouseDownEvent, phase, window, cx| { if phase == DispatchPhase::Bubble && hitbox.is_hovered(window) && !window.default_prevented() { - window.focus(&focus_handle); + window.focus(&focus_handle, cx); // If there is a parent that is also focusable, prevent it // from transferring focus because we already did so. window.prevent_default(); diff --git a/crates/gpui/src/elements/surface.rs b/crates/gpui/src/elements/surface.rs index b4fced1001b3f9881b66f2f93e81588c750aa64c..ac1c247b47ec81bca06e458827786f549ca2d747 100644 --- a/crates/gpui/src/elements/surface.rs +++ b/crates/gpui/src/elements/surface.rs @@ -29,6 +29,7 @@ pub struct Surface { } /// Create a new surface element. +#[cfg(target_os = "macos")] pub fn surface(source: impl Into) -> Surface { Surface { source: source.into(), diff --git a/crates/gpui/src/elements/text.rs b/crates/gpui/src/elements/text.rs index 914e8a286510a2ffd833db4c4d3ef85c84db073f..1b1bfd778c7bc746c67551eb31cf70f60b1485ea 100644 --- a/crates/gpui/src/elements/text.rs +++ b/crates/gpui/src/elements/text.rs @@ -6,6 +6,7 @@ use crate::{ register_tooltip_mouse_handlers, set_tooltip_on_window, }; use anyhow::Context as _; +use itertools::Itertools; use smallvec::SmallVec; use std::{ borrow::Cow, @@ -597,14 +598,14 @@ impl TextLayout { .unwrap() .lines .iter() - .map(|s| s.text.to_string()) - .collect::>() + .map(|s| &s.text) .join("\n") } /// The text for this layout (with soft-wraps as newlines) pub fn wrapped_text(&self) -> String { - let mut lines = Vec::new(); + let mut accumulator = String::new(); + for wrapped in self.0.borrow().as_ref().unwrap().lines.iter() { let mut seen = 0; for boundary in wrapped.layout.wrap_boundaries.iter() { @@ -612,13 +613,16 @@ impl TextLayout { [boundary.glyph_ix] .index; - lines.push(wrapped.text[seen..index].to_string()); + accumulator.push_str(&wrapped.text[seen..index]); + accumulator.push('\n'); seen = index; } - lines.push(wrapped.text[seen..].to_string()); + accumulator.push_str(&wrapped.text[seen..]); + accumulator.push('\n'); } - - lines.join("\n") + // Remove trailing newline + accumulator.pop(); + accumulator } } diff --git a/crates/gpui/src/elements/uniform_list.rs b/crates/gpui/src/elements/uniform_list.rs index 1e38b0e7ac9abcf891201b7db61b819abe00ef1e..a7486f0c00ac4e11ef807af90f6fb75b74b5d142 100644 --- a/crates/gpui/src/elements/uniform_list.rs +++ b/crates/gpui/src/elements/uniform_list.rs @@ -712,8 +712,8 @@ mod test { #[gpui::test] fn test_scroll_strategy_nearest(cx: &mut TestAppContext) { use crate::{ - Context, FocusHandle, ScrollStrategy, UniformListScrollHandle, Window, actions, div, - prelude::*, px, uniform_list, + Context, FocusHandle, ScrollStrategy, UniformListScrollHandle, Window, div, prelude::*, + px, uniform_list, }; use std::ops::Range; @@ -788,7 +788,7 @@ mod test { let (view, cx) = cx.add_window_view(|window, cx| { let focus_handle = cx.focus_handle(); - window.focus(&focus_handle); + window.focus(&focus_handle, cx); TestView { scroll_handle: UniformListScrollHandle::new(), index: 0, diff --git a/crates/gpui/src/executor.rs b/crates/gpui/src/executor.rs index a219a20e92819f7d510ff9e93bce493f7ca723c9..6c2ecb341ff2fe446efd7823c107fd32a557feb5 100644 --- a/crates/gpui/src/executor.rs +++ b/crates/gpui/src/executor.rs @@ -290,9 +290,19 @@ impl BackgroundExecutor { &self, future: AnyFuture, label: Option, + #[cfg_attr( + target_os = "windows", + expect( + unused_variables, + reason = "Multi priority scheduler is broken on windows" + ) + )] priority: Priority, ) -> Task { let dispatcher = self.dispatcher.clone(); + #[cfg(target_os = "windows")] + let priority = Priority::Medium; // multi-prio scheduler is broken on windows + let (runnable, task) = if let Priority::Realtime(realtime) = priority { let location = core::panic::Location::caller(); let (mut tx, rx) = flume::bounded::>(1); diff --git a/crates/gpui/src/gpui.rs b/crates/gpui/src/gpui.rs index e5c726f58e117b76e2dbb2976089d5788baa848e..76a61e286d3fe6c1acae8e4e628d4c9130f1305f 100644 --- a/crates/gpui/src/gpui.rs +++ b/crates/gpui/src/gpui.rs @@ -31,7 +31,7 @@ mod path_builder; mod platform; pub mod prelude; mod profiler; -#[cfg(any(target_os = "windows", target_os = "linux"))] +#[cfg(target_os = "linux")] mod queue; mod scene; mod shared_string; @@ -91,7 +91,7 @@ pub use keymap::*; pub use path_builder::*; pub use platform::*; pub use profiler::*; -#[cfg(any(target_os = "windows", target_os = "linux"))] +#[cfg(target_os = "linux")] pub(crate) use queue::{PriorityQueueReceiver, PriorityQueueSender}; pub use refineable::*; pub use scene::*; diff --git a/crates/gpui/src/interactive.rs b/crates/gpui/src/interactive.rs index 6852b9596a3f74e1d533fc2a7e9a7b7eeab71cda..a500ac46f0bbf96fc2b9d326a3a61da42c40b7ec 100644 --- a/crates/gpui/src/interactive.rs +++ b/crates/gpui/src/interactive.rs @@ -705,8 +705,8 @@ mod test { }); window - .update(cx, |test_view, window, _cx| { - window.focus(&test_view.focus_handle) + .update(cx, |test_view, window, cx| { + window.focus(&test_view.focus_handle, cx) }) .unwrap(); diff --git a/crates/gpui/src/key_dispatch.rs b/crates/gpui/src/key_dispatch.rs index ae4553408fa8d0dc7ed640319ae0b0a178465b74..85aa550fa96ca76e46f8d75ab84e91a7e9ba43cd 100644 --- a/crates/gpui/src/key_dispatch.rs +++ b/crates/gpui/src/key_dispatch.rs @@ -610,8 +610,8 @@ impl DispatchTree { #[cfg(test)] mod tests { use crate::{ - self as gpui, DispatchResult, Element, ElementId, GlobalElementId, InspectorElementId, - Keystroke, LayoutId, Style, + self as gpui, AppContext, DispatchResult, Element, ElementId, GlobalElementId, + InspectorElementId, Keystroke, LayoutId, Style, }; use core::panic; use smallvec::SmallVec; @@ -619,8 +619,8 @@ mod tests { use crate::{ Action, ActionRegistry, App, Bounds, Context, DispatchTree, FocusHandle, InputHandler, - IntoElement, KeyBinding, KeyContext, Keymap, Pixels, Point, Render, TestAppContext, - UTF16Selection, Window, + IntoElement, KeyBinding, KeyContext, Keymap, Pixels, Point, Render, Subscription, + TestAppContext, UTF16Selection, Window, }; #[derive(PartialEq, Eq)] @@ -723,6 +723,213 @@ mod tests { assert!(!result.pending_has_binding); } + #[crate::test] + fn test_pending_input_observers_notified_on_focus_change(cx: &mut TestAppContext) { + #[derive(Clone)] + struct CustomElement { + focus_handle: FocusHandle, + text: Rc>, + } + + impl CustomElement { + fn new(cx: &mut Context) -> Self { + Self { + focus_handle: cx.focus_handle(), + text: Rc::default(), + } + } + } + + impl Element for CustomElement { + type RequestLayoutState = (); + + type PrepaintState = (); + + fn id(&self) -> Option { + Some("custom".into()) + } + + fn source_location(&self) -> Option<&'static panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + (window.request_layout(Style::default(), [], cx), ()) + } + + fn prepaint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + _: Bounds, + _: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + window.set_focus_handle(&self.focus_handle, cx); + } + + fn paint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + _: Bounds, + _: &mut Self::RequestLayoutState, + _: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + let mut key_context = KeyContext::default(); + key_context.add("Terminal"); + window.set_key_context(key_context); + window.handle_input(&self.focus_handle, self.clone(), cx); + window.on_action(std::any::TypeId::of::(), |_, _, _, _| {}); + } + } + + impl IntoElement for CustomElement { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } + } + + impl InputHandler for CustomElement { + fn selected_text_range( + &mut self, + _: bool, + _: &mut Window, + _: &mut App, + ) -> Option { + None + } + + fn marked_text_range(&mut self, _: &mut Window, _: &mut App) -> Option> { + None + } + + fn text_for_range( + &mut self, + _: Range, + _: &mut Option>, + _: &mut Window, + _: &mut App, + ) -> Option { + None + } + + fn replace_text_in_range( + &mut self, + replacement_range: Option>, + text: &str, + _: &mut Window, + _: &mut App, + ) { + if replacement_range.is_some() { + unimplemented!() + } + self.text.borrow_mut().push_str(text) + } + + fn replace_and_mark_text_in_range( + &mut self, + replacement_range: Option>, + new_text: &str, + _: Option>, + _: &mut Window, + _: &mut App, + ) { + if replacement_range.is_some() { + unimplemented!() + } + self.text.borrow_mut().push_str(new_text) + } + + fn unmark_text(&mut self, _: &mut Window, _: &mut App) {} + + fn bounds_for_range( + &mut self, + _: Range, + _: &mut Window, + _: &mut App, + ) -> Option> { + None + } + + fn character_index_for_point( + &mut self, + _: Point, + _: &mut Window, + _: &mut App, + ) -> Option { + None + } + } + + impl Render for CustomElement { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + self.clone() + } + } + + cx.update(|cx| { + cx.bind_keys([KeyBinding::new("ctrl-b", TestAction, Some("Terminal"))]); + cx.bind_keys([KeyBinding::new("ctrl-b h", TestAction, Some("Terminal"))]); + }); + + let (test, cx) = cx.add_window_view(|_, cx| CustomElement::new(cx)); + let focus_handle = test.update(cx, |test, _| test.focus_handle.clone()); + + let pending_input_changed_count = Rc::new(RefCell::new(0usize)); + let pending_input_changed_count_for_observer = pending_input_changed_count.clone(); + + struct PendingInputObserver { + _subscription: Subscription, + } + + let _observer = cx.update(|window, cx| { + cx.new(|cx| PendingInputObserver { + _subscription: cx.observe_pending_input(window, move |_, _, _| { + *pending_input_changed_count_for_observer.borrow_mut() += 1; + }), + }) + }); + + cx.update(|window, cx| { + window.focus(&focus_handle, cx); + window.activate_window(); + }); + + cx.simulate_keystrokes("ctrl-b"); + + let count_after_pending = Rc::new(RefCell::new(0usize)); + let count_after_pending_for_assertion = count_after_pending.clone(); + + cx.update(|window, cx| { + assert!(window.has_pending_keystrokes()); + *count_after_pending.borrow_mut() = *pending_input_changed_count.borrow(); + assert!(*count_after_pending.borrow() > 0); + + window.focus(&cx.focus_handle(), cx); + + assert!(!window.has_pending_keystrokes()); + }); + + // Focus-triggered pending-input notifications are deferred to the end of the current + // effect cycle, so the observer callback should run after the focus update completes. + cx.update(|_, _| { + let count_after_focus_change = *pending_input_changed_count.borrow(); + assert!(count_after_focus_change > *count_after_pending_for_assertion.borrow()); + }); + } + #[crate::test] fn test_input_handler_pending(cx: &mut TestAppContext) { #[derive(Clone)] @@ -876,8 +1083,9 @@ mod tests { cx.bind_keys([KeyBinding::new("ctrl-b h", TestAction, Some("Terminal"))]); }); let (test, cx) = cx.add_window_view(|_, cx| CustomElement::new(cx)); + let focus_handle = test.update(cx, |test, _| test.focus_handle.clone()); cx.update(|window, cx| { - window.focus(&test.read(cx).focus_handle); + window.focus(&focus_handle, cx); window.activate_window(); }); cx.simulate_keystrokes("ctrl-b ["); diff --git a/crates/gpui/src/platform/linux/x11/client.rs b/crates/gpui/src/platform/linux/x11/client.rs index 60400dada57775a295fdb36c7f1ddd9dd8b83a67..5e9089b09809a7ec1b8b257427b0a670adc0f123 100644 --- a/crates/gpui/src/platform/linux/x11/client.rs +++ b/crates/gpui/src/platform/linux/x11/client.rs @@ -29,7 +29,7 @@ use x11rb::{ protocol::xkb::ConnectionExt as _, protocol::xproto::{ AtomEnum, ChangeWindowAttributesAux, ClientMessageData, ClientMessageEvent, - ConnectionExt as _, EventMask, Visibility, + ConnectionExt as _, EventMask, ModMask, Visibility, }, protocol::{Event, randr, render, xinput, xkb, xproto}, resource_manager::Database, @@ -1018,6 +1018,12 @@ impl X11Client { let modifiers = modifiers_from_state(event.state); state.modifiers = modifiers; state.pre_key_char_down.take(); + + // Macros containing modifiers might result in + // the modifiers missing from the event. + // We therefore update the mask from the global state. + update_xkb_mask_from_event_state(&mut state.xkb, event.state); + let keystroke = { let code = event.detail.into(); let mut keystroke = crate::Keystroke::from_xkb(&state.xkb, modifiers, code); @@ -1083,6 +1089,11 @@ impl X11Client { let modifiers = modifiers_from_state(event.state); state.modifiers = modifiers; + // Macros containing modifiers might result in + // the modifiers missing from the event. + // We therefore update the mask from the global state. + update_xkb_mask_from_event_state(&mut state.xkb, event.state); + let keystroke = { let code = event.detail.into(); let keystroke = crate::Keystroke::from_xkb(&state.xkb, modifiers, code); @@ -2516,3 +2527,19 @@ fn get_dpi_factor((width_px, height_px): (u32, u32), (width_mm, height_mm): (u64 fn valid_scale_factor(scale_factor: f32) -> bool { scale_factor.is_sign_positive() && scale_factor.is_normal() } + +#[inline] +fn update_xkb_mask_from_event_state(xkb: &mut xkbc::State, event_state: xproto::KeyButMask) { + let depressed_mods = event_state.remove((ModMask::LOCK | ModMask::M2).bits()); + let latched_mods = xkb.serialize_mods(xkbc::STATE_MODS_LATCHED); + let locked_mods = xkb.serialize_mods(xkbc::STATE_MODS_LOCKED); + let locked_layout = xkb.serialize_layout(xkbc::STATE_LAYOUT_LOCKED); + xkb.update_mask( + depressed_mods.into(), + latched_mods, + locked_mods, + 0, + 0, + locked_layout, + ); +} diff --git a/crates/gpui/src/platform/mac/metal_atlas.rs b/crates/gpui/src/platform/mac/metal_atlas.rs index 9b43efe361a0816e32e858a44cafec66c42e7f85..8282530c5efdc13ca95a1f04c0f6ef1a23c8366c 100644 --- a/crates/gpui/src/platform/mac/metal_atlas.rs +++ b/crates/gpui/src/platform/mac/metal_atlas.rs @@ -15,9 +15,6 @@ pub(crate) struct MetalAtlas(Mutex); 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, - unified_memory: bool, monochrome_textures: AtlasTextureList, polychrome_textures: AtlasTextureList, tiles_by_key: FxHashMap, @@ -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 { diff --git a/crates/gpui/src/platform/mac/metal_renderer.rs b/crates/gpui/src/platform/mac/metal_renderer.rs index 6d7b82507fb581ec1f124e153e5bb91d3eaf9d25..550041a0ccb4cd39bc7a86317d9540e806af2a28 100644 --- a/crates/gpui/src/platform/mac/metal_renderer.rs +++ b/crates/gpui/src/platform/mac/metal_renderer.rs @@ -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()) } diff --git a/crates/gpui/src/platform/mac/open_type.rs b/crates/gpui/src/platform/mac/open_type.rs index 37a29559fdfbc284ffd1021cc6c2c6ed717ca228..ff501df15f671318548a3959bd6b966f97e051b1 100644 --- a/crates/gpui/src/platform/mac/open_type.rs +++ b/crates/gpui/src/platform/mac/open_type.rs @@ -52,6 +52,11 @@ pub fn apply_features_and_fallbacks( &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks, ); + + for value in &values { + CFRelease(*value as _); + } + let new_descriptor = CTFontDescriptorCreateWithAttributes(attrs); CFRelease(attrs as _); let new_descriptor = CTFontDescriptor::wrap_under_create_rule(new_descriptor); diff --git a/crates/gpui/src/platform/mac/screen_capture.rs b/crates/gpui/src/platform/mac/screen_capture.rs index 2f2c1eae335c8bcb366879661534c46dacfd47b4..4b80a87d32f45540c76790065514f29cc7f93b3f 100644 --- a/crates/gpui/src/platform/mac/screen_capture.rs +++ b/crates/gpui/src/platform/mac/screen_capture.rs @@ -110,13 +110,21 @@ impl ScreenCaptureSource for MacScreenCaptureSource { let _: id = msg_send![configuration, setHeight: meta.resolution.height.0 as i64]; let stream: id = msg_send![stream, initWithFilter:filter configuration:configuration delegate:delegate]; + // Stream contains filter, configuration, and delegate internally so we release them here + // to prevent a memory leak when steam is dropped + let _: () = msg_send![filter, release]; + let _: () = msg_send![configuration, release]; + let _: () = msg_send![delegate, release]; + let (mut tx, rx) = oneshot::channel(); let mut error: id = nil; let _: () = msg_send![stream, addStreamOutput:output type:SCStreamOutputTypeScreen sampleHandlerQueue:0 error:&mut error as *mut id]; if error != nil { let message: id = msg_send![error, localizedDescription]; - tx.send(Err(anyhow!("failed to add stream output {message:?}"))) + let _: () = msg_send![stream, release]; + let _: () = msg_send![output, release]; + tx.send(Err(anyhow!("failed to add stream output {message:?}"))) .ok(); return rx; } @@ -132,8 +140,10 @@ impl ScreenCaptureSource for MacScreenCaptureSource { }; Ok(Box::new(stream) as Box) } else { + let _: () = msg_send![stream, release]; + let _: () = msg_send![output, release]; let message: id = msg_send![error, localizedDescription]; - Err(anyhow!("failed to stop screen capture stream {message:?}")) + Err(anyhow!("failed to start screen capture stream {message:?}")) }; if let Some(tx) = tx.borrow_mut().take() { tx.send(result).ok(); diff --git a/crates/gpui/src/platform/mac/text_system.rs b/crates/gpui/src/platform/mac/text_system.rs index 3faf4e6491e6588bdb1341d5a8845171562fa8a0..8595582f4ad7e078f7cfb0140e249feb0a9740dc 100644 --- a/crates/gpui/src/platform/mac/text_system.rs +++ b/crates/gpui/src/platform/mac/text_system.rs @@ -8,6 +8,7 @@ use anyhow::anyhow; use cocoa::appkit::CGFloat; use collections::HashMap; use core_foundation::{ + array::{CFArray, CFArrayRef}, attributed_string::CFMutableAttributedString, base::{CFRange, TCFType}, number::CFNumber, @@ -21,8 +22,10 @@ use core_graphics::{ }; use core_text::{ font::CTFont, + font_collection::CTFontCollectionRef, font_descriptor::{ - kCTFontSlantTrait, kCTFontSymbolicTrait, kCTFontWeightTrait, kCTFontWidthTrait, + CTFontDescriptor, kCTFontSlantTrait, kCTFontSymbolicTrait, kCTFontWeightTrait, + kCTFontWidthTrait, }, line::CTLine, string_attributes::kCTFontAttributeName, @@ -97,7 +100,26 @@ impl PlatformTextSystem for MacTextSystem { fn all_font_names(&self) -> Vec { let mut names = Vec::new(); let collection = core_text::font_collection::create_for_all_families(); - let Some(descriptors) = collection.get_descriptors() else { + // NOTE: We intentionally avoid using `collection.get_descriptors()` here because + // it has a memory leak bug in core-text v21.0.0. The upstream code uses + // `wrap_under_get_rule` but `CTFontCollectionCreateMatchingFontDescriptors` + // follows the Create Rule (caller owns the result), so it should use + // `wrap_under_create_rule`. We call the function directly with correct memory management. + unsafe extern "C" { + fn CTFontCollectionCreateMatchingFontDescriptors( + collection: CTFontCollectionRef, + ) -> CFArrayRef; + } + let descriptors: Option> = unsafe { + let array_ref = + CTFontCollectionCreateMatchingFontDescriptors(collection.as_concrete_TypeRef()); + if array_ref.is_null() { + None + } else { + Some(CFArray::wrap_under_create_rule(array_ref)) + } + }; + let Some(descriptors) = descriptors else { return names; }; for descriptor in descriptors.into_iter() { diff --git a/crates/gpui/src/platform/mac/window.rs b/crates/gpui/src/platform/mac/window.rs index 19ad1777570da9494148e01161e156748cd9bcfc..14b0113c7cf44fa9574bfcca30b46fb988b5e380 100644 --- a/crates/gpui/src/platform/mac/window.rs +++ b/crates/gpui/src/platform/mac/window.rs @@ -1190,6 +1190,7 @@ impl PlatformWindow for MacWindow { let (done_tx, done_rx) = oneshot::channel(); let done_tx = Cell::new(Some(done_tx)); let block = ConcreteBlock::new(move |answer: NSInteger| { + let _: () = msg_send![alert, release]; if let Some(done_tx) = done_tx.take() { let _ = done_tx.send(answer.try_into().unwrap()); } diff --git a/crates/gpui/src/platform/windows/dispatcher.rs b/crates/gpui/src/platform/windows/dispatcher.rs index 0720d414c9b44dec4a3bab5b50fd7dde47991989..14486ccee9843ef9c0792d62f22fa825f0db43ee 100644 --- a/crates/gpui/src/platform/windows/dispatcher.rs +++ b/crates/gpui/src/platform/windows/dispatcher.rs @@ -4,31 +4,24 @@ use std::{ time::{Duration, Instant}, }; -use anyhow::Context; +use flume::Sender; use util::ResultExt; use windows::{ - System::Threading::{ - ThreadPool, ThreadPoolTimer, TimerElapsedHandler, WorkItemHandler, WorkItemPriority, - }, + System::Threading::{ThreadPool, ThreadPoolTimer, TimerElapsedHandler, WorkItemHandler}, Win32::{ Foundation::{LPARAM, WPARAM}, - System::Threading::{ - GetCurrentThread, HIGH_PRIORITY_CLASS, SetPriorityClass, SetThreadPriority, - THREAD_PRIORITY_HIGHEST, THREAD_PRIORITY_TIME_CRITICAL, - }, UI::WindowsAndMessaging::PostMessageW, }, }; use crate::{ - GLOBAL_THREAD_TIMINGS, HWND, PlatformDispatcher, Priority, PriorityQueueSender, - RealtimePriority, RunnableVariant, SafeHwnd, THREAD_TIMINGS, TaskLabel, TaskTiming, - ThreadTaskTimings, WM_GPUI_TASK_DISPATCHED_ON_MAIN_THREAD, profiler, + GLOBAL_THREAD_TIMINGS, HWND, PlatformDispatcher, RunnableVariant, SafeHwnd, THREAD_TIMINGS, + TaskLabel, TaskTiming, ThreadTaskTimings, WM_GPUI_TASK_DISPATCHED_ON_MAIN_THREAD, }; pub(crate) struct WindowsDispatcher { pub(crate) wake_posted: AtomicBool, - main_sender: PriorityQueueSender, + main_sender: Sender, main_thread_id: ThreadId, pub(crate) platform_window_handle: SafeHwnd, validation_number: usize, @@ -36,7 +29,7 @@ pub(crate) struct WindowsDispatcher { impl WindowsDispatcher { pub(crate) fn new( - main_sender: PriorityQueueSender, + main_sender: Sender, platform_window_handle: HWND, validation_number: usize, ) -> Self { @@ -52,7 +45,7 @@ impl WindowsDispatcher { } } - fn dispatch_on_threadpool(&self, priority: WorkItemPriority, runnable: RunnableVariant) { + fn dispatch_on_threadpool(&self, runnable: RunnableVariant) { let handler = { let mut task_wrapper = Some(runnable); WorkItemHandler::new(move |_| { @@ -60,8 +53,7 @@ impl WindowsDispatcher { Ok(()) }) }; - - ThreadPool::RunWithPriorityAsync(&handler, priority).log_err(); + ThreadPool::RunAsync(&handler).log_err(); } fn dispatch_on_threadpool_after(&self, runnable: RunnableVariant, duration: Duration) { @@ -87,7 +79,7 @@ impl WindowsDispatcher { start, end: None, }; - profiler::add_task_timing(timing); + Self::add_task_timing(timing); runnable.run(); @@ -99,7 +91,7 @@ impl WindowsDispatcher { start, end: None, }; - profiler::add_task_timing(timing); + Self::add_task_timing(timing); runnable.run(); @@ -110,7 +102,23 @@ impl WindowsDispatcher { let end = Instant::now(); timing.end = Some(end); - profiler::add_task_timing(timing); + Self::add_task_timing(timing); + } + + pub(crate) fn add_task_timing(timing: TaskTiming) { + THREAD_TIMINGS.with(|timings| { + let mut timings = timings.lock(); + let timings = &mut timings.timings; + + if let Some(last_timing) = timings.iter_mut().rev().next() { + if last_timing.location == timing.location { + last_timing.end = timing.end; + return; + } + } + + timings.push_back(timing); + }); } } @@ -138,22 +146,20 @@ impl PlatformDispatcher for WindowsDispatcher { current().id() == self.main_thread_id } - fn dispatch(&self, runnable: RunnableVariant, label: Option, priority: Priority) { - let priority = match priority { - Priority::Realtime(_) => unreachable!(), - Priority::High => WorkItemPriority::High, - Priority::Medium => WorkItemPriority::Normal, - Priority::Low => WorkItemPriority::Low, - }; - self.dispatch_on_threadpool(priority, runnable); - + fn dispatch( + &self, + runnable: RunnableVariant, + label: Option, + _priority: gpui::Priority, + ) { + self.dispatch_on_threadpool(runnable); if let Some(label) = label { log::debug!("TaskLabel: {label:?}"); } } - fn dispatch_on_main_thread(&self, runnable: RunnableVariant, priority: Priority) { - match self.main_sender.send(priority, runnable) { + fn dispatch_on_main_thread(&self, runnable: RunnableVariant, _priority: gpui::Priority) { + match self.main_sender.send(runnable) { Ok(_) => { if !self.wake_posted.swap(true, Ordering::AcqRel) { unsafe { @@ -185,27 +191,8 @@ impl PlatformDispatcher for WindowsDispatcher { self.dispatch_on_threadpool_after(runnable, duration); } - fn spawn_realtime(&self, priority: RealtimePriority, f: Box) { - std::thread::spawn(move || { - // SAFETY: always safe to call - let thread_handle = unsafe { GetCurrentThread() }; - - let thread_priority = match priority { - RealtimePriority::Audio => THREAD_PRIORITY_TIME_CRITICAL, - RealtimePriority::Other => THREAD_PRIORITY_HIGHEST, - }; - - // SAFETY: thread_handle is a valid handle to a thread - unsafe { SetPriorityClass(thread_handle, HIGH_PRIORITY_CLASS) } - .context("thread priority class") - .log_err(); - - // SAFETY: thread_handle is a valid handle to a thread - unsafe { SetThreadPriority(thread_handle, thread_priority) } - .context("thread priority") - .log_err(); - - f(); - }); + fn spawn_realtime(&self, _priority: crate::RealtimePriority, _f: Box) { + // disabled on windows for now. + unimplemented!(); } } diff --git a/crates/gpui/src/platform/windows/events.rs b/crates/gpui/src/platform/windows/events.rs index f648f45cf4bf632ae07784de8bdc1503f88d6177..e6fa6006eb95ec45f1634cb72ef63e2f622455a7 100644 --- a/crates/gpui/src/platform/windows/events.rs +++ b/crates/gpui/src/platform/windows/events.rs @@ -243,8 +243,7 @@ impl WindowsWindowInner { fn handle_timer_msg(&self, handle: HWND, wparam: WPARAM) -> Option { if wparam.0 == SIZE_MOVE_LOOP_TIMER_ID { - let mut runnables = self.main_receiver.clone().try_iter(); - while let Some(Ok(runnable)) = runnables.next() { + for runnable in self.main_receiver.drain() { WindowsDispatcher::execute_runnable(runnable); } self.handle_paint_msg(handle) diff --git a/crates/gpui/src/platform/windows/platform.rs b/crates/gpui/src/platform/windows/platform.rs index fa847bca6b404538a9f75b757bf53a2e4e2a1418..af0cb89ecc94da70cc42c8d4c397aeb2a811d6fb 100644 --- a/crates/gpui/src/platform/windows/platform.rs +++ b/crates/gpui/src/platform/windows/platform.rs @@ -51,7 +51,7 @@ struct WindowsPlatformInner { raw_window_handles: std::sync::Weak>>, // The below members will never change throughout the entire lifecycle of the app. validation_number: usize, - main_receiver: PriorityQueueReceiver, + main_receiver: flume::Receiver, dispatcher: Arc, } @@ -98,7 +98,7 @@ impl WindowsPlatform { OleInitialize(None).context("unable to initialize Windows OLE")?; } let directx_devices = DirectXDevices::new().context("Creating DirectX devices")?; - let (main_sender, main_receiver) = PriorityQueueReceiver::new(); + let (main_sender, main_receiver) = flume::unbounded::(); let validation_number = if usize::BITS == 64 { rand::random::() as usize } else { @@ -857,24 +857,22 @@ impl WindowsPlatformInner { } break 'tasks; } - let mut main_receiver = self.main_receiver.clone(); - match main_receiver.try_pop() { - Ok(Some(runnable)) => WindowsDispatcher::execute_runnable(runnable), - _ => break 'timeout_loop, + match self.main_receiver.try_recv() { + Err(_) => break 'timeout_loop, + Ok(runnable) => WindowsDispatcher::execute_runnable(runnable), } } // Someone could enqueue a Runnable here. The flag is still true, so they will not PostMessage. // We need to check for those Runnables after we clear the flag. self.dispatcher.wake_posted.store(false, Ordering::Release); - let mut main_receiver = self.main_receiver.clone(); - match main_receiver.try_pop() { - Ok(Some(runnable)) => { + match self.main_receiver.try_recv() { + Err(_) => break 'tasks, + Ok(runnable) => { self.dispatcher.wake_posted.store(true, Ordering::Release); WindowsDispatcher::execute_runnable(runnable); } - _ => break 'tasks, } } @@ -936,7 +934,7 @@ pub(crate) struct WindowCreationInfo { pub(crate) windows_version: WindowsVersion, pub(crate) drop_target_helper: IDropTargetHelper, pub(crate) validation_number: usize, - pub(crate) main_receiver: PriorityQueueReceiver, + pub(crate) main_receiver: flume::Receiver, pub(crate) platform_window_handle: HWND, pub(crate) disable_direct_composition: bool, pub(crate) directx_devices: DirectXDevices, @@ -949,8 +947,8 @@ struct PlatformWindowCreateContext { inner: Option>>, raw_window_handles: std::sync::Weak>>, validation_number: usize, - main_sender: Option>, - main_receiver: Option>, + main_sender: Option>, + main_receiver: Option>, directx_devices: Option, dispatcher: Option>, } diff --git a/crates/gpui/src/platform/windows/window.rs b/crates/gpui/src/platform/windows/window.rs index 0cfa812b288406c5b4afcea37949eed3918f5c91..7ef92b4150e69424b68e9417dda377aa7f2e9cc0 100644 --- a/crates/gpui/src/platform/windows/window.rs +++ b/crates/gpui/src/platform/windows/window.rs @@ -81,7 +81,7 @@ pub(crate) struct WindowsWindowInner { pub(crate) executor: ForegroundExecutor, pub(crate) windows_version: WindowsVersion, pub(crate) validation_number: usize, - pub(crate) main_receiver: PriorityQueueReceiver, + pub(crate) main_receiver: flume::Receiver, pub(crate) platform_window_handle: HWND, } @@ -362,7 +362,7 @@ struct WindowCreateContext { windows_version: WindowsVersion, drop_target_helper: IDropTargetHelper, validation_number: usize, - main_receiver: PriorityQueueReceiver, + main_receiver: flume::Receiver, platform_window_handle: HWND, appearance: WindowAppearance, disable_direct_composition: bool, diff --git a/crates/gpui/src/test.rs b/crates/gpui/src/test.rs index 5ae72d2be1688893374e16a55445558b5bc33040..2a5711a01a9c8f2874cea4803fc517089cafd0fe 100644 --- a/crates/gpui/src/test.rs +++ b/crates/gpui/src/test.rs @@ -69,7 +69,10 @@ pub fn run_test( std::mem::forget(error); } else { if is_multiple_runs { - eprintln!("failing seed: {}", seed); + eprintln!("failing seed: {seed}"); + eprintln!( + "You can rerun from this seed by setting the environmental variable SEED to {seed}" + ); } if let Some(on_fail_fn) = on_fail_fn { on_fail_fn() diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 36e46f6961ae8a1e8581b3c01987f4641377d677..dd20f71c22e388e0c739083d45941270ac8eac8e 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -345,8 +345,8 @@ impl FocusHandle { } /// Moves the focus to the element associated with this handle. - pub fn focus(&self, window: &mut Window) { - window.focus(self) + pub fn focus(&self, window: &mut Window, cx: &mut App) { + window.focus(self, cx) } /// Obtains whether the element associated with this handle is currently focused. @@ -1436,13 +1436,25 @@ impl Window { } /// Move focus to the element associated with the given [`FocusHandle`]. - pub fn focus(&mut self, handle: &FocusHandle) { + pub fn focus(&mut self, handle: &FocusHandle, cx: &mut App) { if !self.focus_enabled || self.focus == Some(handle.id) { return; } self.focus = Some(handle.id); self.clear_pending_keystrokes(); + + // Avoid re-entrant entity updates by deferring observer notifications to the end of the + // current effect cycle, and only for this window. + let window_handle = self.handle; + cx.defer(move |cx| { + window_handle + .update(cx, |_, window, cx| { + window.pending_input_changed(cx); + }) + .ok(); + }); + self.refresh(); } @@ -1463,24 +1475,24 @@ impl Window { } /// Move focus to next tab stop. - pub fn focus_next(&mut self) { + pub fn focus_next(&mut self, cx: &mut App) { if !self.focus_enabled { return; } if let Some(handle) = self.rendered_frame.tab_stops.next(self.focus.as_ref()) { - self.focus(&handle) + self.focus(&handle, cx) } } /// Move focus to previous tab stop. - pub fn focus_prev(&mut self) { + pub fn focus_prev(&mut self, cx: &mut App) { if !self.focus_enabled { return; } if let Some(handle) = self.rendered_frame.tab_stops.prev(self.focus.as_ref()) { - self.focus(&handle) + self.focus(&handle, cx) } } @@ -1961,7 +1973,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 +1981,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 { self.mouse_position @@ -4012,7 +4032,7 @@ impl Window { self.dispatch_keystroke_observers(event, None, context_stack, cx); } - fn pending_input_changed(&mut self, cx: &mut App) { + pub(crate) fn pending_input_changed(&mut self, cx: &mut App) { self.pending_input_observers .clone() .retain(&(), |callback| callback(self, cx)); diff --git a/crates/gpui/src/window/prompts.rs b/crates/gpui/src/window/prompts.rs index 63ad1668bec298a6b59d218bf7d4ca7cdce11e8c..980c6f6812405a8fbf4f8c6e24388ab4f967a94c 100644 --- a/crates/gpui/src/window/prompts.rs +++ b/crates/gpui/src/window/prompts.rs @@ -44,10 +44,10 @@ impl PromptHandle { if let Some(sender) = sender.take() { sender.send(e.0).ok(); window_handle - .update(cx, |_, window, _cx| { + .update(cx, |_, window, cx| { window.prompt.take(); if let Some(previous_focus) = &previous_focus { - window.focus(previous_focus); + window.focus(previous_focus, cx); } }) .ok(); @@ -55,7 +55,7 @@ impl PromptHandle { }) .detach(); - window.focus(&view.focus_handle(cx)); + window.focus(&view.focus_handle(cx), cx); RenderablePromptHandle { view: Box::new(view), diff --git a/crates/gpui_macros/src/derive_visual_context.rs b/crates/gpui_macros/src/derive_visual_context.rs index f2681bb29b92f31d31599ebb7201a42a482283d8..b827e753d9678efba01d3fdd77f8e66ea62b6bbd 100644 --- a/crates/gpui_macros/src/derive_visual_context.rs +++ b/crates/gpui_macros/src/derive_visual_context.rs @@ -62,7 +62,7 @@ pub fn derive_visual_context(input: TokenStream) -> TokenStream { V: gpui::Focusable, { let focus_handle = gpui::Focusable::focus_handle(entity, self.#app_variable); - self.#window_variable.focus(&focus_handle) + self.#window_variable.focus(&focus_handle, self.#app_variable) } } }; diff --git a/crates/inspector_ui/src/inspector.rs b/crates/inspector_ui/src/inspector.rs index 7f7985df9b98ee286c79e18a665802b1f73fbc1e..a82d27b6d015bef97b50983e05f3e2096a1ef8c7 100644 --- a/crates/inspector_ui/src/inspector.rs +++ b/crates/inspector_ui/src/inspector.rs @@ -33,6 +33,7 @@ pub fn init(app_state: Arc, cx: &mut App) { app_state.languages.clone(), app_state.fs.clone(), None, + false, cx, ); diff --git a/crates/keymap_editor/src/keymap_editor.rs b/crates/keymap_editor/src/keymap_editor.rs index 9e243d32151e3caeec2b8c51c7889d2ebe93f29b..be20feaf5f8c1feea5b08fa3a6a3b542b26ef5ce 100644 --- a/crates/keymap_editor/src/keymap_editor.rs +++ b/crates/keymap_editor/src/keymap_editor.rs @@ -911,7 +911,7 @@ impl KeymapEditor { .focus_handle(cx) .contains_focused(window, cx) { - window.focus(&self.filter_editor.focus_handle(cx)); + window.focus(&self.filter_editor.focus_handle(cx), cx); } else { self.filter_editor.update(cx, |editor, cx| { editor.select_all(&Default::default(), window, cx); @@ -948,7 +948,7 @@ impl KeymapEditor { if let Some(scroll_strategy) = scroll { self.scroll_to_item(index, scroll_strategy, cx); } - window.focus(&self.focus_handle); + window.focus(&self.focus_handle, cx); cx.notify(); } } @@ -998,7 +998,7 @@ impl KeymapEditor { }); let context_menu_handle = context_menu.focus_handle(cx); - window.defer(cx, move |window, _cx| window.focus(&context_menu_handle)); + window.defer(cx, move |window, cx| window.focus(&context_menu_handle, cx)); let subscription = cx.subscribe_in( &context_menu, window, @@ -1014,7 +1014,7 @@ impl KeymapEditor { fn dismiss_context_menu(&mut self, window: &mut Window, cx: &mut Context) { self.context_menu.take(); - window.focus(&self.focus_handle); + window.focus(&self.focus_handle, cx); cx.notify(); } @@ -1230,7 +1230,7 @@ impl KeymapEditor { window, cx, ); - window.focus(&modal.focus_handle(cx)); + window.focus(&modal.focus_handle(cx), cx); modal }); }) @@ -1338,7 +1338,7 @@ impl KeymapEditor { editor.stop_recording(&StopRecording, window, cx); editor.clear_keystrokes(&ClearKeystrokes, window, cx); }); - window.focus(&self.filter_editor.focus_handle(cx)); + window.focus(&self.filter_editor.focus_handle(cx), cx); } } } @@ -2698,32 +2698,32 @@ impl KeybindingEditorModalFocusState { .map(|i| i as i32) } - fn focus_index(&self, mut index: i32, window: &mut Window) { + fn focus_index(&self, mut index: i32, window: &mut Window, cx: &mut App) { if index < 0 { index = self.handles.len() as i32 - 1; } if index >= self.handles.len() as i32 { index = 0; } - window.focus(&self.handles[index as usize]); + window.focus(&self.handles[index as usize], cx); } - fn focus_next(&self, window: &mut Window, cx: &App) { + fn focus_next(&self, window: &mut Window, cx: &mut App) { let index_to_focus = if let Some(index) = self.focused_index(window, cx) { index + 1 } else { 0 }; - self.focus_index(index_to_focus, window); + self.focus_index(index_to_focus, window, cx); } - fn focus_previous(&self, window: &mut Window, cx: &App) { + fn focus_previous(&self, window: &mut Window, cx: &mut App) { let index_to_focus = if let Some(index) = self.focused_index(window, cx) { index - 1 } else { self.handles.len() as i32 - 1 }; - self.focus_index(index_to_focus, window); + self.focus_index(index_to_focus, window, cx); } } @@ -2757,7 +2757,7 @@ impl ActionArgumentsEditor { ) -> Self { let focus_handle = cx.focus_handle(); cx.on_focus_in(&focus_handle, window, |this, window, cx| { - this.editor.focus_handle(cx).focus(window); + this.editor.focus_handle(cx).focus(window, cx); }) .detach(); let editor = cx.new(|cx| { @@ -2810,7 +2810,7 @@ impl ActionArgumentsEditor { this.update_in(cx, |this, window, cx| { if this.editor.focus_handle(cx).is_focused(window) { - editor.focus_handle(cx).focus(window); + editor.focus_handle(cx).focus(window, cx); } this.editor = editor; this.backup_temp_dir = backup_temp_dir; diff --git a/crates/keymap_editor/src/ui_components/keystroke_input.rs b/crates/keymap_editor/src/ui_components/keystroke_input.rs index 6936de784f9d5c16b218d0952c41d6336299a0f9..496a8ae7e6359bc169845542a0f05800008a4786 100644 --- a/crates/keymap_editor/src/ui_components/keystroke_input.rs +++ b/crates/keymap_editor/src/ui_components/keystroke_input.rs @@ -388,7 +388,7 @@ impl KeystrokeInput { window: &mut Window, cx: &mut Context, ) { - window.focus(&self.inner_focus_handle); + window.focus(&self.inner_focus_handle, cx); self.clear_keystrokes(&ClearKeystrokes, window, cx); self.previous_modifiers = window.modifiers(); #[cfg(test)] @@ -407,7 +407,7 @@ impl KeystrokeInput { if !self.is_recording(window) { return; } - window.focus(&self.outer_focus_handle); + window.focus(&self.outer_focus_handle, cx); if let Some(close_keystrokes_start) = self.close_keystrokes_start.take() && close_keystrokes_start < self.keystrokes.len() { diff --git a/crates/language/Cargo.toml b/crates/language/Cargo.toml index 49ea681290c3edc878391a337c5423fa795dba4f..3ba93476d2a9fa5371b9d146cfc0c5833a748842 100644 --- a/crates/language/Cargo.toml +++ b/crates/language/Cargo.toml @@ -48,6 +48,7 @@ rand = { workspace = true, optional = true } regex.workspace = true rpc.workspace = true schemars.workspace = true +semver.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 59795c375ab9b663339dbbebccc60062058c6ef9..39003773f83718c6c61d4cfda55b9528f7c6eb2a 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -8,8 +8,8 @@ use crate::{ outline::OutlineItem, row_chunk::RowChunks, syntax_map::{ - SyntaxLayer, SyntaxMap, SyntaxMapCapture, SyntaxMapCaptures, SyntaxMapMatch, - SyntaxMapMatches, SyntaxSnapshot, ToTreeSitterPoint, + MAX_BYTES_TO_QUERY, SyntaxLayer, SyntaxMap, SyntaxMapCapture, SyntaxMapCaptures, + SyntaxMapMatch, SyntaxMapMatches, SyntaxSnapshot, ToTreeSitterPoint, }, task_context::RunnableRange, text_diff::text_diff, @@ -3216,15 +3216,22 @@ impl BufferSnapshot { struct StartPosition { start: Point, suffix: SharedString, + language: Arc, } // Find the suggested indentation ranges based on the syntax tree. let start = Point::new(prev_non_blank_row.unwrap_or(row_range.start), 0); let end = Point::new(row_range.end, 0); let range = (start..end).to_offset(&self.text); - let mut matches = self.syntax.matches(range.clone(), &self.text, |grammar| { - Some(&grammar.indents_config.as_ref()?.query) - }); + let mut matches = self.syntax.matches_with_options( + range.clone(), + &self.text, + TreeSitterOptions { + max_bytes_to_query: Some(MAX_BYTES_TO_QUERY), + max_start_depth: None, + }, + |grammar| Some(&grammar.indents_config.as_ref()?.query), + ); let indent_configs = matches .grammars() .iter() @@ -3253,6 +3260,7 @@ impl BufferSnapshot { start_positions.push(StartPosition { start: Point::from_ts_point(capture.node.start_position()), suffix: suffix.clone(), + language: mat.language.clone(), }); } } @@ -3303,8 +3311,7 @@ impl BufferSnapshot { // set its end to the outdent position if let Some(range_to_truncate) = indent_ranges .iter_mut() - .filter(|indent_range| indent_range.contains(&outdent_position)) - .next_back() + .rfind(|indent_range| indent_range.contains(&outdent_position)) { range_to_truncate.end = outdent_position; } @@ -3314,7 +3321,7 @@ impl BufferSnapshot { // Find the suggested indentation increases and decreased based on regexes. let mut regex_outdent_map = HashMap::default(); - let mut last_seen_suffix: HashMap> = HashMap::default(); + let mut last_seen_suffix: HashMap> = HashMap::default(); let mut start_positions_iter = start_positions.iter().peekable(); let mut indent_change_rows = Vec::<(u32, Ordering)>::new(); @@ -3322,14 +3329,21 @@ impl BufferSnapshot { Point::new(prev_non_blank_row.unwrap_or(row_range.start), 0) ..Point::new(row_range.end, 0), |row, line| { - if config + let indent_len = self.indent_size_for_line(row).len; + let row_language = self.language_at(Point::new(row, indent_len)).cloned(); + let row_language_config = row_language + .as_ref() + .map(|lang| lang.config()) + .unwrap_or(config); + + if row_language_config .decrease_indent_pattern .as_ref() .is_some_and(|regex| regex.is_match(line)) { indent_change_rows.push((row, Ordering::Less)); } - if config + if row_language_config .increase_indent_pattern .as_ref() .is_some_and(|regex| regex.is_match(line)) @@ -3338,16 +3352,16 @@ impl BufferSnapshot { } while let Some(pos) = start_positions_iter.peek() { if pos.start.row < row { - let pos = start_positions_iter.next().unwrap(); + let pos = start_positions_iter.next().unwrap().clone(); last_seen_suffix .entry(pos.suffix.to_string()) .or_default() - .push(pos.start); + .push(pos); } else { break; } } - for rule in &config.decrease_indent_patterns { + for rule in &row_language_config.decrease_indent_patterns { if rule.pattern.as_ref().is_some_and(|r| r.is_match(line)) { let row_start_column = self.indent_size_for_line(row).len; let basis_row = rule @@ -3355,10 +3369,16 @@ impl BufferSnapshot { .iter() .filter_map(|valid_suffix| last_seen_suffix.get(valid_suffix)) .flatten() - .filter(|start_point| start_point.column <= row_start_column) - .max_by_key(|start_point| start_point.row); - if let Some(outdent_to_row) = basis_row { - regex_outdent_map.insert(row, outdent_to_row.row); + .filter(|pos| { + row_language + .as_ref() + .or(self.language.as_ref()) + .is_some_and(|lang| Arc::ptr_eq(lang, &pos.language)) + }) + .filter(|pos| pos.start.column <= row_start_column) + .max_by_key(|pos| pos.start.row); + if let Some(outdent_to) = basis_row { + regex_outdent_map.insert(row, outdent_to.start.row); } break; } @@ -4336,11 +4356,15 @@ impl BufferSnapshot { let mut opens = Vec::new(); let mut color_pairs = Vec::new(); - let mut matches = self - .syntax - .matches(chunk_range.clone(), &self.text, |grammar| { - grammar.brackets_config.as_ref().map(|c| &c.query) - }); + let mut matches = self.syntax.matches_with_options( + chunk_range.clone(), + &self.text, + TreeSitterOptions { + max_bytes_to_query: Some(MAX_BYTES_TO_QUERY), + max_start_depth: None, + }, + |grammar| grammar.brackets_config.as_ref().map(|c| &c.query), + ); let configs = matches .grammars() .iter() diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index a17c93f11a8705bf477d2eceb4f7bec9315cf6d1..a573e3d78a4de03c6ccf382c80bc33eaf0b5690d 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -43,6 +43,7 @@ pub use manifest::{ManifestDelegate, ManifestName, ManifestProvider, ManifestQue use parking_lot::Mutex; use regex::Regex; use schemars::{JsonSchema, SchemaGenerator, json_schema}; +use semver::Version; use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; use serde_json::Value; use settings::WorktreeId; @@ -329,6 +330,10 @@ impl CachedLspAdapter { .cloned() .unwrap_or_else(|| language_name.lsp_id()) } + + pub fn process_prompt_response(&self, context: &PromptResponseContext, cx: &mut AsyncApp) { + self.adapter.process_prompt_response(context, cx) + } } /// [`LspAdapterDelegate`] allows [`LspAdapter]` implementations to interface with the application @@ -347,13 +352,24 @@ pub trait LspAdapterDelegate: Send + Sync { async fn npm_package_installed_version( &self, package_name: &str, - ) -> Result>; + ) -> Result>; async fn which(&self, command: &OsStr) -> Option; async fn shell_env(&self) -> HashMap; async fn read_text_file(&self, path: &RelPath) -> Result; async fn try_exec(&self, binary: LanguageServerBinary) -> Result<()>; } +/// Context provided to LSP adapters when a user responds to a ShowMessageRequest prompt. +/// This allows adapters to intercept preference selections (like "Always" or "Never") +/// and potentially persist them to Zed's settings. +#[derive(Debug, Clone)] +pub struct PromptResponseContext { + /// The original message shown to the user + pub message: String, + /// The action (button) the user selected + pub selected_action: lsp::MessageActionItem, +} + #[async_trait(?Send)] pub trait LspAdapter: 'static + Send + Sync + DynLspInstaller { fn name(&self) -> LanguageServerName; @@ -510,6 +526,11 @@ pub trait LspAdapter: 'static + Send + Sync + DynLspInstaller { fn is_extension(&self) -> bool { false } + + /// Called when a user responds to a ShowMessageRequest from this language server. + /// This allows adapters to intercept preference selections (like "Always" or "Never") + /// for settings that should be persisted to Zed's settings file. + fn process_prompt_response(&self, _context: &PromptResponseContext, _cx: &mut AsyncApp) {} } pub trait LspInstaller { @@ -2425,7 +2446,10 @@ impl CodeLabel { "invalid filter range" ); runs.iter().for_each(|(range, _)| { - assert!(text.get(range.clone()).is_some(), "invalid run range"); + assert!( + text.get(range.clone()).is_some(), + "invalid run range with inputs. Requested range {range:?} in text '{text}'", + ); }); Self { runs, diff --git a/crates/language/src/syntax_map.rs b/crates/language/src/syntax_map.rs index 17285ca315fb64dd518d00039d28266c0a7f51ab..77e90c4ca89d0b6e5b8cb0a604175ec9a97e719e 100644 --- a/crates/language/src/syntax_map.rs +++ b/crates/language/src/syntax_map.rs @@ -21,6 +21,8 @@ use sum_tree::{Bias, Dimensions, SeekTarget, SumTree}; use text::{Anchor, BufferSnapshot, OffsetRangeExt, Point, Rope, ToOffset, ToPoint}; use tree_sitter::{Node, Query, QueryCapture, QueryCaptures, QueryCursor, QueryMatches, Tree}; +pub const MAX_BYTES_TO_QUERY: usize = 16 * 1024; + pub struct SyntaxMap { snapshot: SyntaxSnapshot, language_registry: Option>, @@ -1096,12 +1098,15 @@ impl<'a> SyntaxMapCaptures<'a> { #[derive(Default)] pub struct TreeSitterOptions { - max_start_depth: Option, + pub max_start_depth: Option, + pub max_bytes_to_query: Option, } + impl TreeSitterOptions { pub fn max_start_depth(max_start_depth: u32) -> Self { Self { max_start_depth: Some(max_start_depth), + max_bytes_to_query: None, } } } @@ -1135,6 +1140,14 @@ impl<'a> SyntaxMapMatches<'a> { }; cursor.set_max_start_depth(options.max_start_depth); + if let Some(max_bytes_to_query) = options.max_bytes_to_query { + let midpoint = (range.start + range.end) / 2; + let containing_range_start = midpoint.saturating_sub(max_bytes_to_query / 2); + let containing_range_end = + containing_range_start.saturating_add(max_bytes_to_query); + cursor.set_containing_byte_range(containing_range_start..containing_range_end); + } + cursor.set_byte_range(range.clone()); let matches = cursor.matches(query, layer.node(), TextProvider(text)); let grammar_index = result @@ -1642,6 +1655,10 @@ impl<'a> SyntaxLayer<'a> { let mut query_cursor = QueryCursorHandle::new(); query_cursor.set_byte_range(offset.saturating_sub(1)..offset.saturating_add(1)); + query_cursor.set_containing_byte_range( + offset.saturating_sub(MAX_BYTES_TO_QUERY / 2) + ..offset.saturating_add(MAX_BYTES_TO_QUERY / 2), + ); let mut smallest_match: Option<(u32, Range)> = None; let mut matches = query_cursor.matches(&config.query, self.node(), text); @@ -1928,6 +1945,8 @@ impl Drop for QueryCursorHandle { let mut cursor = self.0.take().unwrap(); cursor.set_byte_range(0..usize::MAX); cursor.set_point_range(Point::zero().to_ts_point()..Point::MAX.to_ts_point()); + cursor.set_containing_byte_range(0..usize::MAX); + cursor.set_containing_point_range(Point::zero().to_ts_point()..Point::MAX.to_ts_point()); QUERY_CURSORS.lock().push(cursor) } } diff --git a/crates/language/src/syntax_map/syntax_map_tests.rs b/crates/language/src/syntax_map/syntax_map_tests.rs index 1eb63772760719a381d16795ecde0c4a3293c789..2a9f7f172388f99543ac979938a3e8fec9db541a 100644 --- a/crates/language/src/syntax_map/syntax_map_tests.rs +++ b/crates/language/src/syntax_map/syntax_map_tests.rs @@ -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, diff --git a/crates/language/src/text_diff.rs b/crates/language/src/text_diff.rs index 1fb94b9f5e87015f317e3e88a963c06c7ea41b70..bc07ec73f0ad2c4738a2ca5f6ff955b53327acc3 100644 --- a/crates/language/src/text_diff.rs +++ b/crates/language/src/text_diff.rs @@ -48,7 +48,6 @@ pub fn text_diff(old_text: &str, new_text: &str) -> Vec<(Range, Arc) /// /// Returns a tuple of (old_ranges, new_ranges) where each vector contains /// the byte ranges of changed words in the respective text. -/// Whitespace-only changes are excluded from the results. pub fn word_diff_ranges( old_text: &str, new_text: &str, @@ -62,23 +61,23 @@ pub fn word_diff_ranges( let mut new_ranges: Vec> = Vec::new(); diff_internal(&input, |old_byte_range, new_byte_range, _, _| { - for range in split_on_whitespace(old_text, &old_byte_range) { + if !old_byte_range.is_empty() { if let Some(last) = old_ranges.last_mut() - && last.end >= range.start + && last.end >= old_byte_range.start { - last.end = range.end; + last.end = old_byte_range.end; } else { - old_ranges.push(range); + old_ranges.push(old_byte_range); } } - for range in split_on_whitespace(new_text, &new_byte_range) { + if !new_byte_range.is_empty() { if let Some(last) = new_ranges.last_mut() - && last.end >= range.start + && last.end >= new_byte_range.start { - last.end = range.end; + last.end = new_byte_range.end; } else { - new_ranges.push(range); + new_ranges.push(new_byte_range); } } }); @@ -86,50 +85,6 @@ pub fn word_diff_ranges( (old_ranges, new_ranges) } -fn split_on_whitespace(text: &str, range: &Range) -> Vec> { - if range.is_empty() { - return Vec::new(); - } - - let slice = &text[range.clone()]; - let mut ranges = Vec::new(); - let mut offset = 0; - - for line in slice.lines() { - let line_start = offset; - let line_end = line_start + line.len(); - offset = line_end + 1; - let trimmed = line.trim(); - - if !trimmed.is_empty() { - let leading = line.len() - line.trim_start().len(); - let trailing = line.len() - line.trim_end().len(); - let trimmed_start = range.start + line_start + leading; - let trimmed_end = range.start + line_end - trailing; - - let original_line_start = text[..range.start + line_start] - .rfind('\n') - .map(|i| i + 1) - .unwrap_or(0); - let original_line_end = text[range.start + line_start..] - .find('\n') - .map(|i| range.start + line_start + i) - .unwrap_or(text.len()); - let original_line = &text[original_line_start..original_line_end]; - let original_trimmed_start = - original_line_start + (original_line.len() - original_line.trim_start().len()); - let original_trimmed_end = - original_line_end - (original_line.len() - original_line.trim_end().len()); - - if trimmed_start > original_trimmed_start || trimmed_end < original_trimmed_end { - ranges.push(trimmed_start..trimmed_end); - } - } - } - - ranges -} - pub struct DiffOptions { pub language_scope: Option, pub max_word_diff_len: usize, diff --git a/crates/language_models/src/provider/anthropic.rs b/crates/language_models/src/provider/anthropic.rs index 6be88460d26da1bfdebe5e01dbde2a55be875b1f..2fad738e4554d37cd2ad565507a0b73d74634b72 100644 --- a/crates/language_models/src/provider/anthropic.rs +++ b/crates/language_models/src/provider/anthropic.rs @@ -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> { - cx.background_spawn(async move { - let messages = request.messages; - let mut tokens_from_images = 0; - let mut string_messages = Vec::with_capacity(messages.len()); - - for message in messages { - use language_model::MessageContent; - - 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); + model: String, + mode: AnthropicModelMode, +) -> CountTokensRequest { + let mut new_messages: Vec = Vec::new(); + let mut system_message = String::new(); + + for message in request.messages { + if message.contents_empty() { + continue; + } + + match message.role { + Role::User | Role::Assistant => { + let anthropic_message_content: Vec = 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 + } } - LanguageModelToolResultContent::Image(image) => { - tokens_from_images += image.estimate_tokens(); + 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; } - } - 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, + 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 { + 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); + } + 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 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() + // 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> { - 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( diff --git a/crates/language_models/src/provider/bedrock.rs b/crates/language_models/src/provider/bedrock.rs index bc2ad2f63f71c54beabc32de46833ac83807084b..525c00be847ffcfee133f9f1c8603b8e0a302143 100644 --- a/crates/language_models/src/provider/bedrock.rs +++ b/crates/language_models/src/provider/bedrock.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use anyhow::{Context as _, Result, anyhow}; use aws_config::stalled_stream_protection::StalledStreamProtectionConfig; use aws_config::{BehaviorVersion, Region}; -use aws_credential_types::Credentials; +use aws_credential_types::{Credentials, Token}; use aws_http_client::AwsHttpClient; use bedrock::bedrock_client::Client as BedrockClient; use bedrock::bedrock_client::config::timeout::TimeoutConfig; @@ -30,18 +30,19 @@ use gpui::{ use gpui_tokio::Tokio; use http_client::HttpClient; use language_model::{ - AuthenticateError, LanguageModel, LanguageModelCacheConfiguration, + AuthenticateError, EnvVar, LanguageModel, LanguageModelCacheConfiguration, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, RateLimiter, Role, - TokenUsage, + TokenUsage, env_var, }; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::Value; use settings::{BedrockAvailableModel as AvailableModel, Settings, SettingsStore}; use smol::lock::OnceCell; +use std::sync::LazyLock; use strum::{EnumIter, IntoEnumIterator, IntoStaticStr}; use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; use ui_input::InputField; @@ -54,12 +55,52 @@ actions!(bedrock, [Tab, TabPrev]); const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("amazon-bedrock"); const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("Amazon Bedrock"); +/// Credentials stored in the keychain for static authentication. +/// Region is handled separately since it's orthogonal to auth method. #[derive(Default, Clone, Deserialize, Serialize, PartialEq, Debug)] pub struct BedrockCredentials { pub access_key_id: String, pub secret_access_key: String, pub session_token: Option, - pub region: String, + pub bearer_token: Option, +} + +/// Resolved authentication configuration for Bedrock. +/// Settings take priority over UX-provided credentials. +#[derive(Clone, Debug, PartialEq)] +pub enum BedrockAuth { + /// Use default AWS credential provider chain (IMDSv2, PodIdentity, env vars, etc.) + Automatic, + /// Use AWS named profile from ~/.aws/credentials or ~/.aws/config + NamedProfile { profile_name: String }, + /// Use AWS SSO profile + SingleSignOn { profile_name: String }, + /// Use IAM credentials (access key + secret + optional session token) + IamCredentials { + access_key_id: String, + secret_access_key: String, + session_token: Option, + }, + /// Use Bedrock API Key (bearer token authentication) + ApiKey { api_key: String }, +} + +impl BedrockCredentials { + /// Convert stored credentials to the appropriate auth variant. + /// Prefers API key if present, otherwise uses IAM credentials. + fn into_auth(self) -> Option { + if let Some(api_key) = self.bearer_token.filter(|t| !t.is_empty()) { + Some(BedrockAuth::ApiKey { api_key }) + } else if !self.access_key_id.is_empty() && !self.secret_access_key.is_empty() { + Some(BedrockAuth::IamCredentials { + access_key_id: self.access_key_id, + secret_access_key: self.secret_access_key, + session_token: self.session_token.filter(|t| !t.is_empty()), + }) + } else { + None + } + } } #[derive(Default, Clone, Debug, PartialEq)] @@ -79,6 +120,8 @@ pub enum BedrockAuthMethod { NamedProfile, #[serde(rename = "sso")] SingleSignOn, + #[serde(rename = "api_key")] + ApiKey, /// IMDSv2, PodIdentity, env vars, etc. #[serde(rename = "default")] Automatic, @@ -90,6 +133,7 @@ impl From for BedrockAuthMethod { settings::BedrockAuthMethodContent::SingleSignOn => BedrockAuthMethod::SingleSignOn, settings::BedrockAuthMethodContent::Automatic => BedrockAuthMethod::Automatic, settings::BedrockAuthMethodContent::NamedProfile => BedrockAuthMethod::NamedProfile, + settings::BedrockAuthMethodContent::ApiKey => BedrockAuthMethod::ApiKey, } } } @@ -130,23 +174,26 @@ impl From for ModelMode { const AMAZON_AWS_URL: &str = "https://amazonaws.com"; // These environment variables all use a `ZED_` prefix because we don't want to overwrite the user's AWS credentials. -const ZED_BEDROCK_ACCESS_KEY_ID_VAR: &str = "ZED_ACCESS_KEY_ID"; -const ZED_BEDROCK_SECRET_ACCESS_KEY_VAR: &str = "ZED_SECRET_ACCESS_KEY"; -const ZED_BEDROCK_SESSION_TOKEN_VAR: &str = "ZED_SESSION_TOKEN"; -const ZED_AWS_PROFILE_VAR: &str = "ZED_AWS_PROFILE"; -const ZED_BEDROCK_REGION_VAR: &str = "ZED_AWS_REGION"; -const ZED_AWS_CREDENTIALS_VAR: &str = "ZED_AWS_CREDENTIALS"; -const ZED_AWS_ENDPOINT_VAR: &str = "ZED_AWS_ENDPOINT"; +static ZED_BEDROCK_ACCESS_KEY_ID_VAR: LazyLock = env_var!("ZED_ACCESS_KEY_ID"); +static ZED_BEDROCK_SECRET_ACCESS_KEY_VAR: LazyLock = env_var!("ZED_SECRET_ACCESS_KEY"); +static ZED_BEDROCK_SESSION_TOKEN_VAR: LazyLock = env_var!("ZED_SESSION_TOKEN"); +static ZED_AWS_PROFILE_VAR: LazyLock = env_var!("ZED_AWS_PROFILE"); +static ZED_BEDROCK_REGION_VAR: LazyLock = env_var!("ZED_AWS_REGION"); +static ZED_AWS_ENDPOINT_VAR: LazyLock = env_var!("ZED_AWS_ENDPOINT"); +static ZED_BEDROCK_BEARER_TOKEN_VAR: LazyLock = env_var!("ZED_BEDROCK_BEARER_TOKEN"); pub struct State { - credentials: Option, + /// The resolved authentication method. Settings take priority over UX credentials. + auth: Option, + /// Raw settings from settings.json settings: Option, + /// Whether credentials came from environment variables (only relevant for static credentials) credentials_from_env: bool, _subscription: Subscription, } impl State { - fn reset_credentials(&self, cx: &mut Context) -> Task> { + fn reset_auth(&self, cx: &mut Context) -> Task> { let credentials_provider = ::global(cx); cx.spawn(async move |this, cx| { credentials_provider @@ -154,19 +201,19 @@ impl State { .await .log_err(); this.update(cx, |this, cx| { - this.credentials = None; + this.auth = None; this.credentials_from_env = false; - this.settings = None; cx.notify(); }) }) } - fn set_credentials( + fn set_static_credentials( &mut self, credentials: BedrockCredentials, cx: &mut Context, ) -> Task> { + let auth = credentials.clone().into_auth(); let credentials_provider = ::global(cx); cx.spawn(async move |this, cx| { credentials_provider @@ -178,50 +225,131 @@ impl State { ) .await?; this.update(cx, |this, cx| { - this.credentials = Some(credentials); + this.auth = auth; + this.credentials_from_env = false; cx.notify(); }) }) } fn is_authenticated(&self) -> bool { - let derived = self - .settings - .as_ref() - .and_then(|s| s.authentication_method.as_ref()); - let creds = self.credentials.as_ref(); - - derived.is_some() || creds.is_some() + self.auth.is_some() } + /// Resolve authentication. Settings take priority over UX-provided credentials. fn authenticate(&self, cx: &mut Context) -> Task> { if self.is_authenticated() { return Task::ready(Ok(())); } + // Step 1: Check if settings specify an auth method (enterprise control) + if let Some(settings) = &self.settings { + if let Some(method) = &settings.authentication_method { + let profile_name = settings + .profile_name + .clone() + .unwrap_or_else(|| "default".to_string()); + + let auth = match method { + BedrockAuthMethod::Automatic => BedrockAuth::Automatic, + BedrockAuthMethod::NamedProfile => BedrockAuth::NamedProfile { profile_name }, + BedrockAuthMethod::SingleSignOn => BedrockAuth::SingleSignOn { profile_name }, + BedrockAuthMethod::ApiKey => { + // ApiKey method means "use static credentials from keychain/env" + // Fall through to load them below + return self.load_static_credentials(cx); + } + }; + + return cx.spawn(async move |this, cx| { + this.update(cx, |this, cx| { + this.auth = Some(auth); + this.credentials_from_env = false; + cx.notify(); + })?; + Ok(()) + }); + } + } + + // Step 2: No settings auth method - try to load static credentials + self.load_static_credentials(cx) + } + + /// Load static credentials from environment variables or keychain. + fn load_static_credentials( + &self, + cx: &mut Context, + ) -> Task> { let credentials_provider = ::global(cx); cx.spawn(async move |this, cx| { - let (credentials, from_env) = - if let Ok(credentials) = std::env::var(ZED_AWS_CREDENTIALS_VAR) { - (credentials, true) - } else { - let (_, credentials) = credentials_provider - .read_credentials(AMAZON_AWS_URL, cx) - .await? - .ok_or_else(|| AuthenticateError::CredentialsNotFound)?; + // Try environment variables first + let (auth, from_env) = if let Some(bearer_token) = &ZED_BEDROCK_BEARER_TOKEN_VAR.value { + if !bearer_token.is_empty() { ( - String::from_utf8(credentials) - .context("invalid {PROVIDER_NAME} credentials")?, - false, + Some(BedrockAuth::ApiKey { + api_key: bearer_token.to_string(), + }), + true, ) - }; + } else { + (None, false) + } + } else if let Some(access_key_id) = &ZED_BEDROCK_ACCESS_KEY_ID_VAR.value { + if let Some(secret_access_key) = &ZED_BEDROCK_SECRET_ACCESS_KEY_VAR.value { + if !access_key_id.is_empty() && !secret_access_key.is_empty() { + let session_token = ZED_BEDROCK_SESSION_TOKEN_VAR + .value + .as_deref() + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + ( + Some(BedrockAuth::IamCredentials { + access_key_id: access_key_id.to_string(), + secret_access_key: secret_access_key.to_string(), + session_token, + }), + true, + ) + } else { + (None, false) + } + } else { + (None, false) + } + } else { + (None, false) + }; + + // If we got auth from env vars, use it + if let Some(auth) = auth { + this.update(cx, |this, cx| { + this.auth = Some(auth); + this.credentials_from_env = from_env; + cx.notify(); + })?; + return Ok(()); + } + + // Try keychain + let (_, credentials_bytes) = credentials_provider + .read_credentials(AMAZON_AWS_URL, cx) + .await? + .ok_or(AuthenticateError::CredentialsNotFound)?; + + let credentials_str = String::from_utf8(credentials_bytes) + .context("invalid {PROVIDER_NAME} credentials")?; let credentials: BedrockCredentials = - serde_json::from_str(&credentials).context("failed to parse credentials")?; + serde_json::from_str(&credentials_str).context("failed to parse credentials")?; + + let auth = credentials + .into_auth() + .ok_or(AuthenticateError::CredentialsNotFound)?; this.update(cx, |this, cx| { - this.credentials = Some(credentials); - this.credentials_from_env = from_env; + this.auth = Some(auth); + this.credentials_from_env = false; cx.notify(); })?; @@ -229,15 +357,19 @@ impl State { }) } + /// Get the resolved region. Checks env var, then settings, then defaults to us-east-1. fn get_region(&self) -> String { - // Get region - from credentials or directly from settings - let credentials_region = self.credentials.as_ref().map(|s| s.region.clone()); - let settings_region = self.settings.as_ref().and_then(|s| s.region.clone()); - - // Use credentials region if available, otherwise use settings region, finally fall back to default - credentials_region - .or(settings_region) - .unwrap_or(String::from("us-east-1")) + // Priority: env var > settings > default + if let Some(region) = ZED_BEDROCK_REGION_VAR.value.as_deref() { + if !region.is_empty() { + return region.to_string(); + } + } + + self.settings + .as_ref() + .and_then(|s| s.region.clone()) + .unwrap_or_else(|| "us-east-1".to_string()) } fn get_allow_global(&self) -> bool { @@ -257,7 +389,7 @@ pub struct BedrockLanguageModelProvider { impl BedrockLanguageModelProvider { pub fn new(http_client: Arc, cx: &mut App) -> Self { let state = cx.new(|cx| State { - credentials: None, + auth: None, settings: Some(AllLanguageModelSettings::get_global(cx).bedrock.clone()), credentials_from_env: false, _subscription: cx.observe_global::(|_, cx| { @@ -266,7 +398,7 @@ impl BedrockLanguageModelProvider { }); Self { - http_client: AwsHttpClient::new(http_client.clone()), + http_client: AwsHttpClient::new(http_client), handle: Tokio::handle(cx), state, } @@ -312,7 +444,6 @@ impl LanguageModelProvider for BedrockLanguageModelProvider { for model in bedrock::Model::iter() { if !matches!(model, bedrock::Model::Custom { .. }) { - // TODO: Sonnet 3.7 vs. 3.7 Thinking bug is here. models.insert(model.id().to_string(), model); } } @@ -366,8 +497,7 @@ impl LanguageModelProvider for BedrockLanguageModelProvider { } fn reset_credentials(&self, cx: &mut App) -> Task> { - self.state - .update(cx, |state, cx| state.reset_credentials(cx)) + self.state.update(cx, |state, cx| state.reset_auth(cx)) } } @@ -393,25 +523,11 @@ impl BedrockModel { fn get_or_init_client(&self, cx: &AsyncApp) -> anyhow::Result<&BedrockClient> { self.client .get_or_try_init_blocking(|| { - let (auth_method, credentials, endpoint, region, settings) = - cx.read_entity(&self.state, |state, _cx| { - let auth_method = state - .settings - .as_ref() - .and_then(|s| s.authentication_method.clone()); - - let endpoint = state.settings.as_ref().and_then(|s| s.endpoint.clone()); - - let region = state.get_region(); - - ( - auth_method, - state.credentials.clone(), - endpoint, - region, - state.settings.clone(), - ) - })?; + let (auth, endpoint, region) = cx.read_entity(&self.state, |state, _cx| { + let endpoint = state.settings.as_ref().and_then(|s| s.endpoint.clone()); + let region = state.get_region(); + (state.auth.clone(), endpoint, region) + })?; let mut config_builder = aws_config::defaults(BehaviorVersion::latest()) .stalled_stream_protection(StalledStreamProtectionConfig::disabled()) @@ -425,37 +541,39 @@ impl BedrockModel { config_builder = config_builder.endpoint_url(endpoint_url); } - match auth_method { - None => { - if let Some(creds) = credentials { - let aws_creds = Credentials::new( - creds.access_key_id, - creds.secret_access_key, - creds.session_token, - None, - "zed-bedrock-provider", - ); - config_builder = config_builder.credentials_provider(aws_creds); - } + match auth { + Some(BedrockAuth::Automatic) | None => { + // Use default AWS credential provider chain } - Some(BedrockAuthMethod::NamedProfile) - | Some(BedrockAuthMethod::SingleSignOn) => { - // Currently NamedProfile and SSO behave the same way but only the instructions change - // Until we support BearerAuth through SSO, this will not change. - let profile_name = settings - .and_then(|s| s.profile_name) - .unwrap_or_else(|| "default".to_string()); - + Some(BedrockAuth::NamedProfile { profile_name }) + | Some(BedrockAuth::SingleSignOn { profile_name }) => { if !profile_name.is_empty() { config_builder = config_builder.profile_name(profile_name); } } - Some(BedrockAuthMethod::Automatic) => { - // Use default credential provider chain + Some(BedrockAuth::IamCredentials { + access_key_id, + secret_access_key, + session_token, + }) => { + let aws_creds = Credentials::new( + access_key_id, + secret_access_key, + session_token, + None, + "zed-bedrock-provider", + ); + config_builder = config_builder.credentials_provider(aws_creds); + } + Some(BedrockAuth::ApiKey { api_key }) => { + config_builder = config_builder + .auth_scheme_preference(["httpBearerAuth".into()]) // https://github.com/smithy-lang/smithy-rs/pull/4241 + .token_provider(Token::new(api_key, None)); } } let config = self.handle.block_on(config_builder.load()); + anyhow::Ok(BedrockClient::new(&config)) }) .context("initializing Bedrock client")?; @@ -1024,7 +1142,7 @@ struct ConfigurationView { access_key_id_editor: Entity, secret_access_key_editor: Entity, session_token_editor: Entity, - region_editor: Entity, + bearer_token_editor: Entity, state: Entity, load_credentials_task: Option>, focus_handle: FocusHandle, @@ -1035,7 +1153,7 @@ impl ConfigurationView { const PLACEHOLDER_SECRET_ACCESS_KEY_TEXT: &'static str = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"; const PLACEHOLDER_SESSION_TOKEN_TEXT: &'static str = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"; - const PLACEHOLDER_REGION: &'static str = "us-east-1"; + const PLACEHOLDER_BEARER_TOKEN_TEXT: &'static str = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"; fn new(state: Entity, window: &mut Window, cx: &mut Context) -> Self { let focus_handle = cx.focus_handle(); @@ -1066,9 +1184,9 @@ impl ConfigurationView { .tab_stop(true) }); - let region_editor = cx.new(|cx| { - InputField::new(window, cx, Self::PLACEHOLDER_REGION) - .label("Region") + let bearer_token_editor = cx.new(|cx| { + InputField::new(window, cx, Self::PLACEHOLDER_BEARER_TOKEN_TEXT) + .label("Bedrock API Key") .tab_index(3) .tab_stop(true) }); @@ -1095,7 +1213,7 @@ impl ConfigurationView { access_key_id_editor, secret_access_key_editor, session_token_editor, - region_editor, + bearer_token_editor, state, load_credentials_task, focus_handle, @@ -1131,25 +1249,30 @@ impl ConfigurationView { } else { Some(session_token) }; - let region = self.region_editor.read(cx).text(cx).trim().to_string(); - let region = if region.is_empty() { - "us-east-1".to_string() + let bearer_token = self + .bearer_token_editor + .read(cx) + .text(cx) + .trim() + .to_string(); + let bearer_token = if bearer_token.is_empty() { + None } else { - region + Some(bearer_token) }; let state = self.state.clone(); cx.spawn(async move |_, cx| { state .update(cx, |state, cx| { - let credentials: BedrockCredentials = BedrockCredentials { - region: region.clone(), - access_key_id: access_key_id.clone(), - secret_access_key: secret_access_key.clone(), - session_token: session_token.clone(), + let credentials = BedrockCredentials { + access_key_id, + secret_access_key, + session_token, + bearer_token, }; - state.set_credentials(credentials, cx) + state.set_static_credentials(credentials, cx) })? .await }) @@ -1163,41 +1286,39 @@ impl ConfigurationView { .update(cx, |editor, cx| editor.set_text("", window, cx)); self.session_token_editor .update(cx, |editor, cx| editor.set_text("", window, cx)); - self.region_editor + self.bearer_token_editor .update(cx, |editor, cx| editor.set_text("", window, cx)); let state = self.state.clone(); - cx.spawn(async move |_, cx| { - state - .update(cx, |state, cx| state.reset_credentials(cx))? - .await - }) - .detach_and_log_err(cx); + cx.spawn(async move |_, cx| state.update(cx, |state, cx| state.reset_auth(cx))?.await) + .detach_and_log_err(cx); } fn should_render_editor(&self, cx: &Context) -> bool { self.state.read(cx).is_authenticated() } - fn on_tab(&mut self, _: &menu::SelectNext, window: &mut Window, _: &mut Context) { - window.focus_next(); + fn on_tab(&mut self, _: &menu::SelectNext, window: &mut Window, cx: &mut Context) { + window.focus_next(cx); } fn on_tab_prev( &mut self, _: &menu::SelectPrevious, window: &mut Window, - _: &mut Context, + cx: &mut Context, ) { - window.focus_prev(); + window.focus_prev(cx); } } impl Render for ConfigurationView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let env_var_set = self.state.read(cx).credentials_from_env; - let bedrock_settings = self.state.read(cx).settings.as_ref(); - let bedrock_method = bedrock_settings + let state = self.state.read(cx); + let env_var_set = state.credentials_from_env; + let auth = state.auth.clone(); + let settings_auth_method = state + .settings .as_ref() .and_then(|s| s.authentication_method.clone()); @@ -1205,34 +1326,62 @@ impl Render for ConfigurationView { return div().child(Label::new("Loading credentials...")).into_any(); } - let configured_label = if env_var_set { - format!( - "Access Key ID is set in {ZED_BEDROCK_ACCESS_KEY_ID_VAR}, Secret Key is set in {ZED_BEDROCK_SECRET_ACCESS_KEY_VAR}, Region is set in {ZED_BEDROCK_REGION_VAR} environment variables." - ) - } else { - match bedrock_method { - Some(BedrockAuthMethod::Automatic) => "You are using automatic credentials.".into(), - Some(BedrockAuthMethod::NamedProfile) => "You are using named profile.".into(), - Some(BedrockAuthMethod::SingleSignOn) => { - "You are using a single sign on profile.".into() - } - None => "You are using static credentials.".into(), + let configured_label = match &auth { + Some(BedrockAuth::Automatic) => { + "Using automatic credentials (AWS default chain)".into() + } + Some(BedrockAuth::NamedProfile { profile_name }) => { + format!("Using AWS profile: {profile_name}") + } + Some(BedrockAuth::SingleSignOn { profile_name }) => { + format!("Using AWS SSO profile: {profile_name}") + } + Some(BedrockAuth::IamCredentials { .. }) if env_var_set => { + format!( + "Using IAM credentials from {} and {} environment variables", + ZED_BEDROCK_ACCESS_KEY_ID_VAR.name, ZED_BEDROCK_SECRET_ACCESS_KEY_VAR.name + ) + } + Some(BedrockAuth::IamCredentials { .. }) => "Using IAM credentials".into(), + Some(BedrockAuth::ApiKey { .. }) if env_var_set => { + format!( + "Using Bedrock API Key from {} environment variable", + ZED_BEDROCK_BEARER_TOKEN_VAR.name + ) } + Some(BedrockAuth::ApiKey { .. }) => "Using Bedrock API Key".into(), + None => "Not authenticated".into(), }; + // Determine if credentials can be reset + // Settings-derived auth (non-ApiKey) cannot be reset from UI + let is_settings_derived = matches!( + settings_auth_method, + Some(BedrockAuthMethod::Automatic) + | Some(BedrockAuthMethod::NamedProfile) + | Some(BedrockAuthMethod::SingleSignOn) + ); + let tooltip_label = if env_var_set { Some(format!( - "To reset your credentials, unset the {ZED_BEDROCK_ACCESS_KEY_ID_VAR}, {ZED_BEDROCK_SECRET_ACCESS_KEY_VAR}, and {ZED_BEDROCK_REGION_VAR} environment variables." + "To reset your credentials, unset the {}, {}, and {} or {} environment variables.", + ZED_BEDROCK_ACCESS_KEY_ID_VAR.name, + ZED_BEDROCK_SECRET_ACCESS_KEY_VAR.name, + ZED_BEDROCK_SESSION_TOKEN_VAR.name, + ZED_BEDROCK_BEARER_TOKEN_VAR.name )) - } else if bedrock_method.is_some() { - Some("You cannot reset credentials as they're being derived, check Zed settings to understand how.".to_string()) + } else if is_settings_derived { + Some( + "Authentication method is configured in settings. Edit settings.json to change." + .to_string(), + ) } else { None }; if self.should_render_editor(cx) { return ConfiguredApiCard::new(configured_label) - .disabled(env_var_set || bedrock_method.is_some()) + .disabled(env_var_set || is_settings_derived) .on_click(cx.listener(|this, _, window, cx| this.reset_credentials(window, cx))) .when_some(tooltip_label, |this, label| this.tooltip_label(label)) .into_any_element(); @@ -1262,7 +1411,7 @@ impl Render for ConfigurationView { .child(self.render_static_credentials_ui()) .child( Label::new( - format!("You can also assign the {ZED_BEDROCK_ACCESS_KEY_ID_VAR}, {ZED_BEDROCK_SECRET_ACCESS_KEY_VAR} AND {ZED_BEDROCK_REGION_VAR} environment variables and restart Zed."), + format!("You can also assign the {}, {} AND {} environment variables (or {} for Bedrock API Key authentication) and restart Zed.", ZED_BEDROCK_ACCESS_KEY_ID_VAR.name, ZED_BEDROCK_SECRET_ACCESS_KEY_VAR.name, ZED_BEDROCK_REGION_VAR.name, ZED_BEDROCK_BEARER_TOKEN_VAR.name), ) .size(LabelSize::Small) .color(Color::Muted) @@ -1270,7 +1419,7 @@ impl Render for ConfigurationView { ) .child( Label::new( - format!("Optionally, if your environment uses AWS CLI profiles, you can set {ZED_AWS_PROFILE_VAR}; if it requires a custom endpoint, you can set {ZED_AWS_ENDPOINT_VAR}; and if it requires a Session Token, you can set {ZED_BEDROCK_SESSION_TOKEN_VAR}."), + format!("Optionally, if your environment uses AWS CLI profiles, you can set {}; if it requires a custom endpoint, you can set {}; and if it requires a Session Token, you can set {}.", ZED_AWS_PROFILE_VAR.name, ZED_AWS_ENDPOINT_VAR.name, ZED_BEDROCK_SESSION_TOKEN_VAR.name), ) .size(LabelSize::Small) .color(Color::Muted), @@ -1292,31 +1441,47 @@ impl ConfigurationView { ) .child( Label::new( - "This method uses your AWS access key ID and secret access key directly.", + "This method uses your AWS access key ID and secret access key, or a Bedrock API Key.", ) ) .child( List::new() .child( ListBulletItem::new("") - .child(Label::new("Create an IAM user in the AWS console with programmatic access")) + .child(Label::new("For access keys: Create an IAM user in the AWS console with programmatic access")) .child(ButtonLink::new("IAM Console", "https://us-east-1.console.aws.amazon.com/iam/home?region=us-east-1#/users")) ) .child( ListBulletItem::new("") - .child(Label::new("Attach the necessary Bedrock permissions to this")) - .child(ButtonLink::new("user", "https://docs.aws.amazon.com/bedrock/latest/userguide/inference-prereq.html")) + .child(Label::new("For Bedrock API Keys: Generate an API key from the")) + .child(ButtonLink::new("Bedrock Console", "https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys-use.html")) ) .child( - ListBulletItem::new("Copy the access key ID and secret access key when provided") + ListBulletItem::new("") + .child(Label::new("Attach the necessary Bedrock permissions to this")) + .child(ButtonLink::new("user", "https://docs.aws.amazon.com/bedrock/latest/userguide/inference-prereq.html")) ) .child( - ListBulletItem::new("Enter these credentials below") - ) + ListBulletItem::new("Enter either access keys OR a Bedrock API Key below (not both)") + ), ) .child(self.access_key_id_editor.clone()) .child(self.secret_access_key_editor.clone()) .child(self.session_token_editor.clone()) - .child(self.region_editor.clone()) + .child( + Label::new("OR") + .size(LabelSize::Default) + .weight(FontWeight::BOLD) + .my_1(), + ) + .child(self.bearer_token_editor.clone()) + .child( + Label::new( + format!("Region is configured via {} environment variable or settings.json (defaults to us-east-1).", ZED_BEDROCK_REGION_VAR.name), + ) + .size(LabelSize::Small) + .color(Color::Muted) + .mt_2(), + ) } } diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index 508a77d38abcf2143170382e945ab6ce31f3a623..def1cef84d3166d08dcc7638ca5a29cabbd149c5 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -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> { 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, diff --git a/crates/language_models/src/provider/lmstudio.rs b/crates/language_models/src/provider/lmstudio.rs index 8e42d12db4c24ef6a66ddef470a34c620ed7ee00..94f99f10afc8928fb7fbc8526ab46e7dca37a5ce 100644 --- a/crates/language_models/src/provider/lmstudio.rs +++ b/crates/language_models/src/provider/lmstudio.rs @@ -20,7 +20,7 @@ use settings::{Settings, SettingsStore}; use std::pin::Pin; use std::str::FromStr; use std::{collections::BTreeMap, sync::Arc}; -use ui::{ButtonLike, Indicator, InlineCode, List, ListBulletItem, prelude::*}; +use ui::{ButtonLike, Indicator, List, ListBulletItem, prelude::*}; use util::ResultExt; use crate::AllLanguageModelSettings; @@ -691,7 +691,7 @@ impl Render for ConfigurationView { .child( ListBulletItem::new("") .child(Label::new("To get your first model, try running")) - .child(InlineCode::new("lms get qwen2.5-coder-7b")), + .child(Label::new("lms get qwen2.5-coder-7b").inline_code(cx)), ), ), ) diff --git a/crates/language_models/src/provider/ollama.rs b/crates/language_models/src/provider/ollama.rs index 6f3c49f8669885bfd02e5b11b81a091b1248227c..c5a8bf41711563110cbcb5d81698b7029b04a713 100644 --- a/crates/language_models/src/provider/ollama.rs +++ b/crates/language_models/src/provider/ollama.rs @@ -23,8 +23,8 @@ use std::sync::LazyLock; use std::sync::atomic::{AtomicU64, Ordering}; use std::{collections::HashMap, sync::Arc}; use ui::{ - ButtonLike, ButtonLink, ConfiguredApiCard, ElevationIndex, InlineCode, List, ListBulletItem, - Tooltip, prelude::*, + ButtonLike, ButtonLink, ConfiguredApiCard, ElevationIndex, List, ListBulletItem, Tooltip, + prelude::*, }; use ui_input::InputField; @@ -724,7 +724,7 @@ impl ConfigurationView { cx.notify(); } - fn render_instructions() -> Div { + fn render_instructions(cx: &mut Context) -> Div { v_flex() .gap_2() .child(Label::new( @@ -742,7 +742,7 @@ impl ConfigurationView { .child( ListBulletItem::new("") .child(Label::new("Start Ollama and download a model:")) - .child(InlineCode::new("ollama run gpt-oss:20b")), + .child(Label::new("ollama run gpt-oss:20b").inline_code(cx)), ) .child(ListBulletItem::new( "Click 'Connect' below to start using Ollama in Zed", @@ -833,7 +833,7 @@ impl Render for ConfigurationView { v_flex() .gap_2() - .child(Self::render_instructions()) + .child(Self::render_instructions(cx)) .child(self.render_api_url_editor(cx)) .child(self.render_api_key_editor(cx)) .child( diff --git a/crates/language_tools/src/lsp_log_view.rs b/crates/language_tools/src/lsp_log_view.rs index 314dcc0b9bde998a0fec65b2847ae13641f0d011..6fc061cd07edd9e22609ba698f27860b1b905765 100644 --- a/crates/language_tools/src/lsp_log_view.rs +++ b/crates/language_tools/src/lsp_log_view.rs @@ -269,7 +269,7 @@ impl LspLogView { let focus_handle = cx.focus_handle(); let focus_subscription = cx.on_focus(&focus_handle, window, |log_view, window, cx| { - window.focus(&log_view.editor.focus_handle(cx)); + window.focus(&log_view.editor.focus_handle(cx), cx); }); cx.on_release(|log_view, cx| { @@ -462,7 +462,7 @@ impl LspLogView { self.editor_subscriptions = editor_subscriptions; cx.notify(); } - self.editor.read(cx).focus_handle(cx).focus(window); + self.editor.read(cx).focus_handle(cx).focus(window, cx); self.log_store.update(cx, |log_store, cx| { let state = log_store.get_language_server_state(server_id)?; state.toggled_log_kind = Some(LogKind::Logs); @@ -494,7 +494,7 @@ impl LspLogView { cx.notify(); } - self.editor.read(cx).focus_handle(cx).focus(window); + self.editor.read(cx).focus_handle(cx).focus(window, cx); } fn show_trace_for_server( @@ -528,7 +528,7 @@ impl LspLogView { }); cx.notify(); } - self.editor.read(cx).focus_handle(cx).focus(window); + self.editor.read(cx).focus_handle(cx).focus(window, cx); } fn show_rpc_trace_for_server( @@ -572,7 +572,7 @@ impl LspLogView { cx.notify(); } - self.editor.read(cx).focus_handle(cx).focus(window); + self.editor.read(cx).focus_handle(cx).focus(window, cx); } fn toggle_rpc_trace_for_server( @@ -660,7 +660,7 @@ impl LspLogView { self.editor = editor; self.editor_subscriptions = editor_subscriptions; cx.notify(); - self.editor.read(cx).focus_handle(cx).focus(window); + self.editor.read(cx).focus_handle(cx).focus(window, cx); self.log_store.update(cx, |log_store, cx| { let state = log_store.get_language_server_state(server_id)?; if let Some(log_kind) = state.toggled_log_kind.take() { @@ -1314,7 +1314,7 @@ impl LspLogToolbarItemView { log_view.show_rpc_trace_for_server(id, window, cx); cx.notify(); } - window.focus(&log_view.focus_handle); + window.focus(&log_view.focus_handle, cx); }); } cx.notify(); diff --git a/crates/language_tools/src/syntax_tree_view.rs b/crates/language_tools/src/syntax_tree_view.rs index 0fbcdcca5eca80a01738888266389db5a678f3e8..15776e07d6d18835885ac5bafb2b29191d9e6bed 100644 --- a/crates/language_tools/src/syntax_tree_view.rs +++ b/crates/language_tools/src/syntax_tree_view.rs @@ -659,7 +659,7 @@ impl SyntaxTreeToolbarItemView { buffer_state.active_layer = Some(layer.to_owned()); view.selected_descendant_ix = None; cx.notify(); - view.focus_handle.focus(window); + view.focus_handle.focus(window, cx); Some(()) }) } diff --git a/crates/languages/Cargo.toml b/crates/languages/Cargo.toml index c0aa9c39aacd86e45071bfe7f7289e50cb64b9b1..8529bdb82ace33d6f3c747ed707b9aac9d319627 100644 --- a/crates/languages/Cargo.toml +++ b/crates/languages/Cargo.toml @@ -68,6 +68,7 @@ serde_json.workspace = true serde_json_lenient.workspace = true settings.workspace = true smallvec.workspace = true +semver.workspace = true smol.workspace = true snippet.workspace = true task.workspace = true diff --git a/crates/languages/src/css.rs b/crates/languages/src/css.rs index 6a925586a622adbf6d8e2e3b1076278c3680a39a..ca6bbd827e1c58beb13244d61e69d5c14a29c89d 100644 --- a/crates/languages/src/css.rs +++ b/crates/languages/src/css.rs @@ -5,6 +5,7 @@ use language::{LspAdapter, LspAdapterDelegate, LspInstaller, Toolchain}; use lsp::{LanguageServerBinary, LanguageServerName, Uri}; use node_runtime::{NodeRuntime, VersionStrategy}; use project::lsp_store::language_server_settings; +use semver::Version; use serde_json::json; use std::{ ffi::OsString, @@ -32,14 +33,14 @@ impl CssLspAdapter { } impl LspInstaller for CssLspAdapter { - type BinaryVersion = String; + type BinaryVersion = Version; async fn fetch_latest_server_version( &self, _: &dyn LspAdapterDelegate, _: bool, _: &mut AsyncApp, - ) -> Result { + ) -> Result { self.node .npm_package_latest_version("vscode-langservers-extracted") .await @@ -65,11 +66,12 @@ impl LspInstaller for CssLspAdapter { async fn fetch_server_binary( &self, - latest_version: String, + latest_version: Self::BinaryVersion, container_dir: PathBuf, _: &dyn LspAdapterDelegate, ) -> Result { let server_path = container_dir.join(SERVER_PATH); + let latest_version = latest_version.to_string(); self.node .npm_install_packages( @@ -87,7 +89,7 @@ impl LspInstaller for CssLspAdapter { async fn check_if_version_installed( &self, - version: &String, + version: &Self::BinaryVersion, container_dir: &PathBuf, _: &dyn LspAdapterDelegate, ) -> Option { diff --git a/crates/languages/src/json.rs b/crates/languages/src/json.rs index 5168ba6e6188da62745df72a031f1d3bcda9a5d2..5e0f4907ef09973ad5d7b4f67c19ced1f1ddf05e 100644 --- a/crates/languages/src/json.rs +++ b/crates/languages/src/json.rs @@ -13,6 +13,7 @@ use language::{ use lsp::{LanguageServerBinary, LanguageServerName, Uri}; use node_runtime::{NodeRuntime, VersionStrategy}; use project::lsp_store::language_server_settings; +use semver::Version; use serde_json::{Value, json}; use smol::{ fs::{self}, @@ -142,14 +143,14 @@ impl JsonLspAdapter { } impl LspInstaller for JsonLspAdapter { - type BinaryVersion = String; + type BinaryVersion = Version; async fn fetch_latest_server_version( &self, _: &dyn LspAdapterDelegate, _: bool, _: &mut AsyncApp, - ) -> Result { + ) -> Result { self.node .npm_package_latest_version(Self::PACKAGE_NAME) .await @@ -175,7 +176,7 @@ impl LspInstaller for JsonLspAdapter { async fn check_if_version_installed( &self, - version: &String, + version: &Self::BinaryVersion, container_dir: &PathBuf, _: &dyn LspAdapterDelegate, ) -> Option { @@ -204,11 +205,12 @@ impl LspInstaller for JsonLspAdapter { async fn fetch_server_binary( &self, - latest_version: String, + latest_version: Self::BinaryVersion, container_dir: PathBuf, _: &dyn LspAdapterDelegate, ) -> Result { let server_path = container_dir.join(SERVER_PATH); + let latest_version = latest_version.to_string(); self.node .npm_install_packages( diff --git a/crates/languages/src/python.rs b/crates/languages/src/python.rs index fbdeb59b7f15a22d4f4097a3b0e60b4aeb9bf202..77d4be6f49a4928731d39d2154cbe4f0e38024ef 100644 --- a/crates/languages/src/python.rs +++ b/crates/languages/src/python.rs @@ -19,6 +19,7 @@ use pet_core::python_environment::{PythonEnvironment, PythonEnvironmentKind}; use pet_virtualenv::is_virtualenv_dir; use project::Fs; use project::lsp_store::language_server_settings; +use semver::Version; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use settings::Settings; @@ -280,7 +281,7 @@ impl LspInstaller for TyLspAdapter { _: &mut AsyncApp, ) -> Result { let release = - latest_github_release("astral-sh/ty", true, true, delegate.http_client()).await?; + latest_github_release("astral-sh/ty", true, false, delegate.http_client()).await?; let (_, asset_name) = Self::build_asset_name()?; let asset = release .assets @@ -621,14 +622,14 @@ impl LspAdapter for PyrightLspAdapter { } impl LspInstaller for PyrightLspAdapter { - type BinaryVersion = String; + type BinaryVersion = Version; async fn fetch_latest_server_version( &self, _: &dyn LspAdapterDelegate, _: bool, _: &mut AsyncApp, - ) -> Result { + ) -> Result { self.node .npm_package_latest_version(Self::SERVER_NAME.as_ref()) .await @@ -672,6 +673,7 @@ impl LspInstaller for PyrightLspAdapter { delegate: &dyn LspAdapterDelegate, ) -> Result { let server_path = container_dir.join(Self::SERVER_PATH); + let latest_version = latest_version.to_string(); self.node .npm_install_packages( @@ -2040,14 +2042,14 @@ impl LspAdapter for BasedPyrightLspAdapter { } impl LspInstaller for BasedPyrightLspAdapter { - type BinaryVersion = String; + type BinaryVersion = Version; async fn fetch_latest_server_version( &self, _: &dyn LspAdapterDelegate, _: bool, _: &mut AsyncApp, - ) -> Result { + ) -> Result { self.node .npm_package_latest_version(Self::SERVER_NAME.as_ref()) .await @@ -2092,6 +2094,7 @@ impl LspInstaller for BasedPyrightLspAdapter { delegate: &dyn LspAdapterDelegate, ) -> Result { let server_path = container_dir.join(Self::SERVER_PATH); + let latest_version = latest_version.to_string(); self.node .npm_install_packages( diff --git a/crates/languages/src/rust.rs b/crates/languages/src/rust.rs index ee64954196f58fe03f53a9e83fbbbea3f636449a..c10f76b079bf093e71b5444934196940e7b26d6c 100644 --- a/crates/languages/src/rust.rs +++ b/crates/languages/src/rust.rs @@ -375,16 +375,20 @@ impl LspAdapter for RustLspAdapter { let start_pos = range.start as usize; let end_pos = range.end as usize; - label.push_str(&snippet.text[text_pos..end_pos]); - text_pos = end_pos; + label.push_str(&snippet.text[text_pos..start_pos]); if start_pos == end_pos { let caret_start = label.len(); label.push('…'); runs.push((caret_start..label.len(), HighlightId::TABSTOP_INSERT_ID)); } else { - runs.push((start_pos..end_pos, HighlightId::TABSTOP_REPLACE_ID)); + let label_start = label.len(); + label.push_str(&snippet.text[start_pos..end_pos]); + let label_end = label.len(); + runs.push((label_start..label_end, HighlightId::TABSTOP_REPLACE_ID)); } + + text_pos = end_pos; } label.push_str(&snippet.text[text_pos..]); @@ -1592,6 +1596,44 @@ mod tests { ], )) ); + + // Test for correct range calculation with mixed empty and non-empty tabstops.(See https://github.com/zed-industries/zed/issues/44825) + let res = adapter + .label_for_completion( + &lsp::CompletionItem { + kind: Some(lsp::CompletionItemKind::STRUCT), + label: "Particles".to_string(), + insert_text_format: Some(lsp::InsertTextFormat::SNIPPET), + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: lsp::Range::default(), + new_text: "Particles { pos_x: $1, pos_y: $2, vel_x: $3, vel_y: $4, acc_x: ${5:()}, acc_y: ${6:()}, mass: $7 }$0".to_string(), + })), + ..Default::default() + }, + &language, + ) + .await + .unwrap(); + + assert_eq!( + res, + CodeLabel::new( + "Particles { pos_x: …, pos_y: …, vel_x: …, vel_y: …, acc_x: (), acc_y: (), mass: … }".to_string(), + 0..9, + vec![ + (19..22, HighlightId::TABSTOP_INSERT_ID), + (31..34, HighlightId::TABSTOP_INSERT_ID), + (43..46, HighlightId::TABSTOP_INSERT_ID), + (55..58, HighlightId::TABSTOP_INSERT_ID), + (67..69, HighlightId::TABSTOP_REPLACE_ID), + (78..80, HighlightId::TABSTOP_REPLACE_ID), + (88..91, HighlightId::TABSTOP_INSERT_ID), + (0..9, highlight_type), + (60..65, highlight_field), + (71..76, highlight_field), + ], + ) + ); } #[gpui::test] diff --git a/crates/languages/src/tailwind.rs b/crates/languages/src/tailwind.rs index 7e23c4ba5255c0413904797d1f8094e67834fa6a..b4b6f76cec28d5d21c31ea67aa72ead6814eae7d 100644 --- a/crates/languages/src/tailwind.rs +++ b/crates/languages/src/tailwind.rs @@ -6,6 +6,7 @@ use language::{LanguageName, LspAdapter, LspAdapterDelegate, LspInstaller, Toolc use lsp::{LanguageServerBinary, LanguageServerName, Uri}; use node_runtime::{NodeRuntime, VersionStrategy}; use project::lsp_store::language_server_settings; +use semver::Version; use serde_json::{Value, json}; use std::{ ffi::OsString, @@ -39,14 +40,14 @@ impl TailwindLspAdapter { } impl LspInstaller for TailwindLspAdapter { - type BinaryVersion = String; + type BinaryVersion = Version; async fn fetch_latest_server_version( &self, _: &dyn LspAdapterDelegate, _: bool, _: &mut AsyncApp, - ) -> Result { + ) -> Result { self.node .npm_package_latest_version(Self::PACKAGE_NAME) .await @@ -70,11 +71,12 @@ impl LspInstaller for TailwindLspAdapter { async fn fetch_server_binary( &self, - latest_version: String, + latest_version: Self::BinaryVersion, container_dir: PathBuf, _: &dyn LspAdapterDelegate, ) -> Result { let server_path = container_dir.join(SERVER_PATH); + let latest_version = latest_version.to_string(); self.node .npm_install_packages( @@ -92,7 +94,7 @@ impl LspInstaller for TailwindLspAdapter { async fn check_if_version_installed( &self, - version: &String, + version: &Self::BinaryVersion, container_dir: &PathBuf, _: &dyn LspAdapterDelegate, ) -> Option { diff --git a/crates/languages/src/typescript.rs b/crates/languages/src/typescript.rs index 7daf178d37229a5b051461e199c3dbf8d830cf22..4f9476d5afa488074b3d770b9f007d155b4863e7 100644 --- a/crates/languages/src/typescript.rs +++ b/crates/languages/src/typescript.rs @@ -12,6 +12,7 @@ use language::{ use lsp::{CodeActionKind, LanguageServerBinary, LanguageServerName, Uri}; use node_runtime::{NodeRuntime, VersionStrategy}; use project::{Fs, lsp_store::language_server_settings}; +use semver::Version; use serde_json::{Value, json}; use smol::lock::RwLock; use std::{ @@ -635,8 +636,8 @@ impl TypeScriptLspAdapter { } pub struct TypeScriptVersions { - typescript_version: String, - server_version: String, + typescript_version: Version, + server_version: Version, } impl LspInstaller for TypeScriptLspAdapter { @@ -647,7 +648,7 @@ impl LspInstaller for TypeScriptLspAdapter { _: &dyn LspAdapterDelegate, _: bool, _: &mut AsyncApp, - ) -> Result { + ) -> Result { Ok(TypeScriptVersions { typescript_version: self .node @@ -662,7 +663,7 @@ impl LspInstaller for TypeScriptLspAdapter { async fn check_if_version_installed( &self, - version: &TypeScriptVersions, + version: &Self::BinaryVersion, container_dir: &PathBuf, _: &dyn LspAdapterDelegate, ) -> Option { @@ -674,7 +675,7 @@ impl LspInstaller for TypeScriptLspAdapter { Self::PACKAGE_NAME, &server_path, container_dir, - VersionStrategy::Latest(version.typescript_version.as_str()), + VersionStrategy::Latest(&version.typescript_version), ) .await { @@ -687,7 +688,7 @@ impl LspInstaller for TypeScriptLspAdapter { Self::SERVER_PACKAGE_NAME, &server_path, container_dir, - VersionStrategy::Latest(version.server_version.as_str()), + VersionStrategy::Latest(&version.server_version), ) .await { @@ -703,7 +704,7 @@ impl LspInstaller for TypeScriptLspAdapter { async fn fetch_server_binary( &self, - latest_version: TypeScriptVersions, + latest_version: Self::BinaryVersion, container_dir: PathBuf, _: &dyn LspAdapterDelegate, ) -> Result { @@ -715,11 +716,11 @@ impl LspInstaller for TypeScriptLspAdapter { &[ ( Self::PACKAGE_NAME, - latest_version.typescript_version.as_str(), + &latest_version.typescript_version.to_string(), ), ( Self::SERVER_PACKAGE_NAME, - latest_version.server_version.as_str(), + &latest_version.server_version.to_string(), ), ], ) diff --git a/crates/languages/src/vtsls.rs b/crates/languages/src/vtsls.rs index b21ae1a4de24e0a8035e8fda7d61223b5143c5ff..29b21a7cd80f1f0457e7720d68a6fb37954a02c5 100644 --- a/crates/languages/src/vtsls.rs +++ b/crates/languages/src/vtsls.rs @@ -2,12 +2,17 @@ use anyhow::Result; use async_trait::async_trait; use collections::HashMap; use gpui::AsyncApp; -use language::{LanguageName, LspAdapter, LspAdapterDelegate, LspInstaller, Toolchain}; +use language::{ + LanguageName, LspAdapter, LspAdapterDelegate, LspInstaller, PromptResponseContext, Toolchain, +}; use lsp::{CodeActionKind, LanguageServerBinary, LanguageServerName, Uri}; use node_runtime::{NodeRuntime, VersionStrategy}; use project::{Fs, lsp_store::language_server_settings}; use regex::Regex; +use semver::Version; use serde_json::Value; +use serde_json::json; +use settings::update_settings_file; use std::{ ffi::OsString, path::{Path, PathBuf}, @@ -15,6 +20,11 @@ use std::{ }; use util::{ResultExt, maybe, merge_json_value_into}; +const ACTION_ALWAYS: &str = "Always"; +const ACTION_NEVER: &str = "Never"; +const UPDATE_IMPORTS_MESSAGE_PATTERN: &str = "Update imports for"; +const VTSLS_SERVER_NAME: &str = "vtsls"; + fn typescript_server_binary_arguments(server_path: &Path) -> Vec { vec![server_path.into(), "--stdio".into()] } @@ -74,8 +84,8 @@ impl VtslsLspAdapter { } pub struct TypeScriptVersions { - typescript_version: String, - server_version: String, + typescript_version: Version, + server_version: Version, } const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("vtsls"); @@ -88,7 +98,7 @@ impl LspInstaller for VtslsLspAdapter { _: &dyn LspAdapterDelegate, _: bool, _: &mut AsyncApp, - ) -> Result { + ) -> Result { Ok(TypeScriptVersions { typescript_version: self.node.npm_package_latest_version("typescript").await?, server_version: self @@ -115,12 +125,15 @@ impl LspInstaller for VtslsLspAdapter { async fn fetch_server_binary( &self, - latest_version: TypeScriptVersions, + latest_version: Self::BinaryVersion, container_dir: PathBuf, _: &dyn LspAdapterDelegate, ) -> Result { let server_path = container_dir.join(Self::SERVER_PATH); + let typescript_version = latest_version.typescript_version.to_string(); + let server_version = latest_version.server_version.to_string(); + let mut packages_to_install = Vec::new(); if self @@ -133,7 +146,7 @@ impl LspInstaller for VtslsLspAdapter { ) .await { - packages_to_install.push((Self::PACKAGE_NAME, latest_version.server_version.as_str())); + packages_to_install.push((Self::PACKAGE_NAME, server_version.as_str())); } if self @@ -146,10 +159,7 @@ impl LspInstaller for VtslsLspAdapter { ) .await { - packages_to_install.push(( - Self::TYPESCRIPT_PACKAGE_NAME, - latest_version.typescript_version.as_str(), - )); + packages_to_install.push((Self::TYPESCRIPT_PACKAGE_NAME, typescript_version.as_str())); } self.node @@ -301,6 +311,52 @@ impl LspAdapter for VtslsLspAdapter { (LanguageName::new_static("TSX"), "typescriptreact".into()), ]) } + + fn process_prompt_response(&self, context: &PromptResponseContext, cx: &mut AsyncApp) { + let selected_title = context.selected_action.title.as_str(); + let is_preference_response = + selected_title == ACTION_ALWAYS || selected_title == ACTION_NEVER; + if !is_preference_response { + return; + } + + if context.message.contains(UPDATE_IMPORTS_MESSAGE_PATTERN) { + let setting_value = match selected_title { + ACTION_ALWAYS => "always", + ACTION_NEVER => "never", + _ => return, + }; + + let settings = json!({ + "typescript": { + "updateImportsOnFileMove": { + "enabled": setting_value + } + }, + "javascript": { + "updateImportsOnFileMove": { + "enabled": setting_value + } + } + }); + + let _ = cx.update(|cx| { + update_settings_file(self.fs.clone(), cx, move |content, _| { + let lsp_settings = content + .project + .lsp + .entry(VTSLS_SERVER_NAME.into()) + .or_default(); + + if let Some(existing) = &mut lsp_settings.settings { + merge_json_value_into(settings, existing); + } else { + lsp_settings.settings = Some(settings); + } + }); + }); + } + } } async fn get_cached_ts_server_binary( diff --git a/crates/languages/src/yaml.rs b/crates/languages/src/yaml.rs index 57f254a68f126ac7f05c57d25ef0f920103f2233..6c1d8bc2d9e74578868dc687ec76a3b95790c5a9 100644 --- a/crates/languages/src/yaml.rs +++ b/crates/languages/src/yaml.rs @@ -7,6 +7,7 @@ use language::{ use lsp::{LanguageServerBinary, LanguageServerName, Uri}; use node_runtime::{NodeRuntime, VersionStrategy}; use project::lsp_store::language_server_settings; +use semver::Version; use serde_json::Value; use settings::{Settings, SettingsLocation}; use std::{ @@ -35,14 +36,14 @@ impl YamlLspAdapter { } impl LspInstaller for YamlLspAdapter { - type BinaryVersion = String; + type BinaryVersion = Version; async fn fetch_latest_server_version( &self, _: &dyn LspAdapterDelegate, _: bool, _: &mut AsyncApp, - ) -> Result { + ) -> Result { self.node .npm_package_latest_version("yaml-language-server") .await @@ -66,7 +67,7 @@ impl LspInstaller for YamlLspAdapter { async fn fetch_server_binary( &self, - latest_version: String, + latest_version: Self::BinaryVersion, container_dir: PathBuf, _: &dyn LspAdapterDelegate, ) -> Result { @@ -75,7 +76,7 @@ impl LspInstaller for YamlLspAdapter { self.node .npm_install_packages( &container_dir, - &[(Self::PACKAGE_NAME, latest_version.as_str())], + &[(Self::PACKAGE_NAME, &latest_version.to_string())], ) .await?; @@ -88,7 +89,7 @@ impl LspInstaller for YamlLspAdapter { async fn check_if_version_installed( &self, - version: &String, + version: &Self::BinaryVersion, container_dir: &PathBuf, _: &dyn LspAdapterDelegate, ) -> Option { diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index 536d9fd6a2439e9b23b9f99d20a4aff425eda956..0bc3b9eb726e1782bafb2a31229ea21f308adc6e 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -151,6 +151,8 @@ actions!( [ /// Copies the selected text to the clipboard. Copy, + /// Copies the selected text as markdown to the clipboard. + CopyAsMarkdown ] ); @@ -295,6 +297,14 @@ impl Markdown { cx.write_to_clipboard(ClipboardItem::new_string(text)); } + fn copy_as_markdown(&self, _: &mut Window, cx: &mut Context) { + if self.selection.end <= self.selection.start { + return; + } + let text = self.source[self.selection.start..self.selection.end].to_string(); + cx.write_to_clipboard(ClipboardItem::new_string(text)); + } + fn parse(&mut self, cx: &mut Context) { if self.source.is_empty() { return; @@ -697,7 +707,7 @@ impl MarkdownElement { pending: true, mode, }; - window.focus(&markdown.focus_handle); + window.focus(&markdown.focus_handle, cx); } window.prevent_default(); @@ -1356,6 +1366,14 @@ impl Element for MarkdownElement { } } }); + window.on_action(std::any::TypeId::of::(), { + let entity = self.markdown.clone(); + move |_, phase, window, cx| { + if phase == DispatchPhase::Bubble { + entity.update(cx, move |this, cx| this.copy_as_markdown(window, cx)) + } + } + }); self.paint_mouse_listeners(hitbox, &rendered_markdown.text, window, cx); rendered_markdown.element.paint(window, cx); @@ -1919,7 +1937,7 @@ impl RenderedText { } fn text_for_range(&self, range: Range) -> String { - let mut ret = vec![]; + let mut accumulator = String::new(); for line in self.lines.iter() { if range.start > line.source_end { @@ -1944,9 +1962,12 @@ impl RenderedText { } .min(text.len()); - ret.push(text[start..end].to_string()); + accumulator.push_str(&text[start..end]); + accumulator.push('\n'); } - ret.join("\n") + // Remove trailing newline + accumulator.pop(); + accumulator } fn link_for_position(&self, position: Point) -> Option<&RenderedLink> { diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs index 20613b112eeccf76ec8be12bddc49c12b600ff9b..650f369309561d76669289737277b45fb99af5ec 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/crates/markdown_preview/src/markdown_preview_view.rs @@ -96,7 +96,7 @@ impl MarkdownPreviewView { pane.add_item(Box::new(view.clone()), false, false, None, window, cx) } }); - editor.focus_handle(cx).focus(window); + editor.focus_handle(cx).focus(window, cx); cx.notify(); } }); @@ -370,7 +370,7 @@ impl MarkdownPreviewView { cx, |selections| selections.select_ranges(vec![selection]), ); - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); }); } } diff --git a/crates/multi_buffer/src/multi_buffer_tests.rs b/crates/multi_buffer/src/multi_buffer_tests.rs index fc2edcac15be72c60309c5c386393ad83c387860..fb6dce079268e3dfed868a0c65c81bd12e226704 100644 --- a/crates/multi_buffer/src/multi_buffer_tests.rs +++ b/crates/multi_buffer/src/multi_buffer_tests.rs @@ -4480,6 +4480,19 @@ async fn test_word_diff_simple_replacement(cx: &mut TestAppContext) { assert_eq!(word_diffs, vec!["world", "bar", "WORLD", "BAR"]); } +#[gpui::test] +async fn test_word_diff_white_space(cx: &mut TestAppContext) { + let settings_store = cx.update(|cx| SettingsStore::test(cx)); + cx.set_global(settings_store); + + let base_text = "hello world foo bar\n"; + let modified_text = " hello world foo bar\n"; + + let word_diffs = collect_word_diffs(base_text, modified_text, cx); + + assert_eq!(word_diffs, vec![" "]); +} + #[gpui::test] async fn test_word_diff_consecutive_modified_lines(cx: &mut TestAppContext) { let settings_store = cx.update(|cx| SettingsStore::test(cx)); diff --git a/crates/node_runtime/src/node_runtime.rs b/crates/node_runtime/src/node_runtime.rs index 1eb6714500446dbfd2967ed4aa2f514a5f427aba..eb8a5b45797baf7329554cb0b8d4a4f67a1f6579 100644 --- a/crates/node_runtime/src/node_runtime.rs +++ b/crates/node_runtime/src/node_runtime.rs @@ -32,9 +32,9 @@ pub struct NodeBinaryOptions { pub enum VersionStrategy<'a> { /// Install if current version doesn't match pinned version - Pin(&'a str), + Pin(&'a Version), /// Install if current version is older than latest version - Latest(&'a str), + Latest(&'a Version), } #[derive(Clone)] @@ -221,14 +221,14 @@ impl NodeRuntime { &self, local_package_directory: &Path, name: &str, - ) -> Result> { + ) -> Result> { self.instance() .await .npm_package_installed_version(local_package_directory, name) .await } - pub async fn npm_package_latest_version(&self, name: &str) -> Result { + pub async fn npm_package_latest_version(&self, name: &str) -> Result { let http = self.0.lock().await.http.clone(); let output = self .instance() @@ -271,16 +271,19 @@ impl NodeRuntime { .map(|(name, version)| format!("{name}@{version}")) .collect(); - let mut arguments: Vec<_> = packages.iter().map(|p| p.as_str()).collect(); - arguments.extend_from_slice(&[ - "--save-exact", - "--fetch-retry-mintimeout", - "2000", - "--fetch-retry-maxtimeout", - "5000", - "--fetch-timeout", - "5000", - ]); + let arguments: Vec<_> = packages + .iter() + .map(|p| p.as_str()) + .chain([ + "--save-exact", + "--fetch-retry-mintimeout", + "2000", + "--fetch-retry-maxtimeout", + "5000", + "--fetch-timeout", + "5000", + ]) + .collect(); // This is also wrong because the directory is wrong. self.run_npm_subcommand(Some(directory), "install", &arguments) @@ -311,23 +314,9 @@ impl NodeRuntime { return true; }; - let Some(installed_version) = Version::parse(&installed_version).log_err() else { - return true; - }; - match version_strategy { - VersionStrategy::Pin(pinned_version) => { - let Some(pinned_version) = Version::parse(pinned_version).log_err() else { - return true; - }; - installed_version != pinned_version - } - VersionStrategy::Latest(latest_version) => { - let Some(latest_version) = Version::parse(latest_version).log_err() else { - return true; - }; - installed_version < latest_version - } + VersionStrategy::Pin(pinned_version) => &installed_version != pinned_version, + VersionStrategy::Latest(latest_version) => &installed_version < latest_version, } } } @@ -342,12 +331,12 @@ enum ArchiveType { pub struct NpmInfo { #[serde(default)] dist_tags: NpmInfoDistTags, - versions: Vec, + versions: Vec, } #[derive(Debug, Deserialize, Default)] pub struct NpmInfoDistTags { - latest: Option, + latest: Option, } #[async_trait::async_trait] @@ -367,7 +356,7 @@ trait NodeRuntimeTrait: Send + Sync { &self, local_package_directory: &Path, name: &str, - ) -> Result>; + ) -> Result>; } #[derive(Clone)] @@ -601,7 +590,7 @@ impl NodeRuntimeTrait for ManagedNodeRuntime { &self, local_package_directory: &Path, name: &str, - ) -> Result> { + ) -> Result> { read_package_installed_version(local_package_directory.join("node_modules"), name).await } } @@ -726,7 +715,7 @@ impl NodeRuntimeTrait for SystemNodeRuntime { &self, local_package_directory: &Path, name: &str, - ) -> Result> { + ) -> Result> { read_package_installed_version(local_package_directory.join("node_modules"), name).await // todo: allow returning a globally installed version (requires callers not to hard-code the path) } @@ -735,7 +724,7 @@ impl NodeRuntimeTrait for SystemNodeRuntime { pub async fn read_package_installed_version( node_module_directory: PathBuf, name: &str, -) -> Result> { +) -> Result> { let package_json_path = node_module_directory.join(name).join("package.json"); let mut file = match fs::File::open(package_json_path).await { @@ -751,7 +740,7 @@ pub async fn read_package_installed_version( #[derive(Deserialize)] struct PackageJson { - version: String, + version: Version, } let mut contents = String::new(); @@ -788,7 +777,7 @@ impl NodeRuntimeTrait for UnavailableNodeRuntime { &self, _local_package_directory: &Path, _: &str, - ) -> Result> { + ) -> Result> { bail!("{}", self.error_message) } } diff --git a/crates/onboarding/src/basics_page.rs b/crates/onboarding/src/basics_page.rs index ab5d578f7de731aff6be355b4d7ddb2c6cf95d57..b5a2f5de365b581b95cb60269918068345474880 100644 --- a/crates/onboarding/src/basics_page.rs +++ b/crates/onboarding/src/basics_page.rs @@ -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 = ::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)) } diff --git a/crates/onboarding/src/onboarding.rs b/crates/onboarding/src/onboarding.rs index 66402f33d31c6e9ce5894c56872c8d92d2c4c36c..495a55411fc936d476dfa0d443e155d1fa7faecd 100644 --- a/crates/onboarding/src/onboarding.rs +++ b/crates/onboarding/src/onboarding.rs @@ -190,7 +190,7 @@ pub fn show_onboarding_view(app_state: Arc, cx: &mut App) -> Task(); - window.focus(&active_editor.focus_handle(cx)); + window.focus(&active_editor.focus_handle(cx), cx); } }); diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index 8dbf7b681d9be45bda0fd9803cbb8e2cd434e921..5a32bd73b74a9e8caade1042a381983af0da71d3 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -998,9 +998,9 @@ impl OutlinePanel { fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context) { if self.filter_editor.focus_handle(cx).is_focused(window) { - self.focus_handle.focus(window); + self.focus_handle.focus(window, cx); } else { - self.filter_editor.focus_handle(cx).focus(window); + self.filter_editor.focus_handle(cx).focus(window, cx); } if self.context_menu.is_some() { @@ -1153,9 +1153,9 @@ impl OutlinePanel { } if change_focus { - active_editor.focus_handle(cx).focus(window); + active_editor.focus_handle(cx).focus(window, cx); } else { - self.focus_handle.focus(window); + self.focus_handle.focus(window, cx); } } } @@ -1458,7 +1458,7 @@ impl OutlinePanel { Box::new(zed_actions::workspace::CopyRelativePath), ) }); - window.focus(&context_menu.focus_handle(cx)); + window.focus(&context_menu.focus_handle(cx), cx); let subscription = cx.subscribe(&context_menu, |outline_panel, _, _: &DismissEvent, cx| { outline_panel.context_menu.take(); cx.notify(); @@ -4539,7 +4539,7 @@ impl OutlinePanel { cx: &mut Context, ) { if focus { - self.focus_handle.focus(window); + self.focus_handle.focus(window, cx); } let ix = self .cached_entries diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index 3d6ae27dfa0c6b60088995de6ccc1d85b08c9428..2da40b5bf4b47651df7236b0decb25fac67a3b1b 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -384,7 +384,7 @@ impl Picker { } pub fn focus(&self, window: &mut Window, cx: &mut App) { - self.focus_handle(cx).focus(window); + self.focus_handle(cx).focus(window, cx); } /// Handles the selecting an index, and passing the change to the delegate. diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml index 9e2789fc109b8217f0f1033cc6d4832105c0ad48..f39c368218511b6ddf560dda1198ef5c06bd0a2e 100644 --- a/crates/project/Cargo.toml +++ b/crates/project/Cargo.toml @@ -96,6 +96,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"] } diff --git a/crates/project/src/agent_server_store.rs b/crates/project/src/agent_server_store.rs index a2cc57beae9702e4d5b495a135e7c357c638c17a..287b25935676e2d5a09e92285a6cc94b81e52e13 100644 --- a/crates/project/src/agent_server_store.rs +++ b/crates/project/src/agent_server_store.rs @@ -22,6 +22,7 @@ use rpc::{ proto::{self, ExternalExtensionAgent}, }; use schemars::JsonSchema; +use semver::Version; use serde::{Deserialize, Serialize}; use settings::{RegisterSetting, SettingsStore}; use task::{Shell, SpawnInTerminal}; @@ -974,11 +975,10 @@ fn get_or_npm_install_builtin_agent( } versions.sort(); - let newest_version = if let Some((version, file_name)) = versions.last().cloned() + let newest_version = if let Some((version, _)) = versions.last().cloned() && minimum_version.is_none_or(|minimum_version| version >= minimum_version) { - versions.pop(); - Some(file_name) + versions.pop() } else { None }; @@ -1004,9 +1004,8 @@ fn get_or_npm_install_builtin_agent( }) .detach(); - let version = if let Some(file_name) = newest_version { + let version = if let Some((version, file_name)) = newest_version { cx.background_spawn({ - let file_name = file_name.clone(); let dir = dir.clone(); let fs = fs.clone(); async move { @@ -1015,7 +1014,7 @@ fn get_or_npm_install_builtin_agent( .await .ok(); if let Some(latest_version) = latest_version - && &latest_version != &file_name.to_string_lossy() + && latest_version != version { let download_result = download_latest_version( fs, @@ -1028,7 +1027,9 @@ fn get_or_npm_install_builtin_agent( if let Some(mut new_version_available) = new_version_available && download_result.is_some() { - new_version_available.send(Some(latest_version)).ok(); + new_version_available + .send(Some(latest_version.to_string())) + .ok(); } } } @@ -1047,6 +1048,7 @@ fn get_or_npm_install_builtin_agent( package_name.clone(), )) .await? + .to_string() .into() }; @@ -1093,7 +1095,7 @@ async fn download_latest_version( dir: PathBuf, node_runtime: NodeRuntime, package_name: SharedString, -) -> Result { +) -> Result { log::debug!("downloading latest version of {package_name}"); let tmp_dir = tempfile::tempdir_in(&dir)?; @@ -1109,7 +1111,7 @@ async fn download_latest_version( fs.rename( &tmp_dir.keep(), - &dir.join(&version), + &dir.join(version.to_string()), RenameOptions { ignore_if_exists: true, overwrite: true, diff --git a/crates/project/src/debugger/session.rs b/crates/project/src/debugger/session.rs index 82a139ea242889f89c3a6a0c6d41e83e00cbfec2..1bc41df4bd89b4a32b71ed4f0bec0a61e729f998 100644 --- a/crates/project/src/debugger/session.rs +++ b/crates/project/src/debugger/session.rs @@ -3118,10 +3118,11 @@ async fn get_or_install_companion(node: NodeRuntime, cx: &mut AsyncApp) -> Resul .await .context("getting installed companion version")? .context("companion was not installed")?; - smol::fs::rename(temp_dir.path(), dir.join(&version)) + let version_folder = dir.join(version.to_string()); + smol::fs::rename(temp_dir.path(), &version_folder) .await .context("moving companion package into place")?; - Ok(dir.join(version)) + Ok(version_folder) } let dir = paths::debug_adapters_dir().join("js-debug-companion"); @@ -3134,19 +3135,23 @@ async fn get_or_install_companion(node: NodeRuntime, cx: &mut AsyncApp) -> Resul .await .context("creating companion installation directory")?; - let mut children = smol::fs::read_dir(&dir) + let children = smol::fs::read_dir(&dir) .await .context("reading companion installation directory")? .try_collect::>() .await .context("reading companion installation directory entries")?; - children - .sort_by_key(|child| semver::Version::parse(child.file_name().to_str()?).ok()); - let latest_installed_version = children.last().and_then(|child| { - let version = child.file_name().into_string().ok()?; - Some((child.path(), version)) - }); + let latest_installed_version = children + .iter() + .filter_map(|child| { + Some(( + child.path(), + semver::Version::parse(child.file_name().to_str()?).ok()?, + )) + }) + .max_by_key(|(_, version)| version.clone()); + let latest_version = node .npm_package_latest_version(PACKAGE_NAME) .await diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index c73ab914b788fb92e69ea3a47db5446223098c2d..a414a03320a2defa4c9dbd4b6193a131e761d2c7 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -1672,6 +1672,59 @@ impl GitStore { } } + fn mark_entries_pending_by_project_paths( + &mut self, + project_paths: &[ProjectPath], + stage: bool, + cx: &mut Context, + ) { + let buffer_store = &self.buffer_store; + + for project_path in project_paths { + let Some(buffer) = buffer_store.read(cx).get_by_path(project_path) else { + continue; + }; + + let buffer_id = buffer.read(cx).remote_id(); + let Some(diff_state) = self.diffs.get(&buffer_id) else { + continue; + }; + + diff_state.update(cx, |diff_state, cx| { + let Some(uncommitted_diff) = diff_state.uncommitted_diff() else { + return; + }; + + let buffer_snapshot = buffer.read(cx).text_snapshot(); + let file_exists = buffer + .read(cx) + .file() + .is_some_and(|file| file.disk_state().exists()); + + let all_hunks: Vec<_> = uncommitted_diff + .read(cx) + .hunks_intersecting_range( + text::Anchor::MIN..text::Anchor::MAX, + &buffer_snapshot, + cx, + ) + .collect(); + + if !all_hunks.is_empty() { + uncommitted_diff.update(cx, |diff, cx| { + diff.stage_or_unstage_hunks( + stage, + &all_hunks, + &buffer_snapshot, + file_exists, + cx, + ); + }); + } + }); + } + } + pub fn git_clone( &self, repo: String, @@ -4200,6 +4253,28 @@ impl Repository { save_futures } + fn mark_entries_pending_for_stage( + &self, + entries: &[RepoPath], + stage: bool, + cx: &mut Context, + ) { + let Some(git_store) = self.git_store() else { + return; + }; + + let mut project_paths = Vec::new(); + for repo_path in entries { + if let Some(project_path) = self.repo_path_to_project_path(repo_path, cx) { + project_paths.push(project_path); + } + } + + git_store.update(cx, move |git_store, cx| { + git_store.mark_entries_pending_by_project_paths(&project_paths, stage, cx); + }); + } + pub fn stage_entries( &mut self, entries: Vec, @@ -4208,6 +4283,9 @@ impl Repository { if entries.is_empty() { return Task::ready(Ok(())); } + + self.mark_entries_pending_for_stage(&entries, true, cx); + let id = self.id; let save_tasks = self.save_buffers(&entries, cx); let paths = entries @@ -4273,6 +4351,9 @@ impl Repository { if entries.is_empty() { return Task::ready(Ok(())); } + + self.mark_entries_pending_for_stage(&entries, false, cx); + let id = self.id; let save_tasks = self.save_buffers(&entries, cx); let paths = entries diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index b107be8b9ff32ef078d92700b46210a3c35c2845..6696ec8c4c280199a55d098ab63a321f126eea5e 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -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 _; @@ -92,17 +93,19 @@ use rpc::{ AnyProtoClient, ErrorCode, ErrorExt as _, proto::{LspRequestId, LspRequestMessage as _}, }; +use semver::Version; 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 +299,7 @@ pub struct LocalLspStore { LanguageServerId, HashMap, HashMap>>, >, + restricted_worktrees_tasks: HashMap)>, } impl LocalLspStore { @@ -367,7 +371,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 +380,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>> = Default::default(); + let pending_workspace_folders = Arc::>>::default(); let pending_server = cx.spawn({ let adapter = adapter.clone(); @@ -420,7 +455,7 @@ impl LocalLspStore { server_id, server_name, binary, - &root_path, + &worktree_abs_path, code_action_kinds, Some(pending_workspace_folders), cx, @@ -556,8 +591,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 +608,34 @@ impl LocalLspStore { fn get_language_server_binary( &self, + worktree_abs_path: Arc, adapter: Arc, settings: Arc, toolchain: Option, delegate: Arc, allow_binary_download: bool, + untrusted_worktree_task: Option>, cx: &mut App, ) -> Task> { 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 +666,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) @@ -992,12 +1056,15 @@ impl LocalLspStore { .on_request::({ let this = lsp_store.clone(); let name = name.to_string(); + let adapter = adapter.clone(); move |params, cx| { let this = this.clone(); let name = name.to_string(); + let adapter = adapter.clone(); let mut cx = cx.clone(); async move { let actions = params.actions.unwrap_or_default(); + let message = params.message.clone(); let (tx, rx) = smol::channel::bounded(1); let request = LanguageServerPromptRequest { level: match params.typ { @@ -1018,6 +1085,14 @@ impl LocalLspStore { .is_ok(); if did_update { let response = rx.recv().await.ok(); + if let Some(ref selected_action) = response { + let context = language::PromptResponseContext { + message, + selected_action: selected_action.clone(), + }; + adapter.process_prompt_response(&context, &mut cx) + } + Ok(response) } else { Ok(None) @@ -2222,12 +2297,10 @@ impl LocalLspStore { && lsp_action.data.is_some() && (lsp_action.command.is_none() || lsp_action.edit.is_none()) { - *lsp_action = Box::new( - lang_server - .request::(*lsp_action.clone()) - .await - .into_response()?, - ); + **lsp_action = lang_server + .request::(*lsp_action.clone()) + .await + .into_response()?; } } LspAction::CodeLens(lens) => { @@ -3238,8 +3311,10 @@ impl LocalLspStore { ) .await .log_err(); - this.update(cx, |this, _| { + this.update(cx, |this, cx| { if let Some(transaction) = transaction { + cx.emit(LspStoreEvent::WorkspaceEditApplied(transaction.clone())); + this.as_local_mut() .unwrap() .last_workspace_edits_by_language_server @@ -3258,6 +3333,7 @@ impl LocalLspStore { id_to_remove: WorktreeId, cx: &mut Context, ) -> Vec { + 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); @@ -3778,6 +3854,7 @@ pub enum LspStoreEvent { edits: Vec<(lsp::Range, Snippet)>, most_recent_edit: clock::Lamport, }, + WorkspaceEditApplied(ProjectTransaction), } #[derive(Clone, Debug, Serialize)] @@ -3974,6 +4051,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(), }), @@ -6415,7 +6493,7 @@ impl LspStore { server_id == *completion_server_id, "server_id mismatch, applying completion resolve for {server_id} but completion server id is {completion_server_id}" ); - *lsp_completion = Box::new(resolved_completion); + **lsp_completion = resolved_completion; *resolved = true; } Ok(()) @@ -6574,7 +6652,7 @@ impl LspStore { server_id == *completion_server_id, "remote server_id mismatch, applying completion resolve for {server_id} but completion server id is {completion_server_id}" ); - *lsp_completion = Box::new(resolved_lsp_completion); + **lsp_completion = resolved_lsp_completion; *resolved = true; } @@ -13918,7 +13996,7 @@ impl LspAdapterDelegate for LocalLspAdapterDelegate { async fn npm_package_installed_version( &self, package_name: &str, - ) -> Result> { + ) -> Result> { let local_package_directory = self.worktree_root_path(); let node_modules_directory = local_package_directory.join("node_modules"); diff --git a/crates/project/src/persistence.rs b/crates/project/src/persistence.rs new file mode 100644 index 0000000000000000000000000000000000000000..5c4e664bdeba02a317da0610cf857e948bd5c93e --- /dev/null +++ b/crates/project/src/persistence.rs @@ -0,0 +1,60 @@ +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 {} + +#[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_WORKTREE_TRUST_LOCK: Mutex<()> = Mutex::new(()); +} diff --git a/crates/project/src/prettier_store.rs b/crates/project/src/prettier_store.rs index 40deac76404ddb4378fe08cae931d0f0e3583487..a8b6fe37701d85d06d837a0a5e494e2a294777ec 100644 --- a/crates/project/src/prettier_store.rs +++ b/crates/project/src/prettier_store.rs @@ -905,7 +905,7 @@ async fn install_prettier_packages( .with_context(|| { format!("fetching latest npm version for package {returned_package_name}") })?; - anyhow::Ok((returned_package_name, latest_version)) + anyhow::Ok((returned_package_name, latest_version.to_string())) }), ) .await diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 7e7c1ecb67d2f463cb5b728cbb2a7f1ea2b072e0..8b57413b22ac95a16e35a95d70a04b3ae49d4b31 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -19,6 +19,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 +40,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::{ @@ -348,6 +350,7 @@ pub enum Event { SnippetEdit(BufferId, Vec<(lsp::Range, Snippet)>), ExpandedAllForEntry(WorktreeId, ProjectEntryId), EntryRenamed(ProjectTransaction, ProjectPath, PathBuf), + WorkspaceEditApplied(ProjectTransaction), AgentLocationChanged, } @@ -1069,6 +1072,7 @@ impl Project { languages: Arc, fs: Arc, env: Option>, + init_worktree_trust: bool, cx: &mut App, ) -> Entity { cx.new(|cx: &mut Context| { @@ -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, languages: Arc, fs: Arc, + init_worktree_trust: bool, cx: &mut App, ) -> Entity { cx.new(|cx: &mut Context| { @@ -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, root_paths: impl IntoIterator, cx: &mut gpui::TestAppContext, + ) -> Entity { + Self::test_project(fs, root_paths, false, cx).await + } + + #[cfg(any(test, feature = "test-support"))] + pub async fn test_with_worktree_trust( + fs: Arc, + root_paths: impl IntoIterator, + cx: &mut gpui::TestAppContext, + ) -> Entity { + Self::test_project(fs, root_paths, true, cx).await + } + + #[cfg(any(test, feature = "test-support"))] + async fn test_project( + fs: Arc, + root_paths: impl IntoIterator, + init_worktree_trust: bool, + cx: &mut gpui::TestAppContext, ) -> Entity { use clock::FakeSystemClock; @@ -1850,6 +1908,7 @@ impl Project { Arc::new(languages), fs, None, + init_worktree_trust, cx, ) }); @@ -2425,13 +2484,11 @@ impl Project { cx: &mut Context, ) -> Result<()> { cx.update_global::(|store, cx| { - self.worktree_store.update(cx, |worktree_store, cx| { - for worktree in worktree_store.worktrees() { - store - .clear_local_settings(worktree.read(cx).id(), cx) - .log_err(); - } - }); + for worktree_metadata in &message.worktrees { + store + .clear_local_settings(WorktreeId::from_proto(worktree_metadata.id), cx) + .log_err(); + } }); self.join_project_response_message_id = message_id; @@ -3193,6 +3250,9 @@ impl Project { cx.emit(Event::SnippetEdit(*buffer_id, edits.clone())) } } + LspStoreEvent::WorkspaceEditApplied(transaction) => { + cx.emit(Event::WorkspaceEditApplied(transaction.clone())) + } } } @@ -4671,6 +4731,14 @@ impl Project { this.update(&mut cx, |this, cx| { // Don't handle messages that were sent before the response to us joining the project if envelope.message_id > this.join_project_response_message_id { + cx.update_global::(|store, cx| { + for worktree_metadata in &envelope.payload.worktrees { + store + .clear_local_settings(WorktreeId::from_proto(worktree_metadata.id), cx) + .log_err(); + } + }); + this.set_worktrees_from_proto(envelope.payload.worktrees, cx)?; } Ok(()) @@ -4757,9 +4825,14 @@ impl Project { envelope: TypedEnvelope, 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 +4859,58 @@ impl Project { BufferStore::handle_update_buffer(buffer_store, envelope, cx).await } + async fn handle_trust_worktrees( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + 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, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let trusted_worktrees = cx + .update(|cx| TrustedWorktrees::try_get_global(cx))? + .context("missing trusted worktrees")?; + trusted_worktrees.update(&mut cx, |trusted_worktrees, cx| { + let restricted_paths = envelope + .payload + .worktree_ids + .into_iter() + .map(WorktreeId::from_proto) + .map(PathTrust::Worktree) + .collect::>(); + 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, envelope: TypedEnvelope, diff --git a/crates/project/src/project_settings.rs b/crates/project/src/project_settings.rs index 8494eac5b33e7e1f231f9c62010c49aec345229f..6d95411681d5d350271e7071b752f27d0807f60d 100644 --- a/crates/project/src/project_settings.rs +++ b/crates/project/src/project_settings.rs @@ -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, project_id: u64, task_store: Entity, + pending_local_settings: + HashMap), Option>>, + _trusted_worktrees_watcher: Option, _user_settings_watcher: Option, _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::(|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, + kind: LocalSettingsKind, + file_content: &Option, + cx: &mut Context<'_, SettingsObserver>, +) { + cx.update_global::(|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, diff --git a/crates/project/src/trusted_worktrees.rs b/crates/project/src/trusted_worktrees.rs new file mode 100644 index 0000000000000000000000000000000000000000..0e1a8b4011bf56b150fe99a502eece905dcc9d78 --- /dev/null +++ b/crates/project/src/trusted_worktrees.rs @@ -0,0 +1,1378 @@ +//! A module, responsible for managing the trust logic in Zed. +//! +//! It deals with multiple hosts, distinguished by [`RemoteHostLocation`]. +//! Each [`crate::Project`] and `HeadlessProject` should call [`init_global`], if wants to establish the trust mechanism. +//! This will set up a [`gpui::Global`] with [`TrustedWorktrees`] entity that will persist, restore and allow querying for worktree trust. +//! It's also possible to subscribe on [`TrustedWorktreesEvent`] events of this entity to track trust changes dynamically. +//! +//! The implementation can synchronize trust information with the remote hosts: currently, WSL and SSH. +//! Docker and Collab remotes do not employ trust mechanism, as manage that themselves. +//! +//! Unless `trust_all_worktrees` auto trust is enabled, does not trust anything that was not persisted before. +//! When dealing with "restricted" and other related concepts in the API, it means all explicitly restricted, after any of the [`TrustedWorktreesStore::can_trust`] and [`TrustedWorktreesStore::can_trust_global`] calls. +//! +//! +//! +//! +//! Path rust hierarchy. +//! +//! Zed has multiple layers of trust, based on the requests and [`PathTrust`] enum variants. +//! From the least to the most trusted level: +//! +//! * "single file worktree" +//! +//! After opening an empty Zed it's possible to open just a file, same as after opening a directory in Zed it's possible to open a file outside of this directory. +//! Usual scenario for both cases is opening Zed's settings.json file via `zed: open settings file` command: that starts a language server for a new file open, which originates from a newly created, single file worktree. +//! +//! Spawning a language server is potentially dangerous, and Zed needs to restrict that by default. +//! Each single file worktree requires a separate trust permission, unless a more global level is trusted. +//! +//! * "directory worktree" +//! +//! If a directory is open in Zed, it's a full worktree which may spawn multiple language servers associated with it. +//! Each such worktree requires a separate trust permission, so each separate directory worktree has to be trusted separately, unless a more global level is trusted. +//! +//! When a directory worktree is trusted and language servers are allowed to be downloaded and started, hence, "single file worktree" level of trust also. +//! +//! * "path override" +//! +//! To ease trusting multiple directory worktrees at once, it's possible to trust a parent directory of a certain directory worktree opened in Zed. +//! Trusting a directory means trusting all its subdirectories as well, including all current and potential directory worktrees. + +use collections::{HashMap, HashSet}; +use gpui::{App, AppContext as _, Context, Entity, EventEmitter, Global, SharedString, WeakEntity}; +use remote::RemoteConnectionOptions; +use rpc::{AnyProtoClient, proto}; +use settings::{Settings as _, WorktreeId}; +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; +use util::debug_panic; + +use crate::{project_settings::ProjectSettings, worktree_store::WorktreeStore}; + +pub fn init( + db_trusted_paths: TrustedPaths, + downstream_client: Option<(AnyProtoClient, u64)>, + upstream_client: Option<(AnyProtoClient, u64)>, + cx: &mut App, +) { + if TrustedWorktrees::try_get_global(cx).is_none() { + let trusted_worktrees = cx.new(|_| { + TrustedWorktreesStore::new( + db_trusted_paths, + None, + None, + downstream_client, + upstream_client, + ) + }); + cx.set_global(TrustedWorktrees(trusted_worktrees)) + } +} + +/// An initialization call to set up trust global for a particular project (remote or local). +pub fn track_worktree_trust( + worktree_store: Entity, + remote_host: Option, + downstream_client: Option<(AnyProtoClient, u64)>, + upstream_client: Option<(AnyProtoClient, u64)>, + cx: &mut App, +) { + match TrustedWorktrees::try_get_global(cx) { + Some(trusted_worktrees) => { + trusted_worktrees.update(cx, |trusted_worktrees, cx| { + let sync_upstream = trusted_worktrees.upstream_client.as_ref().map(|(_, id)| id) + != upstream_client.as_ref().map(|(_, id)| id); + trusted_worktrees.downstream_client = downstream_client; + trusted_worktrees.upstream_client = upstream_client; + trusted_worktrees.add_worktree_store(worktree_store, remote_host, cx); + + if sync_upstream { + if let Some((upstream_client, upstream_project_id)) = + &trusted_worktrees.upstream_client + { + let trusted_paths = trusted_worktrees + .trusted_paths + .iter() + .flat_map(|(_, paths)| { + paths.iter().map(|trusted_path| trusted_path.to_proto()) + }) + .collect::>(); + if !trusted_paths.is_empty() { + upstream_client + .send(proto::TrustWorktrees { + project_id: *upstream_project_id, + trusted_paths, + }) + .ok(); + } + } + } + }); + } + None => log::debug!("No TrustedWorktrees initialized, not tracking worktree trust"), + } +} + +/// A collection of worktree trust metadata, can be accessed globally (if initialized) and subscribed to. +pub struct TrustedWorktrees(Entity); + +impl Global for TrustedWorktrees {} + +impl TrustedWorktrees { + pub fn try_get_global(cx: &App) -> Option> { + cx.try_global::().map(|this| this.0.clone()) + } +} + +/// A collection of worktrees that are considered trusted and not trusted. +/// This can be used when checking for this criteria before enabling certain features. +/// +/// Emits an event each time the worktree was checked and found not trusted, +/// or a certain worktree had been trusted. +pub struct TrustedWorktreesStore { + downstream_client: Option<(AnyProtoClient, u64)>, + upstream_client: Option<(AnyProtoClient, u64)>, + worktree_stores: HashMap, Option>, + trusted_paths: TrustedPaths, + restricted: HashSet, +} + +/// An identifier of a host to split the trust questions by. +/// Each trusted data change and event is done for a particular host. +/// A host may contain more than one worktree or even project open concurrently. +#[derive(Debug, PartialEq, Eq, Clone, Hash)] +pub struct RemoteHostLocation { + pub user_name: Option, + pub host_identifier: SharedString, +} + +impl From for RemoteHostLocation { + fn from(options: RemoteConnectionOptions) -> Self { + let (user_name, host_name) = match options { + RemoteConnectionOptions::Ssh(ssh) => ( + ssh.username.map(SharedString::new), + SharedString::new(ssh.host.to_string()), + ), + RemoteConnectionOptions::Wsl(wsl) => ( + wsl.user.map(SharedString::new), + SharedString::new(wsl.distro_name), + ), + RemoteConnectionOptions::Docker(docker_connection_options) => ( + Some(SharedString::new(docker_connection_options.name)), + SharedString::new(docker_connection_options.container_id), + ), + }; + RemoteHostLocation { + user_name, + host_identifier: host_name, + } + } +} + +/// A unit of trust consideration inside a particular host: +/// either a familiar worktree, or a path that may influence other worktrees' trust. +/// See module-level documentation on the trust model. +#[derive(Debug, PartialEq, Eq, Clone, Hash)] +pub enum PathTrust { + /// A worktree that is familiar to this workspace. + /// Either a single file or a directory worktree. + Worktree(WorktreeId), + /// A path that may be another worktree yet not loaded into any workspace (hence, without any `WorktreeId`), + /// or a parent path coming out of the security modal. + AbsPath(PathBuf), +} + +impl PathTrust { + fn to_proto(&self) -> proto::PathTrust { + match self { + Self::Worktree(worktree_id) => proto::PathTrust { + content: Some(proto::path_trust::Content::WorktreeId( + worktree_id.to_proto(), + )), + }, + Self::AbsPath(path_buf) => proto::PathTrust { + content: Some(proto::path_trust::Content::AbsPath( + path_buf.to_string_lossy().to_string(), + )), + }, + } + } + + pub fn from_proto(proto: proto::PathTrust) -> Option { + Some(match proto.content? { + proto::path_trust::Content::WorktreeId(id) => { + Self::Worktree(WorktreeId::from_proto(id)) + } + proto::path_trust::Content::AbsPath(path) => Self::AbsPath(PathBuf::from(path)), + }) + } +} + +/// A change of trust on a certain host. +#[derive(Debug)] +pub enum TrustedWorktreesEvent { + Trusted(Option, HashSet), + Restricted(Option, HashSet), +} + +impl EventEmitter for TrustedWorktreesStore {} + +pub type TrustedPaths = HashMap, HashSet>; + +impl TrustedWorktreesStore { + fn new( + trusted_paths: TrustedPaths, + worktree_store: Option>, + remote_host: Option, + downstream_client: Option<(AnyProtoClient, u64)>, + upstream_client: Option<(AnyProtoClient, u64)>, + ) -> Self { + if let Some((upstream_client, upstream_project_id)) = &upstream_client { + let trusted_paths = trusted_paths + .iter() + .flat_map(|(_, paths)| paths.iter().map(|trusted_path| trusted_path.to_proto())) + .collect::>(); + if !trusted_paths.is_empty() { + upstream_client + .send(proto::TrustWorktrees { + project_id: *upstream_project_id, + trusted_paths, + }) + .ok(); + } + } + + let worktree_stores = match worktree_store { + Some(worktree_store) => HashMap::from_iter([(worktree_store.downgrade(), remote_host)]), + None => HashMap::default(), + }; + + Self { + trusted_paths, + downstream_client, + upstream_client, + restricted: HashSet::default(), + worktree_stores, + } + } + + /// Whether a particular worktree store has associated worktrees that are restricted, or an associated host is restricted. + pub fn has_restricted_worktrees( + &self, + worktree_store: &Entity, + cx: &App, + ) -> bool { + self.worktree_stores + .contains_key(&worktree_store.downgrade()) + && self.restricted.iter().any(|restricted_worktree| { + worktree_store + .read(cx) + .worktree_for_id(*restricted_worktree, cx) + .is_some() + }) + } + + /// Adds certain entities on this host to the trusted list. + /// This will emit [`TrustedWorktreesEvent::Trusted`] event for all passed entries + /// and the ones that got auto trusted based on trust hierarchy (see module-level docs). + pub fn trust( + &mut self, + mut trusted_paths: HashSet, + remote_host: Option, + cx: &mut Context, + ) { + let mut new_trusted_single_file_worktrees = HashSet::default(); + let mut new_trusted_other_worktrees = HashSet::default(); + let mut new_trusted_abs_paths = HashSet::default(); + for trusted_path in trusted_paths.iter().chain( + self.trusted_paths + .remove(&remote_host) + .iter() + .flat_map(|current_trusted| current_trusted.iter()), + ) { + match trusted_path { + PathTrust::Worktree(worktree_id) => { + self.restricted.remove(worktree_id); + if let Some((abs_path, is_file, host)) = + self.find_worktree_data(*worktree_id, cx) + { + if host == remote_host { + if is_file { + new_trusted_single_file_worktrees.insert(*worktree_id); + } else { + new_trusted_other_worktrees.insert((abs_path, *worktree_id)); + } + } + } + } + PathTrust::AbsPath(path) => { + debug_assert!( + path.is_absolute(), + "Cannot trust non-absolute path {path:?}" + ); + new_trusted_abs_paths.insert(path.clone()); + } + } + } + + new_trusted_other_worktrees.retain(|(worktree_abs_path, _)| { + new_trusted_abs_paths + .iter() + .all(|new_trusted_path| !worktree_abs_path.starts_with(new_trusted_path)) + }); + if !new_trusted_other_worktrees.is_empty() { + new_trusted_single_file_worktrees.clear(); + } + self.restricted = std::mem::take(&mut self.restricted) + .into_iter() + .filter(|restricted_worktree| { + let Some((restricted_worktree_path, is_file, restricted_host)) = + self.find_worktree_data(*restricted_worktree, cx) + else { + return false; + }; + if restricted_host != remote_host { + return true; + } + let retain = (!is_file || new_trusted_other_worktrees.is_empty()) + && new_trusted_abs_paths.iter().all(|new_trusted_path| { + !restricted_worktree_path.starts_with(new_trusted_path) + }); + if !retain { + trusted_paths.insert(PathTrust::Worktree(*restricted_worktree)); + } + retain + }) + .collect(); + + { + let trusted_paths = self.trusted_paths.entry(remote_host.clone()).or_default(); + trusted_paths.extend(new_trusted_abs_paths.into_iter().map(PathTrust::AbsPath)); + trusted_paths.extend( + new_trusted_other_worktrees + .into_iter() + .map(|(_, worktree_id)| PathTrust::Worktree(worktree_id)), + ); + trusted_paths.extend( + new_trusted_single_file_worktrees + .into_iter() + .map(PathTrust::Worktree), + ); + } + + cx.emit(TrustedWorktreesEvent::Trusted( + remote_host, + trusted_paths.clone(), + )); + + if let Some((upstream_client, upstream_project_id)) = &self.upstream_client { + let trusted_paths = trusted_paths + .iter() + .map(|trusted_path| trusted_path.to_proto()) + .collect::>(); + if !trusted_paths.is_empty() { + upstream_client + .send(proto::TrustWorktrees { + project_id: *upstream_project_id, + trusted_paths, + }) + .ok(); + } + } + } + + /// Restricts certain entities on this host. + /// This will emit [`TrustedWorktreesEvent::Restricted`] event for all passed entries. + pub fn restrict( + &mut self, + restricted_paths: HashSet, + remote_host: Option, + cx: &mut Context, + ) { + for restricted_path in restricted_paths { + match restricted_path { + PathTrust::Worktree(worktree_id) => { + self.restricted.insert(worktree_id); + cx.emit(TrustedWorktreesEvent::Restricted( + remote_host.clone(), + HashSet::from_iter([PathTrust::Worktree(worktree_id)]), + )); + } + PathTrust::AbsPath(..) => debug_panic!("Unexpected: cannot restrict an abs path"), + } + } + } + + /// Erases all trust information. + /// Requires Zed's restart to take proper effect. + pub fn clear_trusted_paths(&mut self) { + self.trusted_paths.clear(); + } + + /// Checks whether a certain worktree is trusted (or on a larger trust level). + /// If not, emits [`TrustedWorktreesEvent::Restricted`] event if for the first time and not trusted, or no corresponding worktree store was found. + /// + /// No events or data adjustment happens when `trust_all_worktrees` auto trust is enabled. + pub fn can_trust(&mut self, worktree_id: WorktreeId, cx: &mut Context) -> bool { + if ProjectSettings::get_global(cx).session.trust_all_worktrees { + return true; + } + if self.restricted.contains(&worktree_id) { + return false; + } + + let Some((worktree_path, is_file, remote_host)) = self.find_worktree_data(worktree_id, cx) + else { + return false; + }; + + if self + .trusted_paths + .get(&remote_host) + .is_some_and(|trusted_paths| trusted_paths.contains(&PathTrust::Worktree(worktree_id))) + { + return true; + } + + // See module documentation for details on trust level. + if is_file && self.trusted_paths.contains_key(&remote_host) { + return true; + } + + let parent_path_trusted = + self.trusted_paths + .get(&remote_host) + .is_some_and(|trusted_paths| { + trusted_paths.iter().any(|trusted_path| { + let PathTrust::AbsPath(trusted_path) = trusted_path else { + return false; + }; + worktree_path.starts_with(trusted_path) + }) + }); + if parent_path_trusted { + return true; + } + + self.restricted.insert(worktree_id); + cx.emit(TrustedWorktreesEvent::Restricted( + remote_host, + HashSet::from_iter([PathTrust::Worktree(worktree_id)]), + )); + if let Some((downstream_client, downstream_project_id)) = &self.downstream_client { + downstream_client + .send(proto::RestrictWorktrees { + project_id: *downstream_project_id, + worktree_ids: vec![worktree_id.to_proto()], + }) + .ok(); + } + if let Some((upstream_client, upstream_project_id)) = &self.upstream_client { + upstream_client + .send(proto::RestrictWorktrees { + project_id: *upstream_project_id, + worktree_ids: vec![worktree_id.to_proto()], + }) + .ok(); + } + false + } + + /// Lists all explicitly restricted worktrees (via [`TrustedWorktreesStore::can_trust`] method calls) for a particular worktree store on a particular host. + pub fn restricted_worktrees( + &self, + worktree_store: &WorktreeStore, + cx: &App, + ) -> HashSet<(WorktreeId, Arc)> { + let mut single_file_paths = HashSet::default(); + let other_paths = self + .restricted + .iter() + .filter_map(|&restricted_worktree_id| { + let worktree = worktree_store.worktree_for_id(restricted_worktree_id, cx)?; + let worktree = worktree.read(cx); + let abs_path = worktree.abs_path(); + if worktree.is_single_file() { + single_file_paths.insert((restricted_worktree_id, abs_path)); + None + } else { + Some((restricted_worktree_id, abs_path)) + } + }) + .collect::>(); + + if !other_paths.is_empty() { + return other_paths; + } else { + single_file_paths + } + } + + /// Switches the "trust nothing" mode to "automatically trust everything". + /// This does not influence already persisted data, but stops adding new worktrees there. + pub fn auto_trust_all(&mut self, cx: &mut Context) { + for (remote_host, worktrees) in std::mem::take(&mut self.restricted) + .into_iter() + .flat_map(|restricted_worktree| { + let (_, _, host) = self.find_worktree_data(restricted_worktree, cx)?; + Some((restricted_worktree, host)) + }) + .fold(HashMap::default(), |mut acc, (worktree_id, remote_host)| { + acc.entry(remote_host) + .or_insert_with(HashSet::default) + .insert(PathTrust::Worktree(worktree_id)); + acc + }) + { + self.trust(worktrees, remote_host, cx); + } + } + + /// Returns a normalized representation of the trusted paths to store in the DB. + pub fn trusted_paths_for_serialization( + &mut self, + cx: &mut Context, + ) -> HashMap, HashSet> { + let new_trusted_worktrees = self + .trusted_paths + .clone() + .into_iter() + .map(|(host, paths)| { + let abs_paths = paths + .into_iter() + .flat_map(|path| match path { + PathTrust::Worktree(worktree_id) => self + .find_worktree_data(worktree_id, cx) + .map(|(abs_path, ..)| abs_path.to_path_buf()), + PathTrust::AbsPath(abs_path) => Some(abs_path), + }) + .collect(); + (host, abs_paths) + }) + .collect(); + new_trusted_worktrees + } + + fn find_worktree_data( + &mut self, + worktree_id: WorktreeId, + cx: &mut Context, + ) -> Option<(Arc, bool, Option)> { + let mut worktree_data = None; + self.worktree_stores.retain( + |worktree_store, remote_host| match worktree_store.upgrade() { + Some(worktree_store) => { + if worktree_data.is_none() { + if let Some(worktree) = + worktree_store.read(cx).worktree_for_id(worktree_id, cx) + { + worktree_data = Some(( + worktree.read(cx).abs_path(), + worktree.read(cx).is_single_file(), + remote_host.clone(), + )); + } + } + true + } + None => false, + }, + ); + worktree_data + } + + fn add_worktree_store( + &mut self, + worktree_store: Entity, + remote_host: Option, + cx: &mut Context, + ) { + self.worktree_stores + .insert(worktree_store.downgrade(), remote_host.clone()); + + if let Some(trusted_paths) = self.trusted_paths.remove(&remote_host) { + self.trusted_paths.insert( + remote_host.clone(), + trusted_paths + .into_iter() + .map(|path_trust| match path_trust { + PathTrust::AbsPath(abs_path) => { + find_worktree_in_store(worktree_store.read(cx), &abs_path, cx) + .map(PathTrust::Worktree) + .unwrap_or_else(|| PathTrust::AbsPath(abs_path)) + } + other => other, + }) + .collect(), + ); + } + } +} + +pub fn find_worktree_in_store( + worktree_store: &WorktreeStore, + abs_path: &Path, + cx: &App, +) -> Option { + let (worktree, path_in_worktree) = worktree_store.find_worktree(&abs_path, cx)?; + if path_in_worktree.is_empty() { + Some(worktree.read(cx).id()) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use std::{cell::RefCell, path::PathBuf, rc::Rc}; + + use collections::HashSet; + use gpui::TestAppContext; + use serde_json::json; + use settings::SettingsStore; + use util::path; + + use crate::{FakeFs, Project}; + + use super::*; + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + if cx.try_global::().is_none() { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + } + if cx.try_global::().is_some() { + cx.remove_global::(); + } + }); + } + + fn init_trust_global( + worktree_store: Entity, + cx: &mut TestAppContext, + ) -> Entity { + cx.update(|cx| { + init(HashMap::default(), None, None, cx); + track_worktree_trust(worktree_store, None, None, None, cx); + TrustedWorktrees::try_get_global(cx).expect("global should be set") + }) + } + + #[gpui::test] + async fn test_single_worktree_trust(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root"), json!({ "main.rs": "fn main() {}" })) + .await; + + let project = Project::test(fs, [path!("/root").as_ref()], cx).await; + let worktree_store = project.read_with(cx, |project, _| project.worktree_store()); + let worktree_id = worktree_store.read_with(cx, |store, cx| { + store.worktrees().next().unwrap().read(cx).id() + }); + + let trusted_worktrees = init_trust_global(worktree_store.clone(), cx); + + let events: Rc>> = Rc::default(); + cx.update({ + let events = events.clone(); + |cx| { + cx.subscribe(&trusted_worktrees, move |_, event, _| { + events.borrow_mut().push(match event { + TrustedWorktreesEvent::Trusted(host, paths) => { + TrustedWorktreesEvent::Trusted(host.clone(), paths.clone()) + } + TrustedWorktreesEvent::Restricted(host, paths) => { + TrustedWorktreesEvent::Restricted(host.clone(), paths.clone()) + } + }); + }) + } + }) + .detach(); + + let can_trust = trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx)); + assert!(!can_trust, "worktree should be restricted by default"); + + { + let events = events.borrow(); + assert_eq!(events.len(), 1); + match &events[0] { + TrustedWorktreesEvent::Restricted(host, paths) => { + assert!(host.is_none()); + assert!(paths.contains(&PathTrust::Worktree(worktree_id))); + } + _ => panic!("expected Restricted event"), + } + } + + let has_restricted = trusted_worktrees.read_with(cx, |store, cx| { + store.has_restricted_worktrees(&worktree_store, cx) + }); + assert!(has_restricted, "should have restricted worktrees"); + + let restricted = worktree_store.read_with(cx, |ws, cx| { + trusted_worktrees.read(cx).restricted_worktrees(ws, cx) + }); + assert!(restricted.iter().any(|(id, _)| *id == worktree_id)); + + events.borrow_mut().clear(); + + let can_trust_again = + trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx)); + assert!(!can_trust_again, "worktree should still be restricted"); + assert!( + events.borrow().is_empty(), + "no duplicate Restricted event on repeated can_trust" + ); + + trusted_worktrees.update(cx, |store, cx| { + store.trust( + HashSet::from_iter([PathTrust::Worktree(worktree_id)]), + None, + cx, + ); + }); + + { + let events = events.borrow(); + assert_eq!(events.len(), 1); + match &events[0] { + TrustedWorktreesEvent::Trusted(host, paths) => { + assert!(host.is_none()); + assert!(paths.contains(&PathTrust::Worktree(worktree_id))); + } + _ => panic!("expected Trusted event"), + } + } + + 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()"); + + let has_restricted_after = trusted_worktrees.read_with(cx, |store, cx| { + store.has_restricted_worktrees(&worktree_store, cx) + }); + assert!( + !has_restricted_after, + "should have no restricted worktrees after trust" + ); + + let restricted_after = worktree_store.read_with(cx, |ws, cx| { + trusted_worktrees.read(cx).restricted_worktrees(ws, cx) + }); + assert!( + restricted_after.is_empty(), + "restricted set should be empty" + ); + } + + #[gpui::test] + async fn test_single_file_worktree_trust(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root"), json!({ "foo.rs": "fn foo() {}" })) + .await; + + let project = Project::test(fs, [path!("/root/foo.rs").as_ref()], cx).await; + let worktree_store = project.read_with(cx, |project, _| project.worktree_store()); + let worktree_id = worktree_store.read_with(cx, |store, cx| { + let worktree = store.worktrees().next().unwrap(); + let worktree = worktree.read(cx); + assert!(worktree.is_single_file(), "expected single-file worktree"); + worktree.id() + }); + + let trusted_worktrees = init_trust_global(worktree_store, cx); + + let events: Rc>> = Rc::default(); + cx.update({ + let events = events.clone(); + |cx| { + cx.subscribe(&trusted_worktrees, move |_, event, _| { + events.borrow_mut().push(match event { + TrustedWorktreesEvent::Trusted(host, paths) => { + TrustedWorktreesEvent::Trusted(host.clone(), paths.clone()) + } + TrustedWorktreesEvent::Restricted(host, paths) => { + TrustedWorktreesEvent::Restricted(host.clone(), paths.clone()) + } + }); + }) + } + }) + .detach(); + + let can_trust = trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx)); + assert!( + !can_trust, + "single-file worktree should be restricted by default" + ); + + { + let events = events.borrow(); + assert_eq!(events.len(), 1); + match &events[0] { + TrustedWorktreesEvent::Restricted(host, paths) => { + assert!(host.is_none()); + assert!(paths.contains(&PathTrust::Worktree(worktree_id))); + } + _ => panic!("expected Restricted event"), + } + } + + events.borrow_mut().clear(); + + trusted_worktrees.update(cx, |store, cx| { + store.trust( + HashSet::from_iter([PathTrust::Worktree(worktree_id)]), + None, + cx, + ); + }); + + { + let events = events.borrow(); + assert_eq!(events.len(), 1); + match &events[0] { + TrustedWorktreesEvent::Trusted(host, paths) => { + assert!(host.is_none()); + assert!(paths.contains(&PathTrust::Worktree(worktree_id))); + } + _ => panic!("expected Trusted event"), + } + } + + let can_trust_after = + trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx)); + assert!( + can_trust_after, + "single-file worktree should be trusted after trust()" + ); + } + + #[gpui::test] + async fn test_multiple_single_file_worktrees_trust_one(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/root"), + json!({ + "a.rs": "fn a() {}", + "b.rs": "fn b() {}", + "c.rs": "fn c() {}" + }), + ) + .await; + + let project = Project::test( + fs, + [ + path!("/root/a.rs").as_ref(), + path!("/root/b.rs").as_ref(), + path!("/root/c.rs").as_ref(), + ], + cx, + ) + .await; + let worktree_store = project.read_with(cx, |project, _| project.worktree_store()); + let worktree_ids: Vec<_> = worktree_store.read_with(cx, |store, cx| { + store + .worktrees() + .map(|worktree| { + let worktree = worktree.read(cx); + assert!(worktree.is_single_file()); + worktree.id() + }) + .collect() + }); + assert_eq!(worktree_ids.len(), 3); + + let trusted_worktrees = init_trust_global(worktree_store, cx); + + for &worktree_id in &worktree_ids { + let can_trust = + trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx)); + assert!( + !can_trust, + "worktree {worktree_id:?} should be restricted initially" + ); + } + + trusted_worktrees.update(cx, |store, cx| { + store.trust( + HashSet::from_iter([PathTrust::Worktree(worktree_ids[1])]), + None, + cx, + ); + }); + + let can_trust_0 = + trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_ids[0], cx)); + let can_trust_1 = + trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_ids[1], cx)); + let can_trust_2 = + trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_ids[2], cx)); + + assert!(!can_trust_0, "worktree 0 should still be restricted"); + assert!(can_trust_1, "worktree 1 should be trusted"); + assert!(!can_trust_2, "worktree 2 should still be restricted"); + } + + #[gpui::test] + async fn test_two_directory_worktrees_separate_trust(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/projects"), + json!({ + "project_a": { "main.rs": "fn main() {}" }, + "project_b": { "lib.rs": "pub fn lib() {}" } + }), + ) + .await; + + let project = Project::test( + fs, + [ + path!("/projects/project_a").as_ref(), + path!("/projects/project_b").as_ref(), + ], + cx, + ) + .await; + let worktree_store = project.read_with(cx, |project, _| project.worktree_store()); + let worktree_ids: Vec<_> = worktree_store.read_with(cx, |store, cx| { + store + .worktrees() + .map(|worktree| { + let worktree = worktree.read(cx); + assert!(!worktree.is_single_file()); + worktree.id() + }) + .collect() + }); + assert_eq!(worktree_ids.len(), 2); + + let trusted_worktrees = init_trust_global(worktree_store, cx); + + let can_trust_a = + trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_ids[0], cx)); + let can_trust_b = + trusted_worktrees.update(cx, |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"); + + trusted_worktrees.update(cx, |store, cx| { + store.trust( + HashSet::from_iter([PathTrust::Worktree(worktree_ids[0])]), + None, + cx, + ); + }); + + let can_trust_a = + trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_ids[0], cx)); + let can_trust_b = + trusted_worktrees.update(cx, |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, |store, cx| { + store.trust( + HashSet::from_iter([PathTrust::Worktree(worktree_ids[1])]), + None, + cx, + ); + }); + + let can_trust_a = + trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_ids[0], cx)); + let can_trust_b = + trusted_worktrees.update(cx, |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"); + } + + #[gpui::test] + async fn test_directory_worktree_trust_enables_single_file(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/"), + json!({ + "project": { "main.rs": "fn main() {}" }, + "standalone.rs": "fn standalone() {}" + }), + ) + .await; + + let project = Project::test( + fs, + [path!("/project").as_ref(), path!("/standalone.rs").as_ref()], + cx, + ) + .await; + let worktree_store = project.read_with(cx, |project, _| project.worktree_store()); + let (dir_worktree_id, file_worktree_id) = worktree_store.read_with(cx, |store, cx| { + let worktrees: Vec<_> = store.worktrees().collect(); + assert_eq!(worktrees.len(), 2); + let (dir_worktree, file_worktree) = if worktrees[0].read(cx).is_single_file() { + (&worktrees[1], &worktrees[0]) + } else { + (&worktrees[0], &worktrees[1]) + }; + assert!(!dir_worktree.read(cx).is_single_file()); + assert!(file_worktree.read(cx).is_single_file()); + (dir_worktree.read(cx).id(), file_worktree.read(cx).id()) + }); + + let trusted_worktrees = init_trust_global(worktree_store, cx); + + let can_trust_file = + trusted_worktrees.update(cx, |store, cx| store.can_trust(file_worktree_id, cx)); + assert!( + !can_trust_file, + "single-file worktree should be restricted initially" + ); + + trusted_worktrees.update(cx, |store, cx| { + store.trust( + HashSet::from_iter([PathTrust::Worktree(dir_worktree_id)]), + None, + cx, + ); + }); + + let can_trust_dir = + trusted_worktrees.update(cx, |store, cx| store.can_trust(dir_worktree_id, cx)); + let can_trust_file_after = + trusted_worktrees.update(cx, |store, cx| store.can_trust(file_worktree_id, cx)); + assert!(can_trust_dir, "directory worktree should be trusted"); + assert!( + can_trust_file_after, + "single-file worktree should be trusted after directory worktree trust" + ); + } + + #[gpui::test] + async fn test_abs_path_trust_covers_multiple_worktrees(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/root"), + json!({ + "project_a": { "main.rs": "fn main() {}" }, + "project_b": { "lib.rs": "pub fn lib() {}" } + }), + ) + .await; + + let project = Project::test( + fs, + [ + path!("/root/project_a").as_ref(), + path!("/root/project_b").as_ref(), + ], + cx, + ) + .await; + let worktree_store = project.read_with(cx, |project, _| project.worktree_store()); + let worktree_ids: Vec<_> = worktree_store.read_with(cx, |store, cx| { + store + .worktrees() + .map(|worktree| worktree.read(cx).id()) + .collect() + }); + assert_eq!(worktree_ids.len(), 2); + + let trusted_worktrees = init_trust_global(worktree_store, cx); + + for &worktree_id in &worktree_ids { + let can_trust = + trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx)); + assert!(!can_trust, "worktree should be restricted initially"); + } + + trusted_worktrees.update(cx, |store, cx| { + store.trust( + HashSet::from_iter([PathTrust::AbsPath(PathBuf::from(path!("/root")))]), + None, + cx, + ); + }); + + for &worktree_id in &worktree_ids { + let can_trust = + trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx)); + assert!( + can_trust, + "worktree should be trusted after parent path trust" + ); + } + } + + #[gpui::test] + async fn test_auto_trust_all(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/"), + json!({ + "project_a": { "main.rs": "fn main() {}" }, + "project_b": { "lib.rs": "pub fn lib() {}" }, + "single.rs": "fn single() {}" + }), + ) + .await; + + let project = Project::test( + fs, + [ + path!("/project_a").as_ref(), + path!("/project_b").as_ref(), + path!("/single.rs").as_ref(), + ], + cx, + ) + .await; + let worktree_store = project.read_with(cx, |project, _| project.worktree_store()); + let worktree_ids: Vec<_> = worktree_store.read_with(cx, |store, cx| { + store + .worktrees() + .map(|worktree| worktree.read(cx).id()) + .collect() + }); + assert_eq!(worktree_ids.len(), 3); + + let trusted_worktrees = init_trust_global(worktree_store.clone(), cx); + + let events: Rc>> = Rc::default(); + cx.update({ + let events = events.clone(); + |cx| { + cx.subscribe(&trusted_worktrees, move |_, event, _| { + events.borrow_mut().push(match event { + TrustedWorktreesEvent::Trusted(host, paths) => { + TrustedWorktreesEvent::Trusted(host.clone(), paths.clone()) + } + TrustedWorktreesEvent::Restricted(host, paths) => { + TrustedWorktreesEvent::Restricted(host.clone(), paths.clone()) + } + }); + }) + } + }) + .detach(); + + for &worktree_id in &worktree_ids { + let can_trust = + trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx)); + assert!(!can_trust, "worktree should be restricted initially"); + } + + let has_restricted = trusted_worktrees.read_with(cx, |store, cx| { + store.has_restricted_worktrees(&worktree_store, cx) + }); + assert!(has_restricted, "should have restricted worktrees"); + + events.borrow_mut().clear(); + + trusted_worktrees.update(cx, |store, cx| { + store.auto_trust_all(cx); + }); + + for &worktree_id in &worktree_ids { + let can_trust = + trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx)); + assert!( + can_trust, + "worktree {worktree_id:?} should be trusted after auto_trust_all" + ); + } + + let has_restricted_after = trusted_worktrees.read_with(cx, |store, cx| { + store.has_restricted_worktrees(&worktree_store, cx) + }); + assert!( + !has_restricted_after, + "should have no restricted worktrees after auto_trust_all" + ); + + let trusted_event_count = events + .borrow() + .iter() + .filter(|e| matches!(e, TrustedWorktreesEvent::Trusted(..))) + .count(); + assert!( + trusted_event_count > 0, + "should have emitted Trusted events" + ); + } + + #[gpui::test] + async fn test_trust_restrict_trust_cycle(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root"), json!({ "main.rs": "fn main() {}" })) + .await; + + let project = Project::test(fs, [path!("/root").as_ref()], cx).await; + let worktree_store = project.read_with(cx, |project, _| project.worktree_store()); + let worktree_id = worktree_store.read_with(cx, |store, cx| { + store.worktrees().next().unwrap().read(cx).id() + }); + + let trusted_worktrees = init_trust_global(worktree_store.clone(), cx); + + let events: Rc>> = Rc::default(); + cx.update({ + let events = events.clone(); + |cx| { + cx.subscribe(&trusted_worktrees, move |_, event, _| { + events.borrow_mut().push(match event { + TrustedWorktreesEvent::Trusted(host, paths) => { + TrustedWorktreesEvent::Trusted(host.clone(), paths.clone()) + } + TrustedWorktreesEvent::Restricted(host, paths) => { + TrustedWorktreesEvent::Restricted(host.clone(), paths.clone()) + } + }); + }) + } + }) + .detach(); + + let can_trust = trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx)); + assert!(!can_trust, "should be restricted initially"); + assert_eq!(events.borrow().len(), 1); + events.borrow_mut().clear(); + + trusted_worktrees.update(cx, |store, cx| { + store.trust( + HashSet::from_iter([PathTrust::Worktree(worktree_id)]), + None, + cx, + ); + }); + let can_trust = trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx)); + assert!(can_trust, "should be trusted after trust()"); + assert_eq!(events.borrow().len(), 1); + assert!(matches!( + &events.borrow()[0], + TrustedWorktreesEvent::Trusted(..) + )); + events.borrow_mut().clear(); + + trusted_worktrees.update(cx, |store, cx| { + store.restrict( + HashSet::from_iter([PathTrust::Worktree(worktree_id)]), + None, + cx, + ); + }); + let can_trust = trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx)); + assert!(!can_trust, "should be restricted after restrict()"); + assert_eq!(events.borrow().len(), 1); + assert!(matches!( + &events.borrow()[0], + TrustedWorktreesEvent::Restricted(..) + )); + + let has_restricted = trusted_worktrees.read_with(cx, |store, cx| { + store.has_restricted_worktrees(&worktree_store, cx) + }); + assert!(has_restricted); + events.borrow_mut().clear(); + + trusted_worktrees.update(cx, |store, cx| { + store.trust( + HashSet::from_iter([PathTrust::Worktree(worktree_id)]), + None, + cx, + ); + }); + let can_trust = trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx)); + assert!(can_trust, "should be trusted again after second trust()"); + assert_eq!(events.borrow().len(), 1); + assert!(matches!( + &events.borrow()[0], + TrustedWorktreesEvent::Trusted(..) + )); + + let has_restricted = trusted_worktrees.read_with(cx, |store, cx| { + store.has_restricted_worktrees(&worktree_store, cx) + }); + assert!(!has_restricted); + } + + #[gpui::test] + async fn test_multi_host_trust_isolation(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/"), + json!({ + "local_project": { "main.rs": "fn main() {}" }, + "remote_project": { "lib.rs": "pub fn lib() {}" } + }), + ) + .await; + + let project = Project::test( + fs, + [ + path!("/local_project").as_ref(), + path!("/remote_project").as_ref(), + ], + cx, + ) + .await; + let worktree_store = project.read_with(cx, |project, _| project.worktree_store()); + let worktree_ids: Vec<_> = worktree_store.read_with(cx, |store, cx| { + store + .worktrees() + .map(|worktree| worktree.read(cx).id()) + .collect() + }); + assert_eq!(worktree_ids.len(), 2); + let local_worktree = worktree_ids[0]; + let _remote_worktree = worktree_ids[1]; + + let trusted_worktrees = init_trust_global(worktree_store, cx); + + let host_a: Option = None; + + let can_trust_local = + trusted_worktrees.update(cx, |store, cx| store.can_trust(local_worktree, cx)); + assert!(!can_trust_local, "local worktree restricted on host_a"); + + trusted_worktrees.update(cx, |store, cx| { + store.trust( + HashSet::from_iter([PathTrust::Worktree(local_worktree)]), + host_a.clone(), + cx, + ); + }); + + let can_trust_local_after = + trusted_worktrees.update(cx, |store, cx| store.can_trust(local_worktree, cx)); + assert!( + can_trust_local_after, + "local worktree should be trusted on host_a" + ); + } +} diff --git a/crates/project_benchmarks/src/main.rs b/crates/project_benchmarks/src/main.rs index 738d0d0f2240f566f77f98a07df4a9ac587e10b4..e4ddbb6cf2c7b6984df2533963bdf6bf88eacba0 100644 --- a/crates/project_benchmarks/src/main.rs +++ b/crates/project_benchmarks/src/main.rs @@ -73,6 +73,7 @@ fn main() -> Result<(), anyhow::Error> { registry, fs, Some(Default::default()), + false, cx, ); diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index ea667ecbb479ca347914ee11ec789a14f29cf474..00aba96ef428eea643e8868e513ab9c3aaa1b910 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -880,7 +880,7 @@ impl ProjectPanel { }); if !focus_opened_item { let focus_handle = project_panel.read(cx).focus_handle.clone(); - window.focus(&focus_handle); + window.focus(&focus_handle, cx); } } } @@ -1169,7 +1169,7 @@ impl ProjectPanel { }) }); - window.focus(&context_menu.focus_handle(cx)); + window.focus(&context_menu.focus_handle(cx), cx); let subscription = cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| { this.context_menu.take(); cx.notify(); @@ -1376,7 +1376,7 @@ impl ProjectPanel { } }); self.update_visible_entries(Some((worktree_id, entry_id)), false, false, window, cx); - window.focus(&self.focus_handle); + window.focus(&self.focus_handle, cx); cx.notify(); } } @@ -1399,7 +1399,7 @@ impl ProjectPanel { } } self.update_visible_entries(Some((worktree_id, entry_id)), false, false, window, cx); - window.focus(&self.focus_handle); + window.focus(&self.focus_handle, cx); cx.notify(); } } @@ -1719,7 +1719,7 @@ impl ProjectPanel { }; if let Some(existing) = worktree.read(cx).entry_for_path(&new_path) { if existing.id == entry.id && refocus { - window.focus(&self.focus_handle); + window.focus(&self.focus_handle, cx); } return None; } @@ -1730,7 +1730,7 @@ impl ProjectPanel { }; if refocus { - window.focus(&self.focus_handle); + window.focus(&self.focus_handle, cx); } edit_state.processing_filename = Some(filename); cx.notify(); @@ -1839,7 +1839,7 @@ impl ProjectPanel { self.autoscroll(cx); } - window.focus(&self.focus_handle); + window.focus(&self.focus_handle, cx); cx.notify(); } @@ -3616,7 +3616,7 @@ impl ProjectPanel { if this.update_visible_entries_task.focus_filename_editor { this.update_visible_entries_task.focus_filename_editor = false; this.filename_editor.update(cx, |editor, cx| { - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); }); } if this.update_visible_entries_task.autoscroll { @@ -5952,7 +5952,7 @@ impl Render for ProjectPanel { cx.stop_propagation(); this.state.selection = None; this.marked_entries.clear(); - this.focus_handle(cx).focus(window); + this.focus_handle(cx).focus(window, cx); })) .on_mouse_down( MouseButton::Right, diff --git a/crates/prompt_store/src/prompt_store.rs b/crates/prompt_store/src/prompt_store.rs index fb087ce34d6d67fe4ea11a33f554307ed558c18a..7823f7a6957caf282f4ad7f1d6f884971364518e 100644 --- a/crates/prompt_store/src/prompt_store.rs +++ b/crates/prompt_store/src/prompt_store.rs @@ -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 { + 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,8 @@ 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(); + metadata.delete(&mut txn, &PromptId::CommitMessage)?; + bodies.delete(&mut txn, &PromptId::CommitMessage)?; txn.commit()?; @@ -387,8 +408,8 @@ impl PromptStore { body: Rope, cx: &Context, ) -> Task> { - 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 +451,7 @@ impl PromptStore { ) -> Task> { let mut cache = self.metadata_cache.write(); - if id.is_built_in() { + if !id.can_edit() { title = cache .metadata_by_id .get(&id) diff --git a/crates/proto/proto/worktree.proto b/crates/proto/proto/worktree.proto index 9ab9e95438d220834351308ea83ffe9a18dec999..5873cfc10c1c6af24520705c27781b916dfda3d0 100644 --- a/crates/proto/proto/worktree.proto +++ b/crates/proto/proto/worktree.proto @@ -158,3 +158,24 @@ 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 worktree_id = 2; + string abs_path = 3; + } + + reserved 1; +} + +message RestrictWorktrees { + uint64 project_id = 1; + repeated uint64 worktree_ids = 3; + + reserved 2; +} diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index 8e26a26a43ff8af5c1b676f5dc7f8fe49e67e19f..b781a06155698505eaeb0a1d19eaaba3e7d3c08d 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -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; diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index 455f94704663dcd96e37487b1a4243850634c18e..840118b0c9d17e3c1889b8138ae70a639930f28e 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -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!( diff --git a/crates/recent_projects/src/remote_connections.rs b/crates/recent_projects/src/remote_connections.rs index c0a655d19e513c838275d3e4f3beadaabcc8fef6..1bab31b4d0ebb80444c40c99feb984ebd23feb60 100644 --- a/crates/recent_projects/src/remote_connections.rs +++ b/crates/recent_projects/src/remote_connections.rs @@ -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, @@ -51,7 +52,7 @@ impl SshSettings { pub fn fill_connection_options_from_settings(&self, options: &mut SshConnectionOptions) { for conn in self.ssh_connections() { - if conn.host == options.host + if conn.host == options.host.to_string() && conn.username == options.username && conn.port == options.port { @@ -71,7 +72,7 @@ impl SshSettings { username: Option, ) -> SshConnectionOptions { let mut options = SshConnectionOptions { - host, + host: host.into(), port, username, ..Default::default() @@ -208,7 +209,7 @@ impl RemoteConnectionPrompt { let markdown = cx.new(|cx| Markdown::new_text(prompt.into(), cx)); self.prompt = Some((markdown, tx)); self.status_message.take(); - window.focus(&self.editor.focus_handle(cx)); + window.focus(&self.editor.focus_handle(cx), cx); cx.notify(); } @@ -532,8 +533,8 @@ impl remote::RemoteClientDelegate for RemoteClientDelegate { AutoUpdater::download_remote_server_release( release_channel, version.clone(), - platform.os, - platform.arch, + platform.os.as_str(), + platform.arch.as_str(), move |status, cx| this.set_status(Some(status), cx), cx, ) @@ -563,8 +564,8 @@ impl remote::RemoteClientDelegate for RemoteClientDelegate { AutoUpdater::get_remote_server_release_url( release_channel, version, - platform.os, - platform.arch, + platform.os.as_str(), + platform.arch.as_str(), cx, ) .await @@ -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) => { diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index c960a2b1a9af9e11730240c24483a673b77e0fb5..15735b6664e4b72749b0149013d02428eb2735de 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -76,7 +76,7 @@ impl CreateRemoteServer { fn new(window: &mut Window, cx: &mut App) -> Self { let address_editor = cx.new(|cx| Editor::single_line(window, cx)); address_editor.update(cx, |this, cx| { - this.focus_handle(cx).focus(window); + this.focus_handle(cx).focus(window, cx); }); Self { address_editor, @@ -107,7 +107,7 @@ struct CreateRemoteDevContainer { impl CreateRemoteDevContainer { fn new(window: &mut Window, cx: &mut Context) -> Self { let entries = std::array::from_fn(|_| NavigableEntry::focusable(cx)); - entries[0].focus_handle.focus(window); + entries[0].focus_handle.focus(window, cx); Self { entries, progress: DevContainerCreationProgress::Initial, @@ -199,7 +199,7 @@ impl EditNicknameState { this.set_text(starting_text, window, cx); } }); - this.editor.focus_handle(cx).focus(window); + this.editor.focus_handle(cx).focus(window, cx); this } } @@ -792,7 +792,7 @@ impl RemoteServerProjects { this.retained_connections.push(client); this.add_ssh_server(connection_options, cx); this.mode = Mode::default_mode(&this.ssh_config_servers, cx); - this.focus_handle(cx).focus(window); + this.focus_handle(cx).focus(window, cx); cx.notify() }) .log_err(), @@ -875,7 +875,7 @@ impl RemoteServerProjects { crate::add_wsl_distro(fs, &connection_options, cx); this.mode = Mode::default_mode(&BTreeSet::new(), cx); - this.focus_handle(cx).focus(window); + this.focus_handle(cx).focus(window, cx); cx.notify(); }), _ => this.update(cx, |this, cx| { @@ -924,7 +924,7 @@ impl RemoteServerProjects { return; } }); - self.focus_handle(cx).focus(window); + self.focus_handle(cx).focus(window, cx); cx.notify(); } @@ -933,7 +933,7 @@ impl RemoteServerProjects { CreateRemoteDevContainer::new(window, cx) .progress(DevContainerCreationProgress::Creating), ); - self.focus_handle(cx).focus(window); + self.focus_handle(cx).focus(window, cx); cx.notify(); } @@ -1000,6 +1000,7 @@ impl RemoteServerProjects { app_state.user_store.clone(), app_state.languages.clone(), app_state.fs.clone(), + true, cx, ), ) @@ -1067,7 +1068,7 @@ impl RemoteServerProjects { } }); self.mode = Mode::default_mode(&self.ssh_config_servers, cx); - self.focus_handle.focus(window); + self.focus_handle.focus(window, cx); } #[cfg(target_os = "windows")] Mode::AddWslDistro(state) => { @@ -1093,7 +1094,7 @@ impl RemoteServerProjects { } _ => { self.mode = Mode::default_mode(&self.ssh_config_servers, cx); - self.focus_handle(cx).focus(window); + self.focus_handle(cx).focus(window, cx); cx.notify(); } } @@ -1517,7 +1518,7 @@ impl RemoteServerProjects { .ssh_connections .get_or_insert(Default::default()) .push(SshConnection { - host: SharedString::from(connection_options.host), + host: SharedString::from(connection_options.host.to_string()), username: connection_options.username, port: connection_options.port, projects: BTreeSet::new(), @@ -1639,7 +1640,7 @@ impl RemoteServerProjects { ) -> impl IntoElement { match &state.progress { DevContainerCreationProgress::Error(message) => { - self.focus_handle(cx).focus(window); + self.focus_handle(cx).focus(window, cx); return div() .track_focus(&self.focus_handle(cx)) .size_full() @@ -1951,7 +1952,7 @@ impl RemoteServerProjects { let connection_prompt = state.connection_prompt.clone(); state.picker.update(cx, |picker, cx| { - picker.focus_handle(cx).focus(window); + picker.focus_handle(cx).focus(window, cx); }); v_flex() @@ -1982,7 +1983,7 @@ impl RemoteServerProjects { .size_full() .child(match &options { ViewServerOptionsState::Ssh { connection, .. } => SshConnectionHeader { - connection_string: connection.host.clone().into(), + connection_string: connection.host.to_string().into(), paths: Default::default(), nickname: connection.nickname.clone().map(|s| s.into()), is_wsl: false, @@ -2147,7 +2148,7 @@ impl RemoteServerProjects { window: &mut Window, cx: &mut Context, ) -> impl IntoElement { - let connection_string = SharedString::new(connection.host.clone()); + let connection_string = SharedString::new(connection.host.to_string()); v_flex() .child({ @@ -2658,7 +2659,7 @@ impl RemoteServerProjects { self.add_ssh_server( SshConnectionOptions { - host: ssh_config_host.to_string(), + host: ssh_config_host.to_string().into(), ..SshConnectionOptions::default() }, cx, @@ -2751,7 +2752,7 @@ impl Render for RemoteServerProjects { .on_action(cx.listener(Self::cancel)) .on_action(cx.listener(Self::confirm)) .capture_any_mouse_down(cx.listener(|this, _, window, cx| { - this.focus_handle(cx).focus(window); + this.focus_handle(cx).focus(window, cx); })) .on_mouse_down_out(cx.listener(|this, _, _, cx| { if matches!(this.mode, Mode::Default(_)) { diff --git a/crates/remote/src/remote.rs b/crates/remote/src/remote.rs index 51b71c988a6dc57e875b3baa28103bef0d8fd729..2db918ecce331acac91bb974df1b784f0d6532b3 100644 --- a/crates/remote/src/remote.rs +++ b/crates/remote/src/remote.rs @@ -7,8 +7,9 @@ mod transport; #[cfg(target_os = "windows")] pub use remote_client::OpenWslPath; pub use remote_client::{ - ConnectionIdentifier, ConnectionState, RemoteClient, RemoteClientDelegate, RemoteClientEvent, - RemoteConnection, RemoteConnectionOptions, RemotePlatform, connect, + ConnectionIdentifier, ConnectionState, RemoteArch, RemoteClient, RemoteClientDelegate, + RemoteClientEvent, RemoteConnection, RemoteConnectionOptions, RemoteOs, RemotePlatform, + connect, }; pub use transport::docker::DockerConnectionOptions; pub use transport::ssh::{SshConnectionOptions, SshPortForwardOption}; diff --git a/crates/remote/src/remote_client.rs b/crates/remote/src/remote_client.rs index e8fa4fe4a3e727e823fc5912ddf3e940adf0f78f..79bdbe540d070bfa18a6417622b386458ff221a8 100644 --- a/crates/remote/src/remote_client.rs +++ b/crates/remote/src/remote_client.rs @@ -49,10 +49,58 @@ use util::{ paths::{PathStyle, RemotePathBuf}, }; +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum RemoteOs { + Linux, + MacOs, + Windows, +} + +impl RemoteOs { + pub fn as_str(&self) -> &'static str { + match self { + RemoteOs::Linux => "linux", + RemoteOs::MacOs => "macos", + RemoteOs::Windows => "windows", + } + } + + pub fn is_windows(&self) -> bool { + matches!(self, RemoteOs::Windows) + } +} + +impl std::fmt::Display for RemoteOs { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum RemoteArch { + X86_64, + Aarch64, +} + +impl RemoteArch { + pub fn as_str(&self) -> &'static str { + match self { + RemoteArch::X86_64 => "x86_64", + RemoteArch::Aarch64 => "aarch64", + } + } +} + +impl std::fmt::Display for RemoteArch { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + #[derive(Copy, Clone, Debug)] pub struct RemotePlatform { - pub os: &'static str, - pub arch: &'static str, + pub os: RemoteOs, + pub arch: RemoteArch, } #[derive(Clone, Debug)] @@ -89,7 +137,8 @@ pub trait RemoteClientDelegate: Send + Sync { const MAX_MISSED_HEARTBEATS: usize = 5; const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5); const HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(5); -const INITIAL_CONNECTION_TIMEOUT: Duration = Duration::from_secs(60); +const INITIAL_CONNECTION_TIMEOUT: Duration = + Duration::from_secs(if cfg!(debug_assertions) { 5 } else { 60 }); const MAX_RECONNECT_ATTEMPTS: usize = 3; @@ -921,10 +970,12 @@ impl RemoteClient { client_cx: &mut gpui::TestAppContext, server_cx: &mut gpui::TestAppContext, ) -> (RemoteConnectionOptions, AnyProtoClient) { + use crate::transport::ssh::SshConnectionHost; + let port = client_cx .update(|cx| cx.default_global::().connections.len() as u16 + 1); let opts = RemoteConnectionOptions::Ssh(SshConnectionOptions { - host: "".to_string(), + host: SshConnectionHost::from("".to_string()), port: Some(port), ..Default::default() }); @@ -1089,7 +1140,7 @@ pub enum RemoteConnectionOptions { impl RemoteConnectionOptions { pub fn display_name(&self) -> String { match self { - RemoteConnectionOptions::Ssh(opts) => opts.host.clone(), + RemoteConnectionOptions::Ssh(opts) => opts.host.to_string(), RemoteConnectionOptions::Wsl(opts) => opts.distro_name.clone(), RemoteConnectionOptions::Docker(opts) => opts.name.clone(), } diff --git a/crates/remote/src/transport.rs b/crates/remote/src/transport.rs index 4cafbf60eec338addbb43e46d156960621301ab0..ebf643352fce8a14d88b7c870b177d2c6b7e7de0 100644 --- a/crates/remote/src/transport.rs +++ b/crates/remote/src/transport.rs @@ -1,5 +1,5 @@ use crate::{ - RemotePlatform, + RemoteArch, RemoteOs, RemotePlatform, json_log::LogRecord, protocol::{MESSAGE_LEN_SIZE, message_len_from_buffer, read_message_with_len, write_message}, }; @@ -26,8 +26,8 @@ fn parse_platform(output: &str) -> Result { }; let os = match os { - "Darwin" => "macos", - "Linux" => "linux", + "Darwin" => RemoteOs::MacOs, + "Linux" => RemoteOs::Linux, _ => anyhow::bail!( "Prebuilt remote servers are not yet available for {os:?}. See https://zed.dev/docs/remote-development" ), @@ -39,9 +39,9 @@ fn parse_platform(output: &str) -> Result { || arch.starts_with("arm64") || arch.starts_with("aarch64") { - "aarch64" + RemoteArch::Aarch64 } else if arch.starts_with("x86") { - "x86_64" + RemoteArch::X86_64 } else { anyhow::bail!( "Prebuilt remote servers are not yet available for {arch:?}. See https://zed.dev/docs/remote-development" @@ -193,7 +193,8 @@ async fn build_remote_server_from_source( .await?; anyhow::ensure!( output.status.success(), - "Failed to run command: {command:?}" + "Failed to run command: {command:?}: output: {}", + String::from_utf8_lossy(&output.stderr) ); Ok(()) } @@ -203,14 +204,15 @@ async fn build_remote_server_from_source( "{}-{}", platform.arch, match platform.os { - "linux" => + RemoteOs::Linux => if use_musl { "unknown-linux-musl" } else { "unknown-linux-gnu" }, - "macos" => "apple-darwin", - _ => anyhow::bail!("can't cross compile for: {:?}", platform), + RemoteOs::MacOs => "apple-darwin", + RemoteOs::Windows if cfg!(windows) => "pc-windows-msvc", + RemoteOs::Windows => "pc-windows-gnu", } ); let mut rust_flags = match std::env::var("RUSTFLAGS") { @@ -221,7 +223,7 @@ async fn build_remote_server_from_source( String::new() } }; - if platform.os == "linux" && use_musl { + if platform.os == RemoteOs::Linux && use_musl { rust_flags.push_str(" -C target-feature=+crt-static"); if let Ok(path) = std::env::var("ZED_ZSTD_MUSL_LIB") { @@ -232,7 +234,9 @@ async fn build_remote_server_from_source( rust_flags.push_str(" -C link-arg=-fuse-ld=mold"); } - if platform.arch == std::env::consts::ARCH && platform.os == std::env::consts::OS { + if platform.arch.as_str() == std::env::consts::ARCH + && platform.os.as_str() == std::env::consts::OS + { delegate.set_status(Some("Building remote server binary from source"), cx); log::info!("building remote server binary from source"); run_cmd( @@ -308,7 +312,8 @@ async fn build_remote_server_from_source( .join("remote_server") .join(&triple) .join("debug") - .join("remote_server"); + .join("remote_server") + .with_extension(if platform.os.is_windows() { "exe" } else { "" }); let path = if !build_remote_server.contains("nocompress") { delegate.set_status(Some("Compressing binary"), cx); @@ -374,35 +379,44 @@ mod tests { #[test] fn test_parse_platform() { let result = parse_platform("Linux x86_64\n").unwrap(); - assert_eq!(result.os, "linux"); - assert_eq!(result.arch, "x86_64"); + assert_eq!(result.os, RemoteOs::Linux); + assert_eq!(result.arch, RemoteArch::X86_64); let result = parse_platform("Darwin arm64\n").unwrap(); - assert_eq!(result.os, "macos"); - assert_eq!(result.arch, "aarch64"); + assert_eq!(result.os, RemoteOs::MacOs); + assert_eq!(result.arch, RemoteArch::Aarch64); let result = parse_platform("Linux x86_64").unwrap(); - assert_eq!(result.os, "linux"); - assert_eq!(result.arch, "x86_64"); + assert_eq!(result.os, RemoteOs::Linux); + assert_eq!(result.arch, RemoteArch::X86_64); let result = parse_platform("some shell init output\nLinux aarch64\n").unwrap(); - assert_eq!(result.os, "linux"); - assert_eq!(result.arch, "aarch64"); + assert_eq!(result.os, RemoteOs::Linux); + assert_eq!(result.arch, RemoteArch::Aarch64); let result = parse_platform("some shell init output\nLinux aarch64").unwrap(); - assert_eq!(result.os, "linux"); - assert_eq!(result.arch, "aarch64"); + assert_eq!(result.os, RemoteOs::Linux); + assert_eq!(result.arch, RemoteArch::Aarch64); - assert_eq!(parse_platform("Linux armv8l\n").unwrap().arch, "aarch64"); - assert_eq!(parse_platform("Linux aarch64\n").unwrap().arch, "aarch64"); - assert_eq!(parse_platform("Linux x86_64\n").unwrap().arch, "x86_64"); + assert_eq!( + parse_platform("Linux armv8l\n").unwrap().arch, + RemoteArch::Aarch64 + ); + assert_eq!( + parse_platform("Linux aarch64\n").unwrap().arch, + RemoteArch::Aarch64 + ); + assert_eq!( + parse_platform("Linux x86_64\n").unwrap().arch, + RemoteArch::X86_64 + ); let result = parse_platform( r#"Linux x86_64 - What you're referring to as Linux, is in fact, GNU/Linux...\n"#, ) .unwrap(); - assert_eq!(result.os, "linux"); - assert_eq!(result.arch, "x86_64"); + assert_eq!(result.os, RemoteOs::Linux); + assert_eq!(result.arch, RemoteArch::X86_64); assert!(parse_platform("Windows x86_64\n").is_err()); assert!(parse_platform("Linux armv7l\n").is_err()); diff --git a/crates/remote/src/transport/docker.rs b/crates/remote/src/transport/docker.rs index 09f5935ec621260e933f11f46aa57493a31ace6d..9c14aa874941a5cdcd824d4adaeb41d694e347d8 100644 --- a/crates/remote/src/transport/docker.rs +++ b/crates/remote/src/transport/docker.rs @@ -24,8 +24,8 @@ use gpui::{App, AppContext, AsyncApp, Task}; use rpc::proto::Envelope; use crate::{ - RemoteClientDelegate, RemoteConnection, RemoteConnectionOptions, RemotePlatform, - remote_client::CommandTemplate, + RemoteArch, RemoteClientDelegate, RemoteConnection, RemoteConnectionOptions, RemoteOs, + RemotePlatform, remote_client::CommandTemplate, }; #[derive(Debug, Default, Clone, PartialEq, Eq, Hash)] @@ -70,7 +70,7 @@ impl DockerExecConnection { let remote_platform = this.check_remote_platform().await?; this.path_style = match remote_platform.os { - "windows" => Some(PathStyle::Windows), + RemoteOs::Windows => Some(PathStyle::Windows), _ => Some(PathStyle::Posix), }; @@ -124,8 +124,8 @@ impl DockerExecConnection { }; let os = match os.trim() { - "Darwin" => "macos", - "Linux" => "linux", + "Darwin" => RemoteOs::MacOs, + "Linux" => RemoteOs::Linux, _ => anyhow::bail!( "Prebuilt remote servers are not yet available for {os:?}. See https://zed.dev/docs/remote-development" ), @@ -136,9 +136,9 @@ impl DockerExecConnection { || arch.starts_with("arm64") || arch.starts_with("aarch64") { - "aarch64" + RemoteArch::Aarch64 } else if arch.starts_with("x86") { - "x86_64" + RemoteArch::X86_64 } else { anyhow::bail!( "Prebuilt remote servers are not yet available for {arch:?}. See https://zed.dev/docs/remote-development" diff --git a/crates/remote/src/transport/ssh.rs b/crates/remote/src/transport/ssh.rs index c445c0565837d33dc044087fc53e6573e06ee54c..6c8eb49c1c2158322a275e064162b53e2f5f3d5e 100644 --- a/crates/remote/src/transport/ssh.rs +++ b/crates/remote/src/transport/ssh.rs @@ -1,5 +1,5 @@ use crate::{ - RemoteClientDelegate, RemotePlatform, + RemoteArch, RemoteClientDelegate, RemoteOs, RemotePlatform, remote_client::{CommandTemplate, RemoteConnection, RemoteConnectionOptions}, transport::{parse_platform, parse_shell}, }; @@ -23,6 +23,7 @@ use smol::{ process::{self, Child, Stdio}, }; use std::{ + net::IpAddr, path::{Path, PathBuf}, sync::Arc, time::Instant, @@ -47,9 +48,58 @@ pub(crate) struct SshRemoteConnection { _temp_dir: TempDir, } +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum SshConnectionHost { + IpAddr(IpAddr), + Hostname(String), +} + +impl SshConnectionHost { + pub fn to_bracketed_string(&self) -> String { + match self { + Self::IpAddr(IpAddr::V4(ip)) => ip.to_string(), + Self::IpAddr(IpAddr::V6(ip)) => format!("[{}]", ip), + Self::Hostname(hostname) => hostname.clone(), + } + } + + pub fn to_string(&self) -> String { + match self { + Self::IpAddr(ip) => ip.to_string(), + Self::Hostname(hostname) => hostname.clone(), + } + } +} + +impl From<&str> for SshConnectionHost { + fn from(value: &str) -> Self { + if let Ok(address) = value.parse() { + Self::IpAddr(address) + } else { + Self::Hostname(value.to_string()) + } + } +} + +impl From for SshConnectionHost { + fn from(value: String) -> Self { + if let Ok(address) = value.parse() { + Self::IpAddr(address) + } else { + Self::Hostname(value) + } + } +} + +impl Default for SshConnectionHost { + fn default() -> Self { + Self::Hostname(Default::default()) + } +} + #[derive(Debug, Default, Clone, PartialEq, Eq, Hash)] pub struct SshConnectionOptions { - pub host: String, + pub host: SshConnectionHost, pub username: Option, pub port: Option, pub password: Option, @@ -64,7 +114,7 @@ pub struct SshConnectionOptions { impl From for SshConnectionOptions { fn from(val: settings::SshConnection) -> Self { SshConnectionOptions { - host: val.host.into(), + host: val.host.to_string().into(), username: val.username, port: val.port, password: None, @@ -96,7 +146,7 @@ impl MasterProcess { askpass_script_path: &std::ffi::OsStr, additional_args: Vec, socket_path: &std::path::Path, - url: &str, + destination: &str, ) -> Result { let args = [ "-N", @@ -120,7 +170,7 @@ impl MasterProcess { master_process.arg(format!("ControlPath={}", socket_path.display())); - let process = master_process.arg(&url).spawn()?; + let process = master_process.arg(&destination).spawn()?; Ok(MasterProcess { process }) } @@ -143,7 +193,7 @@ impl MasterProcess { pub fn new( askpass_script_path: &std::ffi::OsStr, additional_args: Vec, - url: &str, + destination: &str, ) -> Result { // On Windows, `ControlMaster` and `ControlPath` are not supported: // https://github.com/PowerShell/Win32-OpenSSH/issues/405 @@ -165,7 +215,7 @@ impl MasterProcess { .env("SSH_ASKPASS_REQUIRE", "force") .env("SSH_ASKPASS", askpass_script_path) .args(additional_args) - .arg(url) + .arg(destination) .args(args); let process = master_process.spawn()?; @@ -352,30 +402,50 @@ impl RemoteConnection for SshRemoteConnection { delegate: Arc, cx: &mut AsyncApp, ) -> Task> { + const VARS: [&str; 3] = ["RUST_LOG", "RUST_BACKTRACE", "ZED_GENERATE_MINIDUMPS"]; delegate.set_status(Some("Starting proxy"), cx); let Some(remote_binary_path) = self.remote_binary_path.clone() else { return Task::ready(Err(anyhow!("Remote binary path not set"))); }; - let mut proxy_args = vec![]; - for env_var in ["RUST_LOG", "RUST_BACKTRACE", "ZED_GENERATE_MINIDUMPS"] { - if let Some(value) = std::env::var(env_var).ok() { - proxy_args.push(format!("{}='{}'", env_var, value)); + let mut ssh_command = if self.ssh_platform.os.is_windows() { + // TODO: Set the `VARS` environment variables, we do not have `env` on windows + // so this needs a different approach + let mut proxy_args = vec![]; + proxy_args.push("proxy".to_owned()); + proxy_args.push("--identifier".to_owned()); + proxy_args.push(unique_identifier); + + if reconnect { + proxy_args.push("--reconnect".to_owned()); } - } - proxy_args.push(remote_binary_path.display(self.path_style()).into_owned()); - proxy_args.push("proxy".to_owned()); - proxy_args.push("--identifier".to_owned()); - proxy_args.push(unique_identifier); + self.socket.ssh_command( + self.ssh_shell_kind, + &remote_binary_path.display(self.path_style()), + &proxy_args, + false, + ) + } else { + let mut proxy_args = vec![]; + for env_var in VARS { + if let Some(value) = std::env::var(env_var).ok() { + proxy_args.push(format!("{}='{}'", env_var, value)); + } + } + proxy_args.push(remote_binary_path.display(self.path_style()).into_owned()); + proxy_args.push("proxy".to_owned()); + proxy_args.push("--identifier".to_owned()); + proxy_args.push(unique_identifier); - if reconnect { - proxy_args.push("--reconnect".to_owned()); - } + if reconnect { + proxy_args.push("--reconnect".to_owned()); + } + self.socket + .ssh_command(self.ssh_shell_kind, "env", &proxy_args, false) + }; - let ssh_proxy_process = match self - .socket - .ssh_command(self.ssh_shell_kind, "env", &proxy_args, false) + let ssh_proxy_process = match ssh_command // IMPORTANT: we kill this process when we drop the task that uses it. .kill_on_drop(true) .spawn() @@ -412,7 +482,7 @@ impl SshRemoteConnection { ) -> Result { use askpass::AskPassResult; - let url = connection_options.ssh_url(); + let destination = connection_options.ssh_destination(); let temp_dir = tempfile::Builder::new() .prefix("zed-ssh-session") @@ -437,14 +507,14 @@ impl SshRemoteConnection { let mut master_process = MasterProcess::new( askpass.script_path().as_ref(), connection_options.additional_args(), - &url, + &destination, )?; #[cfg(not(target_os = "windows"))] let mut master_process = MasterProcess::new( askpass.script_path().as_ref(), connection_options.additional_args(), &socket_path, - &url, + &destination, )?; let result = select_biased! { @@ -495,22 +565,20 @@ impl SshRemoteConnection { .await?; drop(askpass); - let ssh_shell = socket.shell().await; + let is_windows = socket.probe_is_windows().await; + log::info!("Remote is windows: {}", is_windows); + + let ssh_shell = socket.shell(is_windows).await; log::info!("Remote shell discovered: {}", ssh_shell); - let ssh_platform = socket.platform(ShellKind::new(&ssh_shell, false)).await?; + + let ssh_shell_kind = ShellKind::new(&ssh_shell, is_windows); + let ssh_platform = socket.platform(ssh_shell_kind, is_windows).await?; log::info!("Remote platform discovered: {:?}", ssh_platform); - let ssh_path_style = match ssh_platform.os { - "windows" => PathStyle::Windows, - _ => PathStyle::Posix, + + let (ssh_path_style, ssh_default_system_shell) = match ssh_platform.os { + RemoteOs::Windows => (PathStyle::Windows, ssh_shell.clone()), + _ => (PathStyle::Posix, String::from("/bin/sh")), }; - let ssh_default_system_shell = String::from("/bin/sh"); - let ssh_shell_kind = ShellKind::new( - &ssh_shell, - match ssh_platform.os { - "windows" => true, - _ => false, - }, - ); let mut this = Self { socket, @@ -546,9 +614,14 @@ impl SshRemoteConnection { _ => version.to_string(), }; let binary_name = format!( - "zed-remote-server-{}-{}", + "zed-remote-server-{}-{}{}", release_channel.dev_name(), - version_str + version_str, + if self.ssh_platform.os.is_windows() { + ".exe" + } else { + "" + } ); let dst_path = paths::remote_server_dir_relative().join(RelPath::unix(&binary_name).unwrap()); @@ -660,14 +733,19 @@ impl SshRemoteConnection { cx: &mut AsyncApp, ) -> Result<()> { if let Some(parent) = tmp_path_gz.parent() { - self.socket + let res = self + .socket .run_command( self.ssh_shell_kind, "mkdir", &["-p", parent.display(self.path_style()).as_ref()], true, ) - .await?; + .await; + if !self.ssh_platform.os.is_windows() { + // mkdir fails on windows if the path already exists ... + res?; + } } delegate.set_status(Some("Downloading remote development server on host"), cx); @@ -755,17 +833,24 @@ impl SshRemoteConnection { cx: &mut AsyncApp, ) -> Result<()> { if let Some(parent) = tmp_path_gz.parent() { - self.socket + let res = self + .socket .run_command( self.ssh_shell_kind, "mkdir", &["-p", parent.display(self.path_style()).as_ref()], true, ) - .await?; + .await; + if !self.ssh_platform.os.is_windows() { + // mkdir fails on windows if the path already exists ... + res?; + } } - let src_stat = fs::metadata(&src_path).await?; + let src_stat = fs::metadata(&src_path) + .await + .with_context(|| format!("failed to get metadata for {:?}", src_path))?; let size = src_stat.len(); let t0 = Instant::now(); @@ -816,7 +901,7 @@ impl SshRemoteConnection { }; let args = shell_kind.args_for_shell(false, script.to_string()); self.socket - .run_command(shell_kind, "sh", &args, true) + .run_command(self.ssh_shell_kind, "sh", &args, true) .await?; Ok(()) } @@ -840,7 +925,7 @@ impl SshRemoteConnection { } command.arg(src_path).arg(format!( "{}:{}", - self.socket.connection_options.scp_url(), + self.socket.connection_options.scp_destination(), dest_path_str )); command @@ -856,7 +941,7 @@ impl SshRemoteConnection { .unwrap_or_default(), ); command.arg("-b").arg("-"); - command.arg(self.socket.connection_options.scp_url()); + command.arg(self.socket.connection_options.scp_destination()); command.stdin(Stdio::piped()); command } @@ -986,7 +1071,7 @@ impl SshSocket { let separator = shell_kind.sequential_commands_separator(); let to_run = format!("cd{separator} {to_run}"); self.ssh_options(&mut command, true) - .arg(self.connection_options.ssh_url()); + .arg(self.connection_options.ssh_destination()); if !allow_pseudo_tty { command.arg("-T"); } @@ -1004,6 +1089,7 @@ impl SshSocket { ) -> Result { let mut command = self.ssh_command(shell_kind, program, args, allow_pseudo_tty); let output = command.output().await?; + log::debug!("{:?}: {:?}", command, output); anyhow::ensure!( output.status.success(), "failed to run command {command:?}: {}", @@ -1063,7 +1149,7 @@ impl SshSocket { "ControlMaster=no".to_string(), "-o".to_string(), format!("ControlPath={}", self.socket_path.display()), - self.connection_options.ssh_url(), + self.connection_options.ssh_destination(), ]); arguments } @@ -1071,16 +1157,75 @@ impl SshSocket { #[cfg(target_os = "windows")] fn ssh_args(&self) -> Vec { let mut arguments = self.connection_options.additional_args(); - arguments.push(self.connection_options.ssh_url()); + arguments.push(self.connection_options.ssh_destination()); arguments } - async fn platform(&self, shell: ShellKind) -> Result { - let output = self.run_command(shell, "uname", &["-sm"], false).await?; + async fn platform(&self, shell: ShellKind, is_windows: bool) -> Result { + if is_windows { + self.platform_windows(shell).await + } else { + self.platform_posix(shell).await + } + } + + async fn platform_posix(&self, shell: ShellKind) -> Result { + let output = self + .run_command(shell, "uname", &["-sm"], false) + .await + .context("Failed to run 'uname -sm' to determine platform")?; parse_platform(&output) } - async fn shell(&self) -> String { + async fn platform_windows(&self, shell: ShellKind) -> Result { + let output = self + .run_command( + shell, + "cmd", + &["/c", "echo", "%PROCESSOR_ARCHITECTURE%"], + false, + ) + .await + .context( + "Failed to run 'echo %PROCESSOR_ARCHITECTURE%' to determine Windows architecture", + )?; + + Ok(RemotePlatform { + os: RemoteOs::Windows, + arch: match output.trim() { + "AMD64" => RemoteArch::X86_64, + "ARM64" => RemoteArch::Aarch64, + arch => anyhow::bail!( + "Prebuilt remote servers are not yet available for windows-{arch}. See https://zed.dev/docs/remote-development" + ), + }, + }) + } + + /// Probes whether the remote host is running Windows. + /// + /// This is done by attempting to run a simple Windows-specific command. + /// If it succeeds and returns Windows-like output, we assume it's Windows. + async fn probe_is_windows(&self) -> bool { + match self + .run_command(ShellKind::PowerShell, "cmd", &["/c", "ver"], false) + .await + { + // Windows 'ver' command outputs something like "Microsoft Windows [Version 10.0.19045.5011]" + Ok(output) => output.trim().contains("indows"), + Err(_) => false, + } + } + + async fn shell(&self, is_windows: bool) -> String { + if is_windows { + self.shell_windows().await + } else { + self.shell_posix().await + } + } + + async fn shell_posix(&self) -> String { const DEFAULT_SHELL: &str = "sh"; match self .run_command(ShellKind::Posix, "sh", &["-c", "echo $SHELL"], false) @@ -1093,6 +1238,13 @@ impl SshSocket { } } } + + async fn shell_windows(&self) -> String { + // powershell is always the default, and cannot really be removed from the system + // so we can rely on that fact and reasonably assume that we will be running in a + // powershell environment + "powershell.exe".to_owned() + } } fn parse_port_number(port_str: &str) -> Result { @@ -1208,10 +1360,24 @@ impl SshConnectionOptions { input = rest; username = Some(u.to_string()); } - if let Some((rest, p)) = input.split_once(':') { + + // Handle port parsing, accounting for IPv6 addresses + // IPv6 addresses can be: 2001:db8::1 or [2001:db8::1]:22 + if input.starts_with('[') { + if let Some((rest, p)) = input.rsplit_once("]:") { + input = rest.strip_prefix('[').unwrap_or(rest); + port = p.parse().ok(); + } else if input.ends_with(']') { + input = input.strip_prefix('[').unwrap_or(input); + input = input.strip_suffix(']').unwrap_or(input); + } + } else if let Some((rest, p)) = input.rsplit_once(':') + && !rest.contains(":") + { input = rest; - port = p.parse().ok() + port = p.parse().ok(); } + hostname = Some(input.to_string()) } @@ -1225,7 +1391,7 @@ impl SshConnectionOptions { }; Ok(Self { - host: hostname, + host: hostname.into(), username, port, port_forwards, @@ -1237,19 +1403,16 @@ impl SshConnectionOptions { }) } - pub fn ssh_url(&self) -> String { - let mut result = String::from("ssh://"); + pub fn ssh_destination(&self) -> String { + let mut result = String::default(); if let Some(username) = &self.username { // Username might be: username1@username2@ip2 let username = urlencoding::encode(username); result.push_str(&username); result.push('@'); } - result.push_str(&self.host); - if let Some(port) = self.port { - result.push(':'); - result.push_str(&port.to_string()); - } + + result.push_str(&self.host.to_string()); result } @@ -1264,6 +1427,11 @@ impl SshConnectionOptions { args.extend(["-o".to_string(), format!("ConnectTimeout={}", timeout)]); } + if let Some(port) = self.port { + args.push("-p".to_string()); + args.push(port.to_string()); + } + if let Some(forwards) = &self.port_forwards { args.extend(forwards.iter().map(|pf| { let local_host = match &pf.local_host { @@ -1285,22 +1453,23 @@ impl SshConnectionOptions { args } - fn scp_url(&self) -> String { + fn scp_destination(&self) -> String { if let Some(username) = &self.username { - format!("{}@{}", username, self.host) + format!("{}@{}", username, self.host.to_bracketed_string()) } else { - self.host.clone() + self.host.to_string() } } pub fn connection_string(&self) -> String { - let host = if let Some(username) = &self.username { - format!("{}@{}", username, self.host) + let host = if let Some(port) = &self.port { + format!("{}:{}", self.host.to_bracketed_string(), port) } else { - self.host.clone() + self.host.to_string() }; - if let Some(port) = &self.port { - format!("{}:{}", host, port) + + if let Some(username) = &self.username { + format!("{}@{}", username, host) } else { host } @@ -1510,4 +1679,44 @@ mod tests { ] ); } + + #[test] + fn test_host_parsing() -> Result<()> { + let opts = SshConnectionOptions::parse_command_line("user@2001:db8::1")?; + assert_eq!(opts.host, "2001:db8::1".into()); + assert_eq!(opts.username, Some("user".to_string())); + assert_eq!(opts.port, None); + + let opts = SshConnectionOptions::parse_command_line("user@[2001:db8::1]:2222")?; + assert_eq!(opts.host, "2001:db8::1".into()); + assert_eq!(opts.username, Some("user".to_string())); + assert_eq!(opts.port, Some(2222)); + + let opts = SshConnectionOptions::parse_command_line("user@[2001:db8::1]")?; + assert_eq!(opts.host, "2001:db8::1".into()); + assert_eq!(opts.username, Some("user".to_string())); + assert_eq!(opts.port, None); + + let opts = SshConnectionOptions::parse_command_line("2001:db8::1")?; + assert_eq!(opts.host, "2001:db8::1".into()); + assert_eq!(opts.username, None); + assert_eq!(opts.port, None); + + let opts = SshConnectionOptions::parse_command_line("[2001:db8::1]:2222")?; + assert_eq!(opts.host, "2001:db8::1".into()); + assert_eq!(opts.username, None); + assert_eq!(opts.port, Some(2222)); + + let opts = SshConnectionOptions::parse_command_line("user@example.com:2222")?; + assert_eq!(opts.host, "example.com".into()); + assert_eq!(opts.username, Some("user".to_string())); + assert_eq!(opts.port, Some(2222)); + + let opts = SshConnectionOptions::parse_command_line("user@192.168.1.1:2222")?; + assert_eq!(opts.host, "192.168.1.1".into()); + assert_eq!(opts.username, Some("user".to_string())); + assert_eq!(opts.port, Some(2222)); + + Ok(()) + } } diff --git a/crates/remote/src/transport/wsl.rs b/crates/remote/src/transport/wsl.rs index d27648e67840681765248ae1cce12c15d7a13228..32dd9ebe8247bb4a0b631a79b1a93deb621e6ed1 100644 --- a/crates/remote/src/transport/wsl.rs +++ b/crates/remote/src/transport/wsl.rs @@ -1,5 +1,5 @@ use crate::{ - RemoteClientDelegate, RemotePlatform, + RemoteArch, RemoteClientDelegate, RemoteOs, RemotePlatform, remote_client::{CommandTemplate, RemoteConnection, RemoteConnectionOptions}, transport::{parse_platform, parse_shell}, }; @@ -70,7 +70,10 @@ impl WslRemoteConnection { let mut this = Self { connection_options, remote_binary_path: None, - platform: RemotePlatform { os: "", arch: "" }, + platform: RemotePlatform { + os: RemoteOs::Linux, + arch: RemoteArch::X86_64, + }, shell: String::new(), shell_kind: ShellKind::Posix, default_system_shell: String::from("/bin/sh"), diff --git a/crates/remote_server/Cargo.toml b/crates/remote_server/Cargo.toml index 114dc777c1d518fc2bcbc6aaff5a4b9aa7b68a1d..ce4af656a60267cde5453f27cad129109ff660f1 100644 --- a/crates/remote_server/Cargo.toml +++ b/crates/remote_server/Cargo.toml @@ -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"] } diff --git a/crates/remote_server/src/headless_project.rs b/crates/remote_server/src/headless_project.rs index 361e74579cc157e6e40a968a29ef4e6eed026335..c83cc6aa34402a082fe104d64a8cb47f460704b8 100644 --- a/crates/remote_server/src/headless_project.rs +++ b/crates/remote_server/src/headless_project.rs @@ -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 { 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::, + 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,50 @@ impl HeadlessProject { }) } + pub async fn handle_trust_worktrees( + _: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + 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, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let trusted_worktrees = cx + .update(|cx| TrustedWorktrees::try_get_global(cx))? + .context("missing trusted worktrees")?; + trusted_worktrees.update(&mut cx, |trusted_worktrees, cx| { + let restricted_paths = envelope + .payload + .worktree_ids + .into_iter() + .map(WorktreeId::from_proto) + .map(PathTrust::Worktree) + .collect::>(); + trusted_worktrees.restrict(restricted_paths, None, cx); + })?; + Ok(proto::Ack {}) + } + pub async fn handle_open_new_buffer( this: Entity, _message: TypedEnvelope, diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index a91d1d055d582eb2f2de4883314ad5984238103a..a7a870b0513694abe8b126fd0badea05534749ea 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -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, cx: &mut TestAppContext) -> Entity

, 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 !built_in_rules.is_empty() { + filtered_entries.push(RulePickerEntry::Header("Built-in Rules".into())); + + for rule in built_in_rules { + filtered_entries.push(RulePickerEntry::Rule(rule)); + } + + filtered_entries.push(RulePickerEntry::Separator); + } + if !default_rules.is_empty() { filtered_entries.push(RulePickerEntry::Header("Default Rules".into())); for rule in default_rules { - filtered_entries.push(RulePickerEntry::Rule(rule.clone())); + filtered_entries.push(RulePickerEntry::Rule(rule)); } filtered_entries.push(RulePickerEntry::Separator); } - for rule in non_default_rules { - filtered_entries.push(RulePickerEntry::Rule(rule.clone())); + 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>, ) -> Option { 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::(default.then(|| { + .end_slot::((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) { 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, + ) { + 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, + ) { + 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, @@ -690,7 +720,7 @@ impl RulesLibrary { if focus { rule_editor .body_editor - .update(cx, |editor, cx| window.focus(&editor.focus_handle(cx))); + .update(cx, |editor, cx| window.focus(&editor.focus_handle(cx), cx)); } self.set_active_rule(Some(prompt_id), window, cx); } else if let Some(rule_metadata) = self.store.read(cx).metadata(prompt_id) { @@ -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); } @@ -733,7 +763,7 @@ impl RulesLibrary { editor.set_current_line_highlight(Some(CurrentLineHighlight::None)); editor.set_completion_provider(Some(make_completion_provider())); if focus { - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); } editor }); @@ -909,7 +939,7 @@ impl RulesLibrary { if let Some(active_rule) = self.active_rule_id { self.rule_editors[&active_rule] .body_editor - .update(cx, |editor, cx| window.focus(&editor.focus_handle(cx))); + .update(cx, |editor, cx| window.focus(&editor.focus_handle(cx), cx)); cx.stop_propagation(); } } @@ -968,7 +998,7 @@ impl RulesLibrary { if let Some(rule_id) = self.active_rule_id && let Some(rule_editor) = self.rule_editors.get(&rule_id) { - window.focus(&rule_editor.body_editor.focus_handle(cx)); + window.focus(&rule_editor.body_editor.focus_handle(cx), cx); } } @@ -981,7 +1011,7 @@ impl RulesLibrary { if let Some(rule_id) = self.active_rule_id && let Some(rule_editor) = self.rule_editors.get(&rule_id) { - window.focus(&rule_editor.title_editor.focus_handle(cx)); + window.focus(&rule_editor.title_editor.focus_handle(cx), cx); } } @@ -1148,30 +1178,38 @@ impl RulesLibrary { fn render_active_rule_editor( &self, editor: &Entity, + read_only: bool, cx: &mut Context, ) -> 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) -> gpui::Stateful

{ 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() @@ -1208,20 +1308,21 @@ impl RulesLibrary { .size_full() .relative() .overflow_hidden() - .on_click(cx.listener(move |_, _, window, _| { - window.focus(&focus_handle); + .on_click(cx.listener(move |_, _, window, cx| { + window.focus(&focus_handle, cx); })) .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) diff --git a/crates/schema_generator/Cargo.toml b/crates/schema_generator/Cargo.toml index 865f76f4af917606af5d61d173950493fdde07c7..b92298a3b41d62b861c19a1f22ceaee0d63828b5 100644 --- a/crates/schema_generator/Cargo.toml +++ b/crates/schema_generator/Cargo.toml @@ -15,4 +15,5 @@ env_logger.workspace = true schemars = { workspace = true, features = ["indexmap2"] } serde.workspace = true serde_json.workspace = true +settings.workspace = true theme.workspace = true diff --git a/crates/schema_generator/src/main.rs b/crates/schema_generator/src/main.rs index a7e406a1a9c0426ac8294c05bd475931c3e62fb4..a77060c54d1361dc96204238a282f8e75946a37b 100644 --- a/crates/schema_generator/src/main.rs +++ b/crates/schema_generator/src/main.rs @@ -1,6 +1,7 @@ use anyhow::Result; use clap::{Parser, ValueEnum}; use schemars::schema_for; +use settings::ProjectSettingsContent; use theme::{IconThemeFamilyContent, ThemeFamilyContent}; #[derive(Parser, Debug)] @@ -14,6 +15,7 @@ pub struct Args { pub enum SchemaType { Theme, IconTheme, + Project, } fn main() -> Result<()> { @@ -30,6 +32,10 @@ fn main() -> Result<()> { let schema = schema_for!(IconThemeFamilyContent); println!("{}", serde_json::to_string_pretty(&schema)?); } + SchemaType::Project => { + let schema = schema_for!(ProjectSettingsContent); + println!("{}", serde_json::to_string_pretty(&schema)?); + } } Ok(()) diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index 686d385aa07accac168062fa598790b36e80199f..66641e91a882b0b994e16673e3c65a1d51f27650 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -518,7 +518,7 @@ impl BufferSearchBar { pub fn register(registrar: &mut impl SearchActionsRegistrar) { registrar.register_handler(ForDeployed(|this, _: &FocusSearch, window, cx| { - this.query_editor.focus_handle(cx).focus(window); + this.query_editor.focus_handle(cx).focus(window, cx); this.select_query(window, cx); })); registrar.register_handler(ForDeployed( @@ -706,7 +706,7 @@ impl BufferSearchBar { active_editor.search_bar_visibility_changed(false, window, cx); active_editor.toggle_filtered_search_ranges(None, window, cx); let handle = active_editor.item_focus_handle(cx); - self.focus(&handle, window); + self.focus(&handle, window, cx); } cx.emit(Event::UpdateLocation); @@ -749,7 +749,7 @@ impl BufferSearchBar { self.select_query(window, cx); } - window.focus(&handle); + window.focus(&handle, cx); } return true; } @@ -878,7 +878,7 @@ impl BufferSearchBar { } pub fn focus_replace(&mut self, window: &mut Window, cx: &mut Context) { - self.focus(&self.replacement_editor.focus_handle(cx), window); + self.focus(&self.replacement_editor.focus_handle(cx), window, cx); cx.notify(); } @@ -909,7 +909,7 @@ impl BufferSearchBar { pub fn focus_editor(&mut self, _: &FocusEditor, window: &mut Window, cx: &mut Context) { if let Some(active_editor) = self.active_searchable_item.as_ref() { let handle = active_editor.item_focus_handle(cx); - window.focus(&handle); + window.focus(&handle, cx); } } @@ -1384,7 +1384,7 @@ impl BufferSearchBar { Direction::Prev => (current_index - 1) % handles.len(), }; let next_focus_handle = &handles[new_index]; - self.focus(next_focus_handle, window); + self.focus(next_focus_handle, window, cx); cx.stop_propagation(); } @@ -1431,9 +1431,9 @@ impl BufferSearchBar { } } - fn focus(&self, handle: &gpui::FocusHandle, window: &mut Window) { + fn focus(&self, handle: &gpui::FocusHandle, window: &mut Window, cx: &mut App) { window.invalidate_character_coordinates(); - window.focus(handle); + window.focus(handle, cx); } fn toggle_replace(&mut self, _: &ToggleReplace, window: &mut Window, cx: &mut Context) { @@ -1444,7 +1444,7 @@ impl BufferSearchBar { } else { self.query_editor.focus_handle(cx) }; - self.focus(&handle, window); + self.focus(&handle, window, cx); cx.notify(); } } @@ -2038,7 +2038,7 @@ mod tests { .update(cx, |_, window, cx| { search_bar.update(cx, |search_bar, cx| { let handle = search_bar.query_editor.focus_handle(cx); - window.focus(&handle); + window.focus(&handle, cx); search_bar.activate_current_match(window, cx); }); assert!( @@ -2056,7 +2056,7 @@ mod tests { search_bar.update(cx, |search_bar, cx| { assert_eq!(search_bar.active_match_index, Some(0)); let handle = search_bar.query_editor.focus_handle(cx); - window.focus(&handle); + window.focus(&handle, cx); search_bar.select_all_matches(&SelectAllMatches, window, cx); }); assert!( @@ -2109,7 +2109,7 @@ mod tests { "Match index should be updated to the next one" ); let handle = search_bar.query_editor.focus_handle(cx); - window.focus(&handle); + window.focus(&handle, cx); search_bar.select_all_matches(&SelectAllMatches, window, cx); }); }) @@ -2175,7 +2175,7 @@ mod tests { .update(cx, |_, window, cx| { search_bar.update(cx, |search_bar, cx| { let handle = search_bar.query_editor.focus_handle(cx); - window.focus(&handle); + window.focus(&handle, cx); search_bar.search("abas_nonexistent_match", None, true, window, cx) }) }) diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index 278f2e86b7b13fd5a82777054c12ff2e1b6239bb..e0bbf58ce6f1d0c752914bbbfa6fcdf70ea30175 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -954,9 +954,9 @@ impl ProjectSearchView { cx.on_next_frame(window, |this, window, cx| { if this.focus_handle.is_focused(window) { if this.has_matches() { - this.results_editor.focus_handle(cx).focus(window); + this.results_editor.focus_handle(cx).focus(window, cx); } else { - this.query_editor.focus_handle(cx).focus(window); + this.query_editor.focus_handle(cx).focus(window, cx); } } }); @@ -1453,7 +1453,7 @@ impl ProjectSearchView { query_editor.select_all(&SelectAll, window, cx); }); let editor_handle = self.query_editor.focus_handle(cx); - window.focus(&editor_handle); + window.focus(&editor_handle, cx); } fn set_query(&mut self, query: &str, window: &mut Window, cx: &mut Context) { @@ -1493,7 +1493,7 @@ impl ProjectSearchView { }); }); let results_handle = self.results_editor.focus_handle(cx); - window.focus(&results_handle); + window.focus(&results_handle, cx); } fn entity_changed(&mut self, window: &mut Window, cx: &mut Context) { @@ -1750,7 +1750,7 @@ impl ProjectSearchBar { fn focus_search(&mut self, window: &mut Window, cx: &mut Context) { if let Some(search_view) = self.active_project_search.as_ref() { search_view.update(cx, |search_view, cx| { - search_view.query_editor.focus_handle(cx).focus(window); + search_view.query_editor.focus_handle(cx).focus(window, cx); }); } } @@ -1783,7 +1783,7 @@ impl ProjectSearchBar { Direction::Prev => (current_index - 1) % views.len(), }; let next_focus_handle = &views[new_index]; - window.focus(next_focus_handle); + window.focus(next_focus_handle, cx); cx.stop_propagation(); }); } @@ -1832,7 +1832,7 @@ impl ProjectSearchBar { } else { this.query_editor.focus_handle(cx) }; - window.focus(&editor_to_focus); + window.focus(&editor_to_focus, cx); cx.notify(); }); } @@ -4352,7 +4352,7 @@ pub mod tests { let buffer_search_query = "search bar query"; buffer_search_bar .update_in(&mut cx, |buffer_search_bar, window, cx| { - buffer_search_bar.focus_handle(cx).focus(window); + buffer_search_bar.focus_handle(cx).focus(window, cx); buffer_search_bar.search(buffer_search_query, None, true, window, cx) }) .await diff --git a/crates/search/src/search.rs b/crates/search/src/search.rs index 6663f8c3184aba9fedbcd5faa3d80d5889181074..3aa40894ea91ed7af3441fad210f6ce0f9e1dd53 100644 --- a/crates/search/src/search.rs +++ b/crates/search/src/search.rs @@ -143,7 +143,7 @@ impl SearchOption { let focus_handle = focus_handle.clone(); button.on_click(move |_: &ClickEvent, window, cx| { if !focus_handle.is_focused(window) { - window.focus(&focus_handle); + window.focus(&focus_handle, cx); } window.dispatch_action(action.boxed_clone(), cx); }) diff --git a/crates/search/src/search_bar.rs b/crates/search/src/search_bar.rs index 13b4df9574aa6b2568dd6db25c6b63551d9b6d03..a1f6c070724c4d57b438c452ef4b4ae3cf20e66d 100644 --- a/crates/search/src/search_bar.rs +++ b/crates/search/src/search_bar.rs @@ -27,7 +27,7 @@ pub(super) fn render_action_button( let focus_handle = focus_handle.clone(); move |_, window, cx| { if !focus_handle.is_focused(window) { - window.focus(&focus_handle); + window.focus(&focus_handle, cx); } window.dispatch_action(action.boxed_clone(), cx); } diff --git a/crates/settings/src/settings_content/agent.rs b/crates/settings/src/settings_content/agent.rs index 5bcf0f49a445ed82d98871c4b8fd3cc66c37d9b1..733dd6b8a2872f14d991a725b8d73caa73d07702 100644 --- a/crates/settings/src/settings_content/agent.rs +++ b/crates/settings/src/settings_content/agent.rs @@ -38,6 +38,9 @@ pub struct AgentSettingsContent { pub default_height: Option, /// The default model to use when creating new chats and for other features when a specific model is not specified. pub default_model: Option, + /// Favorite models to show at the top of the model selector. + #[serde(default)] + pub favorite_models: Vec, /// Model to use for the inline assistant. Defaults to default_model when not specified. pub inline_assistant_model: Option, /// 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) { 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] diff --git a/crates/settings/src/settings_content/language_model.rs b/crates/settings/src/settings_content/language_model.rs index b106f3d9925cb4afe058cff44649f998c8b73d8a..e523286e5f56af88110c2d4a7d874c22195ea2b1 100644 --- a/crates/settings/src/settings_content/language_model.rs +++ b/crates/settings/src/settings_content/language_model.rs @@ -83,6 +83,8 @@ pub enum BedrockAuthMethodContent { NamedProfile, #[serde(rename = "sso")] SingleSignOn, + #[serde(rename = "api_key")] + ApiKey, /// IMDSv2, PodIdentity, env vars, etc. #[serde(rename = "default")] Automatic, diff --git a/crates/settings/src/settings_content/project.rs b/crates/settings/src/settings_content/project.rs index 5cd708694d0cfd3699fdc822509d0209f9a96fd1..a5e15153832c425134e129cba1984b3b5886aa56 100644 --- a/crates/settings/src/settings_content/project.rs +++ b/crates/settings/src/settings_content/project.rs @@ -187,6 +187,12 @@ pub struct SessionSettingsContent { /// /// Default: true pub restore_unsaved_buffers: Option, + /// 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, } #[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema, MergeFrom, Debug)] diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 0682f0815cdfb13ff7bc649402e47445223c4bbc..34373a964f6ad163796905e93ae4e8a7816491b9 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -138,6 +138,28 @@ pub(crate) fn settings_data(cx: &App) -> Vec { 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", diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index 40678f6cf8d1c6773ccf1168e065cb318ae9f14f..0ec6d0aee308ce3c20b67a5db9c6a6d9224bf229 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -345,8 +345,8 @@ impl NonFocusableHandle { fn from_handle(handle: FocusHandle, window: &mut Window, cx: &mut App) -> Entity { cx.new(|cx| { let _subscription = cx.on_focus(&handle, window, { - move |_, window, _| { - window.focus_next(); + move |_, window, cx| { + window.focus_next(cx); } }); Self { @@ -890,7 +890,7 @@ impl SettingsPageItem { .size(ButtonSize::Medium) .on_click({ let sub_page_link = sub_page_link.clone(); - cx.listener(move |this, _, _, cx| { + cx.listener(move |this, _, window, cx| { let mut section_index = item_index; let current_page = this.current_page(); @@ -909,7 +909,7 @@ impl SettingsPageItem { ) }; - this.push_sub_page(sub_page_link.clone(), header, cx) + this.push_sub_page(sub_page_link.clone(), header, window, cx) }) }), ) @@ -1537,7 +1537,7 @@ impl SettingsWindow { this.build_search_index(); this.search_bar.update(cx, |editor, cx| { - editor.focus_handle(cx).focus(window); + editor.focus_handle(cx).focus(window, cx); }); this @@ -2174,7 +2174,7 @@ impl SettingsWindow { let focus_handle = focus_handle.clone(); move |this, _: &gpui::ClickEvent, window, cx| { this.change_file(ix, window, cx); - focus_handle.focus(window); + focus_handle.focus(window, cx); } })) }; @@ -2251,7 +2251,7 @@ impl SettingsWindow { this.update(cx, |this, cx| { this.change_file(ix, window, cx); }); - focus_handle.focus(window); + focus_handle.focus(window, cx); } }, ); @@ -2385,7 +2385,7 @@ impl SettingsWindow { let focused_entry_parent = this.root_entry_containing(focused_entry); if this.navbar_entries[focused_entry_parent].expanded { this.toggle_navbar_entry(focused_entry_parent); - window.focus(&this.navbar_entries[focused_entry_parent].focus_handle); + window.focus(&this.navbar_entries[focused_entry_parent].focus_handle, cx); } cx.notify(); })) @@ -2534,6 +2534,7 @@ impl SettingsWindow { window.focus( &this.navbar_entries[entry_index] .focus_handle, + cx, ); cx.notify(); }, @@ -2658,7 +2659,7 @@ impl SettingsWindow { // back to back. cx.on_next_frame(window, move |_, window, cx| { if let Some(handle) = handle_to_focus.as_ref() { - window.focus(handle); + window.focus(handle, cx); } cx.on_next_frame(window, |_, _, cx| { @@ -2725,7 +2726,7 @@ impl SettingsWindow { }; self.navbar_scroll_handle .scroll_to_item(position, gpui::ScrollStrategy::Top); - window.focus(&self.navbar_entries[nav_entry_index].focus_handle); + window.focus(&self.navbar_entries[nav_entry_index].focus_handle, cx); cx.notify(); } @@ -2995,8 +2996,8 @@ impl SettingsWindow { IconButton::new("back-btn", IconName::ArrowLeft) .icon_size(IconSize::Small) .shape(IconButtonShape::Square) - .on_click(cx.listener(|this, _, _, cx| { - this.pop_sub_page(cx); + .on_click(cx.listener(|this, _, window, cx| { + this.pop_sub_page(window, cx); })), ) .child(self.render_sub_page_breadcrumbs()), @@ -3100,7 +3101,7 @@ impl SettingsWindow { .id("settings-ui-page") .on_action(cx.listener(|this, _: &menu::SelectNext, window, cx| { if !sub_page_stack().is_empty() { - window.focus_next(); + window.focus_next(cx); return; } for (logical_index, (actual_index, _)) in this.visible_page_items().enumerate() { @@ -3120,7 +3121,7 @@ impl SettingsWindow { cx.on_next_frame(window, |_, window, cx| { cx.notify(); cx.on_next_frame(window, |_, window, cx| { - window.focus_next(); + window.focus_next(cx); cx.notify(); }); }); @@ -3128,11 +3129,11 @@ impl SettingsWindow { return; } } - window.focus_next(); + window.focus_next(cx); })) .on_action(cx.listener(|this, _: &menu::SelectPrevious, window, cx| { if !sub_page_stack().is_empty() { - window.focus_prev(); + window.focus_prev(cx); return; } let mut prev_was_header = false; @@ -3152,7 +3153,7 @@ impl SettingsWindow { cx.on_next_frame(window, |_, window, cx| { cx.notify(); cx.on_next_frame(window, |_, window, cx| { - window.focus_prev(); + window.focus_prev(cx); cx.notify(); }); }); @@ -3161,7 +3162,7 @@ impl SettingsWindow { } prev_was_header = is_header; } - window.focus_prev(); + window.focus_prev(cx); })) .when(sub_page_stack().is_empty(), |this| { this.vertical_scrollbar_for(&self.list_state, window, cx) @@ -3355,23 +3356,28 @@ impl SettingsWindow { &mut self, sub_page_link: SubPageLink, section_header: &'static str, + window: &mut Window, cx: &mut Context, ) { sub_page_stack_mut().push(SubPage { link: sub_page_link, section_header, }); + self.sub_page_scroll_handle + .set_offset(point(px(0.), px(0.))); + self.content_focus_handle.focus_handle(cx).focus(window, cx); cx.notify(); } - fn pop_sub_page(&mut self, cx: &mut Context) { + fn pop_sub_page(&mut self, window: &mut Window, cx: &mut Context) { sub_page_stack_mut().pop(); + self.content_focus_handle.focus_handle(cx).focus(window, cx); cx.notify(); } - fn focus_file_at_index(&mut self, index: usize, window: &mut Window) { + fn focus_file_at_index(&mut self, index: usize, window: &mut Window, cx: &mut App) { if let Some((_, handle)) = self.files.get(index) { - handle.focus(window); + handle.focus(window, cx); } } @@ -3451,7 +3457,7 @@ impl Render for SettingsWindow { window.minimize_window(); }) .on_action(cx.listener(|this, _: &search::FocusSearch, window, cx| { - this.search_bar.focus_handle(cx).focus(window); + this.search_bar.focus_handle(cx).focus(window, cx); })) .on_action(cx.listener(|this, _: &ToggleFocusNav, window, cx| { if this @@ -3471,8 +3477,8 @@ impl Render for SettingsWindow { } })) .on_action(cx.listener( - |this, FocusFile(file_index): &FocusFile, window, _| { - this.focus_file_at_index(*file_index as usize, window); + |this, FocusFile(file_index): &FocusFile, window, cx| { + this.focus_file_at_index(*file_index as usize, window, cx); }, )) .on_action(cx.listener(|this, _: &FocusNextFile, window, cx| { @@ -3480,11 +3486,11 @@ impl Render for SettingsWindow { this.focused_file_index(window, cx) + 1, this.files.len().saturating_sub(1), ); - this.focus_file_at_index(next_index, window); + this.focus_file_at_index(next_index, window, cx); })) .on_action(cx.listener(|this, _: &FocusPreviousFile, window, cx| { let prev_index = this.focused_file_index(window, cx).saturating_sub(1); - this.focus_file_at_index(prev_index, window); + this.focus_file_at_index(prev_index, window, cx); })) .on_action(cx.listener(|this, _: &menu::SelectNext, window, cx| { if this @@ -3494,11 +3500,11 @@ impl Render for SettingsWindow { { this.focus_and_scroll_to_first_visible_nav_entry(window, cx); } else { - window.focus_next(); + window.focus_next(cx); } })) - .on_action(|_: &menu::SelectPrevious, window, _| { - window.focus_prev(); + .on_action(|_: &menu::SelectPrevious, window, cx| { + window.focus_prev(cx); }) .flex() .flex_row() diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index 601fa75044a648e7c40e84b32aabda8096856119..e64780e2945363e71b357b79aee57024484d417c 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -155,8 +155,8 @@ enum InternalEvent { ScrollToAlacPoint(AlacPoint), SetSelection(Option<(Selection, AlacPoint)>), UpdateSelection(Point), - // Adjusted mouse position, should open FindHyperlink(Point, bool), + ProcessHyperlink((String, bool, Match), bool), // Whether keep selection when copy Copy(Option), // Vi mode events @@ -380,6 +380,7 @@ impl TerminalBuilder { is_remote_terminal: false, last_mouse_move_time: Instant::now(), last_hyperlink_search_position: None, + mouse_down_hyperlink: None, #[cfg(windows)] shell_program: None, activation_script: Vec::new(), @@ -610,6 +611,7 @@ impl TerminalBuilder { is_remote_terminal, last_mouse_move_time: Instant::now(), last_hyperlink_search_position: None, + mouse_down_hyperlink: None, #[cfg(windows)] shell_program, activation_script: activation_script.clone(), @@ -840,6 +842,7 @@ pub struct Terminal { is_remote_terminal: bool, last_mouse_move_time: Instant, last_hyperlink_search_position: Option>, + mouse_down_hyperlink: Option<(String, bool, Match)>, #[cfg(windows)] shell_program: Option, template: CopyTemplate, @@ -892,6 +895,8 @@ impl TaskStatus { } } +const FIND_HYPERLINK_THROTTLE_PX: Pixels = px(5.0); + impl Terminal { fn process_event(&mut self, event: AlacTermEvent, cx: &mut Context) { match event { @@ -1150,7 +1155,6 @@ impl Terminal { } InternalEvent::FindHyperlink(position, open) => { trace!("Finding hyperlink at position: position={position:?}, open={open:?}"); - let prev_hovered_word = self.last_content.last_hovered_word.take(); let point = grid_point( *position, @@ -1164,47 +1168,53 @@ impl Terminal { point, &mut self.hyperlink_regex_searches, ) { - Some((maybe_url_or_path, is_url, url_match)) => { - let target = if is_url { - // Treat "file://" URLs like file paths to ensure - // that line numbers at the end of the path are - // handled correctly. - // file://{path} should be urldecoded, returning a urldecoded {path} - if let Some(path) = maybe_url_or_path.strip_prefix("file://") { - let decoded_path = urlencoding::decode(path) - .map(|decoded| decoded.into_owned()) - .unwrap_or(path.to_owned()); - - MaybeNavigationTarget::PathLike(PathLikeTarget { - maybe_path: decoded_path, - terminal_dir: self.working_directory(), - }) - } else { - MaybeNavigationTarget::Url(maybe_url_or_path.clone()) - } - } else { - MaybeNavigationTarget::PathLike(PathLikeTarget { - maybe_path: maybe_url_or_path.clone(), - terminal_dir: self.working_directory(), - }) - }; - if *open { - cx.emit(Event::Open(target)); - } else { - self.update_selected_word( - prev_hovered_word, - url_match, - maybe_url_or_path, - target, - cx, - ); - } + Some(hyperlink) => { + self.process_hyperlink(hyperlink, *open, cx); } None => { cx.emit(Event::NewNavigationTarget(None)); } } } + InternalEvent::ProcessHyperlink(hyperlink, open) => { + self.process_hyperlink(hyperlink.clone(), *open, cx); + } + } + } + + fn process_hyperlink( + &mut self, + hyperlink: (String, bool, Match), + open: bool, + cx: &mut Context, + ) { + let (maybe_url_or_path, is_url, url_match) = hyperlink; + let prev_hovered_word = self.last_content.last_hovered_word.take(); + + let target = if is_url { + if let Some(path) = maybe_url_or_path.strip_prefix("file://") { + let decoded_path = urlencoding::decode(path) + .map(|decoded| decoded.into_owned()) + .unwrap_or(path.to_owned()); + + MaybeNavigationTarget::PathLike(PathLikeTarget { + maybe_path: decoded_path, + terminal_dir: self.working_directory(), + }) + } else { + MaybeNavigationTarget::Url(maybe_url_or_path.clone()) + } + } else { + MaybeNavigationTarget::PathLike(PathLikeTarget { + maybe_path: maybe_url_or_path.clone(), + terminal_dir: self.working_directory(), + }) + }; + + if open { + cx.emit(Event::Open(target)); + } else { + self.update_selected_word(prev_hovered_word, url_match, maybe_url_or_path, target, cx); } } @@ -1718,38 +1728,40 @@ impl Terminal { { self.write_to_pty(bytes); } - } else if e.modifiers.secondary() { - self.word_from_position(e.position); + } else { + self.schedule_find_hyperlink(e.modifiers, e.position); } cx.notify(); } - fn word_from_position(&mut self, position: Point) { - if self.selection_phase == SelectionPhase::Selecting { + fn schedule_find_hyperlink(&mut self, modifiers: Modifiers, position: Point) { + if self.selection_phase == SelectionPhase::Selecting + || !modifiers.secondary() + || !self.last_content.terminal_bounds.bounds.contains(&position) + { self.last_content.last_hovered_word = None; - } else if self.last_content.terminal_bounds.bounds.contains(&position) { - // Throttle hyperlink searches to avoid excessive processing - let now = Instant::now(); - let should_search = if let Some(last_pos) = self.last_hyperlink_search_position { + return; + } + + // Throttle hyperlink searches to avoid excessive processing + let now = Instant::now(); + if self + .last_hyperlink_search_position + .map_or(true, |last_pos| { // Only search if mouse moved significantly or enough time passed - let distance_moved = - ((position.x - last_pos.x).abs() + (position.y - last_pos.y).abs()) > px(5.0); + let distance_moved = ((position.x - last_pos.x).abs() + + (position.y - last_pos.y).abs()) + > FIND_HYPERLINK_THROTTLE_PX; let time_elapsed = now.duration_since(self.last_mouse_move_time).as_millis() > 100; distance_moved || time_elapsed - } else { - true - }; - - if should_search { - self.last_mouse_move_time = now; - self.last_hyperlink_search_position = Some(position); - self.events.push_back(InternalEvent::FindHyperlink( - position - self.last_content.terminal_bounds.bounds.origin, - false, - )); - } - } else { - self.last_content.last_hovered_word = None; + }) + { + self.last_mouse_move_time = now; + self.last_hyperlink_search_position = Some(position); + self.events.push_back(InternalEvent::FindHyperlink( + position - self.last_content.terminal_bounds.bounds.origin, + false, + )); } } @@ -1773,6 +1785,20 @@ impl Terminal { ) { let position = e.position - self.last_content.terminal_bounds.bounds.origin; if !self.mouse_mode(e.modifiers.shift) { + if let Some((.., hyperlink_range)) = &self.mouse_down_hyperlink { + let point = grid_point( + position, + self.last_content.terminal_bounds, + self.last_content.display_offset, + ); + + if !hyperlink_range.contains(&point) { + self.mouse_down_hyperlink = None; + } else { + return; + } + } + self.selection_phase = SelectionPhase::Selecting; // Alacritty has the same ordering, of first updating the selection // then scrolling 15ms later @@ -1819,6 +1845,23 @@ impl Terminal { self.last_content.display_offset, ); + if e.button == MouseButton::Left + && e.modifiers.secondary() + && !self.mouse_mode(e.modifiers.shift) + { + let term_lock = self.term.lock(); + self.mouse_down_hyperlink = terminal_hyperlinks::find_from_grid_point( + &term_lock, + point, + &mut self.hyperlink_regex_searches, + ); + drop(term_lock); + + if self.mouse_down_hyperlink.is_some() { + return; + } + } + if self.mouse_mode(e.modifiers.shift) { if let Some(bytes) = mouse_button_report(point, e.button, e.modifiers, true, self.last_content.mode) @@ -1889,6 +1932,31 @@ impl Terminal { self.copy(Some(true)); } + if let Some(mouse_down_hyperlink) = self.mouse_down_hyperlink.take() { + let point = grid_point( + position, + self.last_content.terminal_bounds, + self.last_content.display_offset, + ); + + if let Some(mouse_up_hyperlink) = { + let term_lock = self.term.lock(); + terminal_hyperlinks::find_from_grid_point( + &term_lock, + point, + &mut self.hyperlink_regex_searches, + ) + } { + if mouse_down_hyperlink == mouse_up_hyperlink { + self.events + .push_back(InternalEvent::ProcessHyperlink(mouse_up_hyperlink, true)); + self.selection_phase = SelectionPhase::Ended; + self.last_mouse = None; + return; + } + } + } + //Hyperlinks if self.selection_phase == SelectionPhase::Ended { let mouse_cell_index = @@ -1941,7 +2009,7 @@ impl Terminal { } fn refresh_hovered_word(&mut self, window: &Window) { - self.word_from_position(window.mouse_position()); + self.schedule_find_hyperlink(window.modifiers(), window.mouse_position()); } fn determine_scroll_lines( @@ -2405,10 +2473,91 @@ mod tests { term::cell::Cell, }; use collections::HashMap; - use gpui::{Pixels, Point, TestAppContext, bounds, point, size, smol_timeout}; + use gpui::{ + Entity, Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, + Point, TestAppContext, bounds, point, size, smol_timeout, + }; use rand::{Rng, distr, rngs::ThreadRng}; use task::ShellBuilder; + fn init_ctrl_click_hyperlink_test(cx: &mut TestAppContext, output: &[u8]) -> Entity { + cx.update(|cx| { + let settings_store = settings::SettingsStore::test(cx); + cx.set_global(settings_store); + }); + + let terminal = cx.new(|cx| { + TerminalBuilder::new_display_only(CursorShape::default(), AlternateScroll::On, None, 0) + .unwrap() + .subscribe(cx) + }); + + terminal.update(cx, |terminal, cx| { + terminal.write_output(output, cx); + }); + + cx.run_until_parked(); + + terminal.update(cx, |terminal, _cx| { + let term_lock = terminal.term.lock(); + terminal.last_content = Terminal::make_content(&term_lock, &terminal.last_content); + drop(term_lock); + + let terminal_bounds = TerminalBounds::new( + px(20.0), + px(10.0), + bounds(point(px(0.0), px(0.0)), size(px(400.0), px(400.0))), + ); + terminal.last_content.terminal_bounds = terminal_bounds; + terminal.events.clear(); + }); + + terminal + } + + fn ctrl_mouse_down_at( + terminal: &mut Terminal, + position: Point, + cx: &mut Context, + ) { + let mouse_down = MouseDownEvent { + button: MouseButton::Left, + position, + modifiers: Modifiers::secondary_key(), + click_count: 1, + first_mouse: true, + }; + terminal.mouse_down(&mouse_down, cx); + } + + fn ctrl_mouse_move_to( + terminal: &mut Terminal, + position: Point, + cx: &mut Context, + ) { + let terminal_bounds = terminal.last_content.terminal_bounds.bounds; + let drag_event = MouseMoveEvent { + position, + pressed_button: Some(MouseButton::Left), + modifiers: Modifiers::secondary_key(), + }; + terminal.mouse_drag(&drag_event, terminal_bounds, cx); + } + + fn ctrl_mouse_up_at( + terminal: &mut Terminal, + position: Point, + cx: &mut Context, + ) { + let mouse_up = MouseUpEvent { + button: MouseButton::Left, + position, + modifiers: Modifiers::secondary_key(), + click_count: 1, + }; + terminal.mouse_up(&mouse_up, cx); + } + #[gpui::test] async fn test_basic_terminal(cx: &mut TestAppContext) { cx.executor().allow_parking(); @@ -2858,4 +3007,168 @@ mod tests { text ); } + + #[gpui::test] + async fn test_hyperlink_ctrl_click_same_position(cx: &mut TestAppContext) { + let terminal = init_ctrl_click_hyperlink_test(cx, b"Visit https://zed.dev/ for more\r\n"); + + terminal.update(cx, |terminal, cx| { + let click_position = point(px(80.0), px(10.0)); + ctrl_mouse_down_at(terminal, click_position, cx); + ctrl_mouse_up_at(terminal, click_position, cx); + + assert!( + terminal + .events + .iter() + .any(|event| matches!(event, InternalEvent::ProcessHyperlink(_, true))), + "Should have ProcessHyperlink event when ctrl+clicking on same hyperlink position" + ); + }); + } + + #[gpui::test] + async fn test_hyperlink_ctrl_click_drag_outside_bounds(cx: &mut TestAppContext) { + let terminal = init_ctrl_click_hyperlink_test( + cx, + b"Visit https://zed.dev/ for more\r\nThis is another line\r\n", + ); + + terminal.update(cx, |terminal, cx| { + let down_position = point(px(80.0), px(10.0)); + let up_position = point(px(10.0), px(50.0)); + + ctrl_mouse_down_at(terminal, down_position, cx); + ctrl_mouse_move_to(terminal, up_position, cx); + ctrl_mouse_up_at(terminal, up_position, cx); + + assert!( + !terminal + .events + .iter() + .any(|event| matches!(event, InternalEvent::ProcessHyperlink(_, _))), + "Should NOT have ProcessHyperlink event when dragging outside the hyperlink" + ); + }); + } + + #[gpui::test] + async fn test_hyperlink_ctrl_click_drag_within_bounds(cx: &mut TestAppContext) { + let terminal = init_ctrl_click_hyperlink_test(cx, b"Visit https://zed.dev/ for more\r\n"); + + terminal.update(cx, |terminal, cx| { + let down_position = point(px(70.0), px(10.0)); + let up_position = point(px(130.0), px(10.0)); + + ctrl_mouse_down_at(terminal, down_position, cx); + ctrl_mouse_move_to(terminal, up_position, cx); + ctrl_mouse_up_at(terminal, up_position, cx); + + assert!( + terminal + .events + .iter() + .any(|event| matches!(event, InternalEvent::ProcessHyperlink(_, true))), + "Should have ProcessHyperlink event when dragging within hyperlink bounds" + ); + }); + } + + mod perf { + use super::super::*; + use gpui::{ + Entity, Point, ScrollDelta, ScrollWheelEvent, TestAppContext, VisualContext, + VisualTestContext, point, + }; + use util::default; + use util_macros::perf; + + async fn init_scroll_perf_test( + cx: &mut TestAppContext, + ) -> (Entity, &mut VisualTestContext) { + cx.update(|cx| { + let settings_store = settings::SettingsStore::test(cx); + cx.set_global(settings_store); + }); + + cx.executor().allow_parking(); + + let window = cx.add_empty_window(); + let builder = window + .update(|window, cx| { + let settings = TerminalSettings::get_global(cx); + let test_path_hyperlink_timeout_ms = 100; + TerminalBuilder::new( + None, + None, + task::Shell::System, + HashMap::default(), + CursorShape::default(), + AlternateScroll::On, + None, + settings.path_hyperlink_regexes.clone(), + test_path_hyperlink_timeout_ms, + false, + window.window_handle().window_id().as_u64(), + None, + cx, + vec![], + ) + }) + .await + .unwrap(); + let terminal = window.new(|cx| builder.subscribe(cx)); + + terminal.update(window, |term, cx| { + term.write_output("long line ".repeat(1000).as_bytes(), cx); + }); + + (terminal, window) + } + + #[perf] + #[gpui::test] + async fn scroll_long_line_benchmark(cx: &mut TestAppContext) { + let (terminal, window) = init_scroll_perf_test(cx).await; + let wobble = point(FIND_HYPERLINK_THROTTLE_PX, px(0.0)); + let mut scroll_by = |lines: i32| { + window.update_window_entity(&terminal, |terminal, window, cx| { + let bounds = terminal.last_content.terminal_bounds.bounds; + let center = bounds.origin + bounds.center(); + let position = center + wobble * lines as f32; + + terminal.mouse_move( + &MouseMoveEvent { + position, + ..default() + }, + cx, + ); + + terminal.scroll_wheel( + &ScrollWheelEvent { + position, + delta: ScrollDelta::Lines(Point::new(0.0, lines as f32)), + ..default() + }, + 1.0, + ); + + assert!( + terminal + .events + .iter() + .any(|event| matches!(event, InternalEvent::Scroll(_))), + "Should have Scroll event when scrolling within terminal bounds" + ); + terminal.sync(window, cx); + }); + }; + + for _ in 0..20000 { + scroll_by(1); + scroll_by(-1); + } + } + } } diff --git a/crates/terminal_view/src/terminal_element.rs b/crates/terminal_view/src/terminal_element.rs index fd9568b0c582d4c191267183e296976f3d429eb3..b5324b7c6c7e0c467c657b122717fbf17cf9f7b9 100644 --- a/crates/terminal_view/src/terminal_element.rs +++ b/crates/terminal_view/src/terminal_element.rs @@ -632,7 +632,7 @@ impl TerminalElement { ) -> impl Fn(&E, &mut Window, &mut App) { move |event, window, cx| { if steal_focus { - window.focus(&focus_handle); + window.focus(&focus_handle, cx); } else if !focus_handle.is_focused(window) { return; } @@ -661,7 +661,7 @@ impl TerminalElement { let terminal_view = terminal_view.clone(); move |e, window, cx| { - window.focus(&focus); + window.focus(&focus, cx); let scroll_top = terminal_view.read(cx).scroll_top; terminal.update(cx, |terminal, cx| { diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index fb660e759c75aee9752cbaa3bdc8c8e0a47615e3..85c6b81f406597e097cabc27408d3df70aad6395 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -351,7 +351,7 @@ impl TerminalPanel { } else if let Some(focus_on_pane) = focus_on_pane.as_ref().or_else(|| self.center.panes().pop()) { - focus_on_pane.focus_handle(cx).focus(window); + focus_on_pane.focus_handle(cx).focus(window, cx); } } pane::Event::ZoomIn => { @@ -397,7 +397,7 @@ impl TerminalPanel { .center .split(&pane, &new_pane, direction, cx) .log_err(); - window.focus(&new_pane.focus_handle(cx)); + window.focus(&new_pane.focus_handle(cx), cx); }) .ok(); }) @@ -419,7 +419,7 @@ impl TerminalPanel { pane.add_item(item, true, true, None, window, cx); }); self.center.split(&pane, &new_pane, direction, cx).log_err(); - window.focus(&new_pane.focus_handle(cx)); + window.focus(&new_pane.focus_handle(cx), cx); } } pane::Event::Focus => { @@ -998,7 +998,7 @@ impl TerminalPanel { RevealStrategy::NoFocus => match reveal_target { RevealTarget::Center => { task_workspace.update_in(cx, |workspace, window, cx| { - workspace.active_pane().focus_handle(cx).focus(window); + workspace.active_pane().focus_handle(cx).focus(window, cx); })?; } RevealTarget::Dock => { @@ -1053,7 +1053,7 @@ impl TerminalPanel { .center .find_pane_in_direction(&self.active_pane, direction, cx) { - window.focus(&pane.focus_handle(cx)); + window.focus(&pane.focus_handle(cx), cx); } else { self.workspace .update(cx, |workspace, cx| { @@ -1297,7 +1297,7 @@ fn add_paths_to_terminal( .active_item() .and_then(|item| item.downcast::()) { - window.focus(&terminal_view.focus_handle(cx)); + window.focus(&terminal_view.focus_handle(cx), cx); let mut new_text = paths.iter().map(|path| format!(" {path:?}")).join(""); new_text.push(' '); terminal_view.update(cx, |terminal_view, cx| { @@ -1451,7 +1451,7 @@ impl Render for TerminalPanel { .position(|pane| **pane == terminal_panel.active_pane) { let next_ix = (ix + 1) % panes.len(); - window.focus(&panes[next_ix].focus_handle(cx)); + window.focus(&panes[next_ix].focus_handle(cx), cx); } }), ) @@ -1463,7 +1463,7 @@ impl Render for TerminalPanel { .position(|pane| **pane == terminal_panel.active_pane) { let prev_ix = cmp::min(ix.wrapping_sub(1), panes.len() - 1); - window.focus(&panes[prev_ix].focus_handle(cx)); + window.focus(&panes[prev_ix].focus_handle(cx), cx); } }, )) @@ -1471,7 +1471,7 @@ impl Render for TerminalPanel { cx.listener(|terminal_panel, action: &ActivatePane, window, cx| { let panes = terminal_panel.center.panes(); if let Some(&pane) = panes.get(action.0) { - window.focus(&pane.read(cx).focus_handle(cx)); + window.focus(&pane.read(cx).focus_handle(cx), cx); } else { let future = terminal_panel.new_pane_with_cloned_active_terminal(window, cx); @@ -1490,7 +1490,7 @@ impl Render for TerminalPanel { ) .log_err(); let new_pane = new_pane.read(cx); - window.focus(&new_pane.focus_handle(cx)); + window.focus(&new_pane.focus_handle(cx), cx); }, ); } diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 98f7a17a2778e05b258f2ab6135cb94ba91ba547..54808934ba7b098a695a8b104a048a379966e6f1 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -409,7 +409,7 @@ impl TerminalView { ) }); - window.focus(&context_menu.focus_handle(cx)); + window.focus(&context_menu.focus_handle(cx), cx); let subscription = cx.subscribe_in( &context_menu, window, diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 4d7397a0bc82142245b86c11ffdf441a6b781ad8..23572677919509d859a141cb09cce8f5822697ef 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -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) -> Option { + 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) -> Option { if self.project.read(cx).is_via_remote_server() { return self.render_remote_project_connection(cx); @@ -555,7 +605,7 @@ impl TitleBar { }) .on_click(move |_, window, cx| { let _ = workspace.update(cx, |this, cx| { - window.focus(&this.active_pane().focus_handle(cx)); + window.focus(&this.active_pane().focus_handle(cx), cx); window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx); }); }) diff --git a/crates/toolchain_selector/src/toolchain_selector.rs b/crates/toolchain_selector/src/toolchain_selector.rs index b58b2f8d699f59c15525c452543cf5bdf071ad2c..f7262c248f15f0f68fcd7a903ee01cac6b22d0af 100644 --- a/crates/toolchain_selector/src/toolchain_selector.rs +++ b/crates/toolchain_selector/src/toolchain_selector.rs @@ -225,7 +225,7 @@ impl AddToolchainState { ); }); *input_state = Self::wait_for_path(rx, window, cx); - this.focus_handle(cx).focus(window); + this.focus_handle(cx).focus(window, cx); } }); return Err(anyhow::anyhow!("Failed to resolve toolchain")); @@ -260,7 +260,7 @@ impl AddToolchainState { toolchain, scope_picker, }; - this.focus_handle(cx).focus(window); + this.focus_handle(cx).focus(window, cx); }); Result::<_, anyhow::Error>::Ok(()) @@ -333,7 +333,7 @@ impl AddToolchainState { }); _ = self.weak.update(cx, |this, cx| { this.state = State::Search((this.create_search_state)(window, cx)); - this.focus_handle(cx).focus(window); + this.focus_handle(cx).focus(window, cx); cx.notify(); }); } @@ -383,7 +383,7 @@ impl Render for AddToolchainState { &weak, |this: &mut ToolchainSelector, _: &menu::Cancel, window, cx| { this.state = State::Search((this.create_search_state)(window, cx)); - this.state.focus_handle(cx).focus(window); + this.state.focus_handle(cx).focus(window, cx); cx.notify(); }, )) @@ -703,7 +703,7 @@ impl ToolchainSelector { window, cx, )); - self.state.focus_handle(cx).focus(window); + self.state.focus_handle(cx).focus(window, cx); cx.notify(); } } diff --git a/crates/ui/src/components.rs b/crates/ui/src/components.rs index c9cb943277c6c6a5e6bc1b472040c31d9caac45c..c08e46c5882cf3c9e0a8e205c8b23224d3a7a8e1 100644 --- a/crates/ui/src/components.rs +++ b/crates/ui/src/components.rs @@ -17,7 +17,6 @@ mod icon; mod image; mod indent_guides; mod indicator; -mod inline_code; mod keybinding; mod keybinding_hint; mod label; @@ -64,7 +63,6 @@ pub use icon::*; pub use image::*; pub use indent_guides::*; pub use indicator::*; -pub use inline_code::*; pub use keybinding::*; pub use keybinding_hint::*; pub use label::*; diff --git a/crates/ui/src/components/context_menu.rs b/crates/ui/src/components/context_menu.rs index a4bae647408f860ec8425266a26efc173099f225..756a2a9364193d6f1cdace8ed8c92cecf401a864 100644 --- a/crates/ui/src/components/context_menu.rs +++ b/crates/ui/src/components/context_menu.rs @@ -562,7 +562,7 @@ impl ContextMenu { action: Some(action.boxed_clone()), handler: Rc::new(move |context, window, cx| { if let Some(context) = &context { - window.focus(context); + window.focus(context, cx); } window.dispatch_action(action.boxed_clone(), cx); }), @@ -594,7 +594,7 @@ impl ContextMenu { action: Some(action.boxed_clone()), handler: Rc::new(move |context, window, cx| { if let Some(context) = &context { - window.focus(context); + window.focus(context, cx); } window.dispatch_action(action.boxed_clone(), cx); }), diff --git a/crates/ui/src/components/inline_code.rs b/crates/ui/src/components/inline_code.rs deleted file mode 100644 index 43507127fef478e5a38cfad2d84446673af15f2e..0000000000000000000000000000000000000000 --- a/crates/ui/src/components/inline_code.rs +++ /dev/null @@ -1,64 +0,0 @@ -use crate::prelude::*; -use gpui::{AnyElement, IntoElement, ParentElement, Styled}; - -/// InlineCode mimics the way inline code is rendered when wrapped in backticks in Markdown. -/// -/// # Usage Example -/// -/// ``` -/// use ui::InlineCode; -/// -/// let InlineCode = InlineCode::new("
hey
"); -/// ``` -#[derive(IntoElement, RegisterComponent)] -pub struct InlineCode { - label: SharedString, - label_size: LabelSize, -} - -impl InlineCode { - pub fn new(label: impl Into) -> Self { - Self { - label: label.into(), - label_size: LabelSize::Default, - } - } - - /// Sets the size of the label. - pub fn label_size(mut self, size: LabelSize) -> Self { - self.label_size = size; - self - } -} - -impl RenderOnce for InlineCode { - fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { - h_flex() - .min_w_0() - .px_0p5() - .overflow_hidden() - .bg(cx.theme().colors().text.opacity(0.05)) - .child(Label::new(self.label).size(self.label_size).buffer_font(cx)) - } -} - -impl Component for InlineCode { - fn scope() -> ComponentScope { - ComponentScope::DataDisplay - } - - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .child( - example_group(vec![single_example( - "Simple", - InlineCode::new("zed.dev").into_any_element(), - )]) - .vertical(), - ) - .into_any_element(), - ) - } -} diff --git a/crates/ui/src/components/navigable.rs b/crates/ui/src/components/navigable.rs index a592bcc36f4cc490c4676a83660ace050025ee39..07e761f9c0c14daf551d272c1a1894da84e1b3cf 100644 --- a/crates/ui/src/components/navigable.rs +++ b/crates/ui/src/components/navigable.rs @@ -75,7 +75,7 @@ impl RenderOnce for Navigable { }) .unwrap_or(0); if let Some(entry) = children.get(target) { - entry.focus_handle.focus(window); + entry.focus_handle.focus(window, cx); if let Some(anchor) = &entry.scroll_anchor { anchor.scroll_to(window, cx); } @@ -89,7 +89,7 @@ impl RenderOnce for Navigable { .and_then(|index| index.checked_sub(1)) .or(children.len().checked_sub(1)); if let Some(entry) = target.and_then(|target| children.get(target)) { - entry.focus_handle.focus(window); + entry.focus_handle.focus(window, cx); if let Some(anchor) = &entry.scroll_anchor { anchor.scroll_to(window, cx); } diff --git a/crates/ui/src/components/notification/alert_modal.rs b/crates/ui/src/components/notification/alert_modal.rs index 9990dc1ce5f13e6834a009c4b8d7c14b594ccf36..52a084c847887a4dea7fd8b9a3fbad8390f68863 100644 --- a/crates/ui/src/components/notification/alert_modal.rs +++ b/crates/ui/src/components/notification/alert_modal.rs @@ -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) -> Stateful
>; #[derive(IntoElement, RegisterComponent)] pub struct AlertModal { id: ElementId, + header: Option, children: SmallVec<[AnyElement; 2]>, - title: SharedString, - primary_action: SharedString, - dismiss_label: SharedString, + footer: Option, + title: Option, + primary_action: Option, + dismiss_label: Option, + width: Option, + key_context: Option, + action_handlers: Vec, + focus_handle: Option, } impl AlertModal { - pub fn new(id: impl Into, title: impl Into) -> Self { + pub fn new(id: impl Into) -> 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) -> 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) -> 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) -> Self { - self.dismiss_label = dismiss_label.into(); + self.dismiss_label = Some(dismiss_label.into()); + self + } + + pub fn width(mut self, width: impl Into) -> Self { + self.width = Some(width.into()); + self + } + + pub fn key_context(mut self, key_context: impl Into) -> Self { + self.key_context = Some(key_context.into()); + self + } + + pub fn on_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 { + fn preview(_window: &mut Window, cx: &mut App) -> Option { 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(), ) } } diff --git a/crates/ui/src/components/popover_menu.rs b/crates/ui/src/components/popover_menu.rs index b1a52bec8fdf1f7030b5b321bed7702d602ff212..cd79e50ce01b1f4e697b252801c2ae76765726d2 100644 --- a/crates/ui/src/components/popover_menu.rs +++ b/crates/ui/src/components/popover_menu.rs @@ -281,13 +281,25 @@ fn show_menu( if modal.focus_handle(cx).contains_focused(window, cx) && let Some(previous_focus_handle) = previous_focus_handle.as_ref() { - window.focus(previous_focus_handle); + window.focus(previous_focus_handle, cx); } *menu2.borrow_mut() = None; window.refresh(); }) .detach(); - window.focus(&new_menu.focus_handle(cx)); + + // Since menus are rendered in a deferred fashion, their focus handles are + // not linked in the dispatch tree until after the deferred draw callback + // runs. We need to wait for that to happen before focusing it, so that + // calling `contains_focused` on the parent's focus handle returns `true` + // when the menu is focused. This prevents the pane's tab bar buttons from + // flickering when opening popover menus. + let focus_handle = new_menu.focus_handle(cx); + window.on_next_frame(move |window, _cx| { + window.on_next_frame(move |window, cx| { + window.focus(&focus_handle, cx); + }); + }); *menu.borrow_mut() = Some(new_menu); window.refresh(); diff --git a/crates/ui/src/components/right_click_menu.rs b/crates/ui/src/components/right_click_menu.rs index dff423073710121bb0bc0fafdb8ab3108b746bde..faf2cb3429b610727209e13188656c174aefb655 100644 --- a/crates/ui/src/components/right_click_menu.rs +++ b/crates/ui/src/components/right_click_menu.rs @@ -253,13 +253,25 @@ impl Element for RightClickMenu { && let Some(previous_focus_handle) = previous_focus_handle.as_ref() { - window.focus(previous_focus_handle); + window.focus(previous_focus_handle, cx); } *menu2.borrow_mut() = None; window.refresh(); }) .detach(); - window.focus(&new_menu.focus_handle(cx)); + + // Since menus are rendered in a deferred fashion, their focus handles are + // not linked in the dispatch tree until after the deferred draw callback + // runs. We need to wait for that to happen before focusing it, so that + // calling `contains_focused` on the parent's focus handle returns `true` + // when the menu is focused. This prevents the pane's tab bar buttons from + // flickering when opening menus. + let focus_handle = new_menu.focus_handle(cx); + window.on_next_frame(move |window, _cx| { + window.on_next_frame(move |window, cx| { + window.focus(&focus_handle, cx); + }); + }); *menu.borrow_mut() = Some(new_menu); *position.borrow_mut() = if let Some(child_bounds) = child_bounds { if let Some(attach) = attach { diff --git a/crates/ui_input/src/number_field.rs b/crates/ui_input/src/number_field.rs index ee5c57b43b7c44db1c2ded122d3d4272a541c32e..2d596a2498f445f6a0d18ce48b02bddf20aee8da 100644 --- a/crates/ui_input/src/number_field.rs +++ b/crates/ui_input/src/number_field.rs @@ -476,7 +476,7 @@ impl RenderOnce for NumberField { if let Some(previous) = previous_focus_handle.as_ref() { - window.focus(previous); + window.focus(previous, cx); } on_change(&new_value, window, cx); }; @@ -485,7 +485,7 @@ impl RenderOnce for NumberField { }) .detach(); - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); editor } diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index 6ba28a1c236ada7c08eeabac9d9189991434a807..f2e629faf2dd4a5d1ff47a49278cdd022f75d8d4 100644 --- a/crates/vim/src/motion.rs +++ b/crates/vim/src/motion.rs @@ -1,5 +1,5 @@ use editor::{ - Anchor, Bias, BufferOffset, DisplayPoint, Editor, MultiBufferOffset, RowExt, ToOffset, ToPoint, + Anchor, Bias, BufferOffset, DisplayPoint, Editor, MultiBufferOffset, RowExt, ToOffset, display_map::{DisplayRow, DisplaySnapshot, FoldPoint, ToDisplayPoint}, movement::{ self, FindRange, TextLayoutDetails, find_boundary, find_preceding_boundary_display_point, @@ -2262,7 +2262,6 @@ fn go_to_line(map: &DisplaySnapshot, display_point: DisplayPoint, line: usize) - .offset_to_point(excerpt.map_offset_from_buffer(BufferOffset(offset))); return map.clip_point(map.point_to_display_point(point, Bias::Left), Bias::Left); } - let mut last_position = None; for (excerpt, buffer, range) in map.buffer_snapshot().excerpts() { let excerpt_range = language::ToOffset::to_offset(&range.context.start, buffer) ..language::ToOffset::to_offset(&range.context.end, buffer); @@ -2273,14 +2272,9 @@ fn go_to_line(map: &DisplaySnapshot, display_point: DisplayPoint, line: usize) - } else if offset <= excerpt_range.start { let anchor = Anchor::in_buffer(excerpt, range.context.start); return anchor.to_display_point(map); - } else { - last_position = Some(Anchor::in_buffer(excerpt, range.context.end)); } } - let mut last_point = last_position.unwrap().to_point(&map.buffer_snapshot()); - last_point.column = point.column; - map.clip_point( map.point_to_display_point( map.buffer_snapshot().clip_point(point, Bias::Left), diff --git a/crates/vim/src/object.rs b/crates/vim/src/object.rs index f11386d02d6846343645b6c7514603f16396163c..02150332405c6d5ea4d5dd78f477348be968fddf 100644 --- a/crates/vim/src/object.rs +++ b/crates/vim/src/object.rs @@ -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())); @@ -2807,9 +2807,8 @@ mod test { for (keystrokes, initial_state, expected_state, expected_mode) in TEST_CASES { cx.set_state(initial_state, Mode::Normal); - + cx.buffer(|buffer, _| buffer.parsing_idle()).await; cx.simulate_keystrokes(keystrokes); - cx.assert_state(expected_state, *expected_mode); } @@ -2830,9 +2829,8 @@ mod test { for (keystrokes, initial_state, mode) in INVALID_CASES { cx.set_state(initial_state, Mode::Normal); - + cx.buffer(|buffer, _| buffer.parsing_idle()).await; cx.simulate_keystrokes(keystrokes); - cx.assert_state(initial_state, *mode); } } @@ -3185,9 +3183,8 @@ mod test { for (keystrokes, initial_state, expected_state, expected_mode) in TEST_CASES { cx.set_state(initial_state, Mode::Normal); - + cx.buffer(|buffer, _| buffer.parsing_idle()).await; cx.simulate_keystrokes(keystrokes); - cx.assert_state(expected_state, *expected_mode); } @@ -3208,9 +3205,8 @@ mod test { for (keystrokes, initial_state, mode) in INVALID_CASES { cx.set_state(initial_state, Mode::Normal); - + cx.buffer(|buffer, _| buffer.parsing_idle()).await; cx.simulate_keystrokes(keystrokes); - cx.assert_state(initial_state, *mode); } } diff --git a/crates/workspace/src/dock.rs b/crates/workspace/src/dock.rs index edc5705a28ecd7d378c0f959ac82a6493c82d325..b358cf7b53ff16bae3756499470a2a55211618a8 100644 --- a/crates/workspace/src/dock.rs +++ b/crates/workspace/src/dock.rs @@ -350,7 +350,7 @@ impl Dock { let focus_subscription = cx.on_focus(&focus_handle, window, |dock: &mut Dock, window, cx| { if let Some(active_entry) = dock.active_panel_entry() { - active_entry.panel.panel_focus_handle(cx).focus(window) + active_entry.panel.panel_focus_handle(cx).focus(window, cx) } }); let zoom_subscription = cx.subscribe(&workspace, |dock, workspace, e: &Event, cx| { @@ -593,7 +593,7 @@ impl Dock { this.set_panel_zoomed(&panel.to_any(), true, window, cx); if !PanelHandle::panel_focus_handle(panel, cx).contains_focused(window, cx) { - window.focus(&panel.focus_handle(cx)); + window.focus(&panel.focus_handle(cx), cx); } workspace .update(cx, |workspace, cx| { @@ -625,7 +625,7 @@ impl Dock { { this.set_open(true, window, cx); this.activate_panel(ix, window, cx); - window.focus(&panel.read(cx).focus_handle(cx)); + window.focus(&panel.read(cx).focus_handle(cx), cx); } } PanelEvent::Close => { @@ -1052,7 +1052,7 @@ impl Render for PanelButtons { name = name, toggle_state = !is_open ); - window.focus(&focus_handle); + window.focus(&focus_handle, cx); window.dispatch_action(action.boxed_clone(), cx) } }) diff --git a/crates/workspace/src/item.rs b/crates/workspace/src/item.rs index bb4b10fa63dc884b8cf0ab8eee8e3bc34880b2a5..4bde632ce720dad792d19677c60ab62fd51d3637 100644 --- a/crates/workspace/src/item.rs +++ b/crates/workspace/src/item.rs @@ -1042,7 +1042,7 @@ impl ItemHandle for Entity { fn relay_action(&self, action: Box, window: &mut Window, cx: &mut App) { self.update(cx, |this, cx| { - this.focus_handle(cx).focus(window); + this.focus_handle(cx).focus(window, cx); window.dispatch_action(action, cx); }) } diff --git a/crates/workspace/src/modal_layer.rs b/crates/workspace/src/modal_layer.rs index bcd7db3a82aec46405927e118af86cf4a0d4912b..10b24497a28faf68ed0820211f0d8860da558786 100644 --- a/crates/workspace/src/modal_layer.rs +++ b/crates/workspace/src/modal_layer.rs @@ -116,7 +116,7 @@ impl ModalLayer { focus_handle, }); cx.defer_in(window, move |_, window, cx| { - window.focus(&new_modal.focus_handle(cx)); + window.focus(&new_modal.focus_handle(cx), cx); }); cx.notify(); } @@ -144,7 +144,7 @@ impl ModalLayer { if let Some(previous_focus) = active_modal.previous_focus_handle && active_modal.focus_handle.contains_focused(window, cx) { - previous_focus.focus(window); + previous_focus.focus(window, cx); } cx.notify(); } @@ -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( diff --git a/crates/workspace/src/oauth_device_flow_modal.rs b/crates/workspace/src/oauth_device_flow_modal.rs index a6085b7e18b9aa9097c91048306b4c2c2015c9c9..629f3ca4b7ded924bbace85d7b2c36ad2c57c2b5 100644 --- a/crates/workspace/src/oauth_device_flow_modal.rs +++ b/crates/workspace/src/oauth_device_flow_modal.rs @@ -285,8 +285,8 @@ impl Render for OAuthDeviceFlowModal { .on_action(cx.listener(|_, _: &menu::Cancel, _, cx| { cx.emit(DismissEvent); })) - .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _| { - window.focus(&this.focus_handle); + .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| { + window.focus(&this.focus_handle, cx); })) .child(self.render_icon(cx)) .child(prompt) diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 036723c13755ff2a7b2b10e9684d822f239a8e0b..f6256aee46b9e2b5c29c020e9ee12f6ff510210f 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -625,11 +625,11 @@ impl Pane { self.last_focus_handle_by_item.get(&active_item.item_id()) && let Some(focus_handle) = weak_last_focus_handle.upgrade() { - focus_handle.focus(window); + focus_handle.focus(window, cx); return; } - active_item.item_focus_handle(cx).focus(window); + active_item.item_focus_handle(cx).focus(window, cx); } else if let Some(focused) = window.focused(cx) && !self.context_menu_focused(window, cx) { @@ -638,7 +638,7 @@ impl Pane { } } else if let Some(welcome_page) = self.welcome_page.as_ref() { if self.focus_handle.is_focused(window) { - welcome_page.read(cx).focus_handle(cx).focus(window); + welcome_page.read(cx).focus_handle(cx).focus(window, cx); } } } @@ -1999,7 +1999,7 @@ impl Pane { let should_activate = activate_pane || self.has_focus(window, cx); if self.items.len() == 1 && should_activate { - self.focus_handle.focus(window); + self.focus_handle.focus(window, cx); } else { self.activate_item( index_to_activate, @@ -2350,7 +2350,7 @@ impl Pane { pub fn focus_active_item(&mut self, window: &mut Window, cx: &mut Context) { if let Some(active_item) = self.active_item() { let focus_handle = active_item.item_focus_handle(cx); - window.focus(&focus_handle); + window.focus(&focus_handle, cx); } } diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index f1835caf8dd84e1f729e0415b5711ffa69981d9b..cf5bdf2ab0059f10f2fb44e2069c8c0baf24d72b 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -9,20 +9,26 @@ use std::{ }; use anyhow::{Context as _, Result, bail}; -use collections::{HashMap, IndexSet}; +use collections::{HashMap, HashSet, IndexSet}; use db::{ + kvp::KEY_VALUE_STORE, query, sqlez::{connection::Connection, domain::Domain}, sqlez_macros::sql, }; -use gpui::{Axis, Bounds, Task, WindowBounds, WindowId, point, size}; -use project::debugger::breakpoint_store::{BreakpointState, SourceBreakpoint}; +use gpui::{Axis, Bounds, Entity, Task, WindowBounds, WindowId, point, size}; +use project::{ + debugger::breakpoint_store::{BreakpointState, SourceBreakpoint}, + trusted_worktrees::{PathTrust, RemoteHostLocation, find_worktree_in_store}, + worktree_store::WorktreeStore, +}; use language::{LanguageName, Toolchain, ToolchainScope}; use project::WorktreeId; use remote::{ DockerConnectionOptions, RemoteConnectionOptions, SshConnectionOptions, WslConnectionOptions, }; +use serde::{Deserialize, Serialize}; use sqlez::{ bindable::{Bind, Column, StaticColumnCount}, statement::Statement, @@ -46,6 +52,11 @@ use model::{ use self::model::{DockStructure, SerializedWorkspaceLocation}; +// 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. +const MAX_QUERY_PLACEHOLDERS: usize = 32000; + #[derive(Copy, Clone, Debug, PartialEq)] pub(crate) struct SerializedAxis(pub(crate) gpui::Axis); impl sqlez::bindable::StaticColumnCount for SerializedAxis {} @@ -154,6 +165,124 @@ impl Column for SerializedWindowBounds { } } +const DEFAULT_WINDOW_BOUNDS_KEY: &str = "default_window_bounds"; + +pub fn read_default_window_bounds() -> Option<(Uuid, WindowBounds)> { + let json_str = KEY_VALUE_STORE + .read_kvp(DEFAULT_WINDOW_BOUNDS_KEY) + .log_err() + .flatten()?; + + let (display_uuid, persisted) = + serde_json::from_str::<(Uuid, WindowBoundsJson)>(&json_str).ok()?; + Some((display_uuid, persisted.into())) +} + +pub async fn write_default_window_bounds( + bounds: WindowBounds, + display_uuid: Uuid, +) -> anyhow::Result<()> { + let persisted = WindowBoundsJson::from(bounds); + let json_str = serde_json::to_string(&(display_uuid, persisted))?; + KEY_VALUE_STORE + .write_kvp(DEFAULT_WINDOW_BOUNDS_KEY.to_string(), json_str) + .await?; + Ok(()) +} + +#[derive(Serialize, Deserialize)] +pub enum WindowBoundsJson { + Windowed { + x: i32, + y: i32, + width: i32, + height: i32, + }, + Maximized { + x: i32, + y: i32, + width: i32, + height: i32, + }, + Fullscreen { + x: i32, + y: i32, + width: i32, + height: i32, + }, +} + +impl From for WindowBoundsJson { + fn from(b: WindowBounds) -> Self { + match b { + WindowBounds::Windowed(bounds) => { + let origin = bounds.origin; + let size = bounds.size; + WindowBoundsJson::Windowed { + x: f32::from(origin.x).round() as i32, + y: f32::from(origin.y).round() as i32, + width: f32::from(size.width).round() as i32, + height: f32::from(size.height).round() as i32, + } + } + WindowBounds::Maximized(bounds) => { + let origin = bounds.origin; + let size = bounds.size; + WindowBoundsJson::Maximized { + x: f32::from(origin.x).round() as i32, + y: f32::from(origin.y).round() as i32, + width: f32::from(size.width).round() as i32, + height: f32::from(size.height).round() as i32, + } + } + WindowBounds::Fullscreen(bounds) => { + let origin = bounds.origin; + let size = bounds.size; + WindowBoundsJson::Fullscreen { + x: f32::from(origin.x).round() as i32, + y: f32::from(origin.y).round() as i32, + width: f32::from(size.width).round() as i32, + height: f32::from(size.height).round() as i32, + } + } + } + } +} + +impl From for WindowBounds { + fn from(n: WindowBoundsJson) -> Self { + match n { + WindowBoundsJson::Windowed { + x, + y, + width, + height, + } => WindowBounds::Windowed(Bounds { + origin: point(px(x as f32), px(y as f32)), + size: size(px(width as f32), px(height as f32)), + }), + WindowBoundsJson::Maximized { + x, + y, + width, + height, + } => WindowBounds::Maximized(Bounds { + origin: point(px(x as f32), px(y as f32)), + size: size(px(width as f32), px(height as f32)), + }), + WindowBoundsJson::Fullscreen { + x, + y, + width, + height, + } => WindowBounds::Fullscreen(Bounds { + origin: point(px(x as f32), px(y as f32)), + size: size(px(width as f32), px(height as f32)), + }), + } + } +} + #[derive(Debug)] pub struct Breakpoint { pub position: u32, @@ -708,6 +837,14 @@ impl Domain for WorkspaceDb { ALTER TABLE remote_connections ADD COLUMN name TEXT; ALTER TABLE remote_connections ADD COLUMN container_id TEXT; ), + sql!( + CREATE TABLE IF NOT EXISTS trusted_worktrees ( + trust_id INTEGER PRIMARY KEY AUTOINCREMENT, + absolute_path TEXT, + user_name TEXT, + host_name TEXT + ) STRICT; + ), ]; // Allow recovering from bad migration that was initially shipped to nightly @@ -1136,7 +1273,7 @@ impl WorkspaceDb { match options { RemoteConnectionOptions::Ssh(options) => { kind = RemoteConnectionKind::Ssh; - host = Some(options.host); + host = Some(options.host.to_string()); port = options.port; user = options.username; } @@ -1349,7 +1486,7 @@ impl WorkspaceDb { user: user, })), RemoteConnectionKind::Ssh => Some(RemoteConnectionOptions::Ssh(SshConnectionOptions { - host: host?, + host: host?.into(), port, username: user, ..Default::default() @@ -1364,24 +1501,6 @@ impl WorkspaceDb { } } - pub(crate) fn last_window( - &self, - ) -> anyhow::Result<(Option, Option)> { - let mut prepared_query = - self.select::<(Option, Option)>(sql!( - SELECT - display, - window_state, window_x, window_y, window_width, window_height - FROM workspaces - WHERE paths - IS NOT NULL - ORDER BY timestamp DESC - LIMIT 1 - ))?; - let result = prepared_query()?; - Ok(result.into_iter().next().unwrap_or((None, None))) - } - query! { pub async fn delete_workspace_by_id(id: WorkspaceId) -> Result<()> { DELETE FROM workspaces @@ -1796,6 +1915,135 @@ impl WorkspaceDb { Ok(()) }).await } + + pub(crate) async fn save_trusted_worktrees( + &self, + trusted_worktrees: HashMap, HashSet>, + ) -> anyhow::Result<()> { + use anyhow::Context as _; + use db::sqlez::statement::Statement; + use itertools::Itertools as _; + + 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())) + }) + .collect::>(); + 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::>() + { + 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 fn fetch_trusted_worktrees( + &self, + worktree_store: Option>, + host: Option, + cx: &App, + ) -> Result, HashSet>> { + let trusted_worktrees = DB.trusted_worktrees()?; + Ok(trusted_worktrees + .into_iter() + .filter_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), + }), + }; + + let abs_path = abs_path?; + Some(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)) + }) + }) + .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, Option, Option)>> { + SELECT absolute_path, user_name, host_name + FROM trusted_worktrees + } + } + + query! { + pub async fn clear_trusted_worktrees() -> Result<()> { + DELETE FROM trusted_worktrees + } + } } pub fn delete_unloaded_items( @@ -2503,7 +2751,7 @@ mod tests { let connection_id = db .get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions { - host: "my-host".to_string(), + host: "my-host".into(), port: Some(1234), ..Default::default() })) @@ -2692,7 +2940,7 @@ mod tests { .into_iter() .map(|(host, user)| async { let options = RemoteConnectionOptions::Ssh(SshConnectionOptions { - host: host.to_string(), + host: host.into(), username: Some(user.to_string()), ..Default::default() }); @@ -2783,7 +3031,7 @@ mod tests { let connection_id = db .get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions { - host: host.clone(), + host: host.clone().into(), port, username: user.clone(), ..Default::default() @@ -2794,7 +3042,7 @@ mod tests { // Test that calling the function again with the same parameters returns the same project let same_connection = db .get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions { - host: host.clone(), + host: host.clone().into(), port, username: user.clone(), ..Default::default() @@ -2811,7 +3059,7 @@ mod tests { let different_connection = db .get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions { - host: host2.clone(), + host: host2.clone().into(), port: port2, username: user2.clone(), ..Default::default() @@ -2830,7 +3078,7 @@ mod tests { let connection_id = db .get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions { - host: host.clone(), + host: host.clone().into(), port, username: None, ..Default::default() @@ -2840,7 +3088,7 @@ mod tests { let same_connection_id = db .get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions { - host: host.clone(), + host: host.clone().into(), port, username: user.clone(), ..Default::default() @@ -2870,7 +3118,7 @@ mod tests { ids.push( db.get_or_create_remote_connection(RemoteConnectionOptions::Ssh( SshConnectionOptions { - host: host.clone(), + host: host.clone().into(), port: *port, username: user.clone(), ..Default::default() diff --git a/crates/workspace/src/security_modal.rs b/crates/workspace/src/security_modal.rs new file mode 100644 index 0000000000000000000000000000000000000000..e3b9ab6e72481048d0f78eb07afb72af53810279 --- /dev/null +++ b/crates/workspace/src/security_modal.rs @@ -0,0 +1,349 @@ +//! 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, + home_dir: Option, + trust_parents: bool, + worktree_store: WeakEntity, + remote_host: Option, + focus_handle: FocusHandle, + trusted: Option, +} + +#[derive(Debug, PartialEq, Eq)] +struct RestrictedPath { + abs_path: Arc, + is_file: bool, + host: Option, +} + +impl Focusable for SecurityModal { + fn focus_handle(&self, _: &ui::App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl EventEmitter for SecurityModal {} + +impl ModalView for SecurityModal { + fn fade_out_background(&self) -> bool { + true + } + + fn on_before_dismiss(&mut self, _: &mut Window, _: &mut Context) -> 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) -> 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 = if restricted_path.is_file { + restricted_path.abs_path.parent() + } else { + Some(restricted_path.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!( + "Workspace trust ({}@{})", + user_name, remote_host.host_identifier + ), + None => { + format!("Workspace trust ({})", remote_host.host_identifier) + } + }, + None => "Workspace trust".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, + remote_host: Option>, + cx: &mut Context, + ) -> 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> { + 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.parent()) + .collect::>(); + 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]).display() + ))), + _ => 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) { + 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() + .copied() + .map(PathTrust::Worktree) + .collect::>(); + if self.trust_parents { + paths_to_trust.extend(self.restricted_paths.values().filter_map( + |restricted_paths| { + if restricted_paths.is_file { + None + } else { + let parent_abs_path = + restricted_paths.abs_path.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) { + cx.emit(DismissEvent); + } + + pub fn refresh_restricted_paths(&mut self, cx: &mut Context) { + if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) { + if let Some(worktree_store) = self.worktree_store.upgrade() { + let new_restricted_worktrees = trusted_worktrees + .read(cx) + .restricted_worktrees(worktree_store.read(cx), cx) + .into_iter() + .filter_map(|(worktree_id, abs_path)| { + let worktree = worktree_store.read(cx).worktree_for_id(worktree_id, cx)?; + Some(( + worktree_id, + RestrictedPath { + abs_path, + is_file: worktree.read(cx).is_single_file(), + host: self.remote_host.clone(), + }, + )) + }) + .collect::>(); + + 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(); + } + } +} diff --git a/crates/workspace/src/welcome.rs b/crates/workspace/src/welcome.rs index 93ff1ea266ff9f40b64064ea03d9bd1b91161300..4d84f3072f87ffa3246a313cbc749ddd61287d25 100644 --- a/crates/workspace/src/welcome.rs +++ b/crates/workspace/src/welcome.rs @@ -250,12 +250,12 @@ impl WelcomePage { } fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context) { - window.focus_next(); + window.focus_next(cx); cx.notify(); } fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context) { - window.focus_prev(); + window.focus_prev(cx); cx.notify(); } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 1634b68c5ce8771dc77a8010ef37ff1c6b617449..d358834f80770f26d3b70a88976ccea266f20bca 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -10,6 +10,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; @@ -78,7 +79,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, TrustedWorktreesEvent}, }; use remote::{ RemoteClientDelegate, RemoteConnection, RemoteConnectionOptions, @@ -87,7 +90,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}, @@ -138,6 +143,7 @@ use crate::{ SerializedAxis, model::{DockData, DockStructure, SerializedItem, SerializedPane, SerializedPaneGroup}, }, + security_modal::SecurityModal, utility_pane::{DraggedUtilityPane, UtilityPaneFrame, UtilityPaneSlot, UtilityPaneState}, }; @@ -278,6 +284,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. @@ -1173,6 +1185,7 @@ pub struct Workspace { _observe_current_user: Task>, _schedule_serialize_workspace: Option>, _schedule_serialize_ssh_paths: Option>, + _schedule_serialize_worktree_trust: Task<()>, pane_history_timestamp: Arc, bounds: Bounds, pub centered_layout: bool, @@ -1218,6 +1231,41 @@ impl Workspace { window: &mut Window, cx: &mut Context, ) -> Self { + if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) { + cx.subscribe(&trusted_worktrees, |workspace, worktrees_store, e, cx| { + if let TrustedWorktreesEvent::Trusted(..) = e { + // Do not persist auto trusted worktrees + if !ProjectSettings::get_global(cx).session.trust_all_worktrees { + let new_trusted_worktrees = + worktrees_store.update(cx, |worktrees_store, cx| { + worktrees_store.trusted_paths_for_serialization(cx) + }); + let timeout = cx.background_executor().timer(SERIALIZATION_THROTTLE_TIME); + workspace._schedule_serialize_worktree_trust = + cx.background_spawn(async move { + timeout.await; + persistence::DB + .save_trusted_worktrees(new_trusted_worktrees) + .await + .log_err(); + }); + } + } + }) + .detach(); + + cx.observe_global::(|_, 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(_) => { @@ -1228,11 +1276,25 @@ impl Workspace { this.collaborator_left(*peer_id, window, cx); } - project::Event::WorktreeRemoved(_) | project::Event::WorktreeAdded(_) => { - this.update_window_title(window, cx); - this.serialize_workspace(window, cx); - // This event could be triggered by `AddFolderToProject` or `RemoveFromProject`. - this.update_history(cx); + project::Event::WorktreeUpdatedEntries(worktree_id, _) => { + if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) { + trusted_worktrees.update(cx, |trusted_worktrees, cx| { + trusted_worktrees.can_trust(*worktree_id, cx); + }); + } + } + + project::Event::WorktreeRemoved(_) => { + this.update_worktree_data(window, cx); + } + + project::Event::WorktreeAdded(worktree_id) => { + if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) { + trusted_worktrees.update(cx, |trusted_worktrees, cx| { + trusted_worktrees.can_trust(*worktree_id, cx); + }); + } + this.update_worktree_data(window, cx); } project::Event::DisconnectedFromHost => { @@ -1329,7 +1391,7 @@ impl Workspace { cx.on_focus_lost(window, |this, window, cx| { let focus_handle = this.focus_handle(cx); - window.focus(&focus_handle); + window.focus(&focus_handle, cx); }) .detach(); @@ -1353,7 +1415,7 @@ impl Workspace { cx.subscribe_in(¢er_pane, window, Self::handle_pane_event) .detach(); - window.focus(¢er_pane.focus_handle(cx)); + window.focus(¢er_pane.focus_handle(cx), cx); cx.emit(Event::PaneAdded(center_pane.clone())); @@ -1444,6 +1506,15 @@ impl Workspace { && let Ok(display_uuid) = display.uuid() { let window_bounds = window.inner_window_bounds(); + let has_paths = !this.root_paths(cx).is_empty(); + if !has_paths { + cx.background_executor() + .spawn(persistence::write_default_window_bounds( + window_bounds, + display_uuid, + )) + .detach_and_log_err(cx); + } if let Some(database_id) = workspace_id { cx.background_executor() .spawn(DB.set_window_open_status( @@ -1452,6 +1523,13 @@ impl Workspace { display_uuid, )) .detach_and_log_err(cx); + } else { + cx.background_executor() + .spawn(persistence::write_default_window_bounds( + window_bounds, + display_uuid, + )) + .detach_and_log_err(cx); } } this.bounds_save_task_queued.take(); @@ -1475,7 +1553,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); }); @@ -1518,6 +1596,7 @@ impl Workspace { _apply_leader_updates, _schedule_serialize_workspace: None, _schedule_serialize_ssh_paths: None, + _schedule_serialize_worktree_trust: Task::ready(()), leader_updates_tx, _subscriptions: subscriptions, pane_history_timestamp, @@ -1546,6 +1625,7 @@ impl Workspace { app_state: Arc, requesting_window: Option>, env: Option>, + init: Option) + Send>>, cx: &mut App, ) -> Task< anyhow::Result<( @@ -1560,6 +1640,7 @@ impl Workspace { app_state.languages.clone(), app_state.fs.clone(), env, + true, cx, ); @@ -1652,12 +1733,19 @@ impl Workspace { ); workspace.centered_layout = centered_layout; + + // Call init callback to add items before window renders + if let Some(init) = init { + init(&mut workspace, window, cx); + } + workspace }); })?; window } else { let window_bounds_override = window_bounds_env_override(); + let is_empty_workspace = project_paths.is_empty(); let (window_bounds, display) = if let Some(bounds) = window_bounds_override { (Some(WindowBounds::Windowed(bounds)), None) @@ -1670,6 +1758,13 @@ impl Workspace { } else { (None, None) } + } else if is_empty_workspace { + // Empty workspace - try to restore the last known no-project window bounds + if let Some((display, bounds)) = persistence::read_default_window_bounds() { + (Some(bounds), Some(display)) + } else { + (None, None) + } } else { // New window - let GPUI's default_bounds() handle cascading (None, None) @@ -1695,6 +1790,12 @@ impl Workspace { cx, ); workspace.centered_layout = centered_layout; + + // Call init callback to add items before window renders + if let Some(init) = init { + init(&mut workspace, window, cx); + } + workspace }) } @@ -1954,7 +2055,7 @@ impl Workspace { ) -> Task> { let to_load = if let Some(pane) = pane.upgrade() { pane.update(cx, |pane, cx| { - window.focus(&pane.focus_handle(cx)); + window.focus(&pane.focus_handle(cx), cx); loop { // Retrieve the weak item handle from the history. let entry = pane.nav_history_mut().pop(mode, cx)?; @@ -2260,7 +2361,7 @@ impl Workspace { Task::ready(Ok(callback(self, window, cx))) } else { let env = self.project.read(cx).cli_environment(cx); - let task = Self::new_local(Vec::new(), self.app_state.clone(), None, env, cx); + let task = Self::new_local(Vec::new(), self.app_state.clone(), None, env, None, cx); cx.spawn_in(window, async move |_vh, cx| { let (workspace, _) = task.await?; workspace.update(cx, callback) @@ -3073,7 +3174,7 @@ impl Workspace { } } else { let focus_handle = &active_panel.panel_focus_handle(cx); - window.focus(focus_handle); + window.focus(focus_handle, cx); reveal_dock = true; } } @@ -3085,7 +3186,7 @@ impl Workspace { if focus_center { self.active_pane - .update(cx, |pane, cx| window.focus(&pane.focus_handle(cx))) + .update(cx, |pane, cx| window.focus(&pane.focus_handle(cx), cx)) } cx.notify(); @@ -3253,7 +3354,7 @@ impl Workspace { if let Some(panel) = panel.as_ref() { if should_focus(&**panel, window, cx) { dock.set_open(true, window, cx); - panel.panel_focus_handle(cx).focus(window); + panel.panel_focus_handle(cx).focus(window, cx); } else { focus_center = true; } @@ -3263,7 +3364,7 @@ impl Workspace { if focus_center { self.active_pane - .update(cx, |pane, cx| window.focus(&pane.focus_handle(cx))) + .update(cx, |pane, cx| window.focus(&pane.focus_handle(cx), cx)) } result_panel = panel; @@ -3337,7 +3438,7 @@ impl Workspace { if focus_center { self.active_pane - .update(cx, |pane, cx| window.focus(&pane.focus_handle(cx))) + .update(cx, |pane, cx| window.focus(&pane.focus_handle(cx), cx)) } if self.zoomed_position != dock_to_reveal { @@ -3368,7 +3469,7 @@ impl Workspace { .detach(); self.panes.push(pane.clone()); - window.focus(&pane.focus_handle(cx)); + window.focus(&pane.focus_handle(cx), cx); cx.emit(Event::PaneAdded(pane.clone())); pane @@ -3763,7 +3864,7 @@ impl Workspace { ) { let panes = self.center.panes(); if let Some(pane) = panes.get(action.0).map(|p| (*p).clone()) { - window.focus(&pane.focus_handle(cx)); + window.focus(&pane.focus_handle(cx), cx); } else { self.split_and_clone(self.active_pane.clone(), SplitDirection::Right, window, cx) .detach(); @@ -3833,7 +3934,7 @@ impl Workspace { if let Some(ix) = panes.iter().position(|pane| **pane == self.active_pane) { let next_ix = (ix + 1) % panes.len(); let next_pane = panes[next_ix].clone(); - window.focus(&next_pane.focus_handle(cx)); + window.focus(&next_pane.focus_handle(cx), cx); } } @@ -3842,7 +3943,7 @@ impl Workspace { if let Some(ix) = panes.iter().position(|pane| **pane == self.active_pane) { let prev_ix = cmp::min(ix.wrapping_sub(1), panes.len() - 1); let prev_pane = panes[prev_ix].clone(); - window.focus(&prev_pane.focus_handle(cx)); + window.focus(&prev_pane.focus_handle(cx), cx); } } @@ -3938,7 +4039,7 @@ impl Workspace { Some(ActivateInDirectionTarget::Pane(pane)) => { let pane = pane.read(cx); if let Some(item) = pane.active_item() { - item.item_focus_handle(cx).focus(window); + item.item_focus_handle(cx).focus(window, cx); } else { log::error!( "Could not find a focus target when in switching focus in {direction} direction for a pane", @@ -3950,7 +4051,7 @@ impl Workspace { window.defer(cx, move |window, cx| { let dock = dock.read(cx); if let Some(panel) = dock.active_panel() { - panel.panel_focus_handle(cx).focus(window); + panel.panel_focus_handle(cx).focus(window, cx); } else { log::error!("Could not find a focus target when in switching focus in {direction} direction for a {:?} dock", dock.position()); } @@ -4570,7 +4671,7 @@ impl Workspace { // if you're already following, find the right pane and focus it. if let Some(follower_state) = self.follower_states.get(&leader_id) { - window.focus(&follower_state.pane().focus_handle(cx)); + window.focus(&follower_state.pane().focus_handle(cx), cx); return; } @@ -5382,12 +5483,12 @@ impl Workspace { ) { self.panes.retain(|p| p != pane); if let Some(focus_on) = focus_on { - focus_on.update(cx, |pane, cx| window.focus(&pane.focus_handle(cx))); + focus_on.update(cx, |pane, cx| window.focus(&pane.focus_handle(cx), cx)); } else if self.active_pane() == pane { self.panes .last() .unwrap() - .update(cx, |pane, cx| window.focus(&pane.focus_handle(cx))); + .update(cx, |pane, cx| window.focus(&pane.focus_handle(cx), cx)); } if self.last_active_center_pane == Some(pane.downgrade()) { self.last_active_center_pane = None; @@ -5939,6 +6040,27 @@ 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) { + trusted_worktrees.update(cx, |trusted_worktrees, _| { + trusted_worktrees.clear_trusted_paths() + }); + let clear_task = persistence::DB.clear_trusted_worktrees(); + cx.spawn(async move |_, cx| { + if clear_task.await.log_err().is_some() { + cx.update(|cx| reload(cx)).ok(); + } + }) + .detach(); + } + }), + ) .on_action(cx.listener( |workspace: &mut Workspace, _: &ReopenClosedItem, window, cx| { workspace.reopen_closed_item(window, cx).detach(); @@ -6124,7 +6246,7 @@ impl Workspace { let workspace = Self::new(Default::default(), project, app_state, window, cx); workspace .active_pane - .update(cx, |pane, cx| window.focus(&pane.focus_handle(cx))); + .update(cx, |pane, cx| window.focus(&pane.focus_handle(cx), cx)); workspace } @@ -6419,6 +6541,48 @@ 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, + ) { + if let Some(security_modal) = self.active_modal::(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 update_worktree_data(&mut self, window: &mut Window, cx: &mut Context<'_, Self>) { + self.update_window_title(window, cx); + self.serialize_workspace(window, cx); + // This event could be triggered by `AddFolderToProject` or `RemoveFromProject`. + self.update_history(cx); + } } fn leader_border_for_pane( @@ -7605,7 +7769,14 @@ pub fn join_channel( // no open workspaces, make one to show the error in (blergh) let (window_handle, _) = cx .update(|cx| { - Workspace::new_local(vec![], app_state.clone(), requesting_window, None, cx) + Workspace::new_local( + vec![], + app_state.clone(), + requesting_window, + None, + None, + cx, + ) })? .await?; @@ -7671,7 +7842,7 @@ pub async fn get_any_active_workspace( // find an existing workspace to focus and show call controls let active_window = activate_any_workspace_window(&mut cx); if active_window.is_none() { - cx.update(|cx| Workspace::new_local(vec![], app_state.clone(), None, None, cx))? + cx.update(|cx| Workspace::new_local(vec![], app_state.clone(), None, None, None, cx))? .await?; } activate_any_workspace_window(&mut cx).context("could not open zed") @@ -7838,6 +8009,7 @@ pub fn open_paths( app_state.clone(), open_options.replace_window, open_options.env, + None, cx, ) })? @@ -7882,14 +8054,17 @@ pub fn open_new( cx: &mut App, init: impl FnOnce(&mut Workspace, &mut Window, &mut Context) + 'static + Send, ) -> Task> { - let task = Workspace::new_local(Vec::new(), app_state, None, open_options.env, cx); - cx.spawn(async move |cx| { - let (workspace, opened_paths) = task.await?; - workspace.update(cx, |workspace, window, cx| { - if opened_paths.is_empty() { - init(workspace, window, cx) - } - })?; + let task = Workspace::new_local( + Vec::new(), + app_state, + None, + open_options.env, + Some(Box::new(init)), + cx, + ); + cx.spawn(async move |_cx| { + let (_workspace, _opened_paths) = task.await?; + // Init callback is called synchronously during workspace creation Ok(()) }) } @@ -7969,6 +8144,7 @@ pub fn open_remote_project_with_new_connection( app_state.user_store.clone(), app_state.languages.clone(), app_state.fs.clone(), + true, cx, ) })?; @@ -8544,7 +8720,7 @@ fn move_all_items( // This automatically removes duplicate items in the pane to_pane.update(cx, |destination, cx| { destination.add_item(item_handle, true, true, None, window, cx); - window.focus(&destination.focus_handle(cx)) + window.focus(&destination.focus_handle(cx), cx) }); } } @@ -8588,7 +8764,7 @@ pub fn move_item( cx, ); if activate { - window.focus(&destination.focus_handle(cx)) + window.focus(&destination.focus_handle(cx), cx) } }); } @@ -8690,14 +8866,13 @@ pub fn remote_workspace_position_from_db( } else { let restorable_bounds = serialized_workspace .as_ref() - .and_then(|workspace| Some((workspace.display?, workspace.window_bounds?))) - .or_else(|| { - let (display, window_bounds) = DB.last_window().log_err()?; - Some((display?, window_bounds?)) - }); + .and_then(|workspace| { + Some((workspace.display?, workspace.window_bounds.map(|b| b.0)?)) + }) + .or_else(|| persistence::read_default_window_bounds()); - if let Some((serialized_display, serialized_status)) = restorable_bounds { - (Some(serialized_status.0), Some(serialized_display)) + if let Some((serialized_display, serialized_bounds)) = restorable_bounds { + (Some(serialized_bounds), Some(serialized_display)) } else { (None, None) } diff --git a/crates/worktree/src/ignore.rs b/crates/worktree/src/ignore.rs index 17c362e2d7f78384fe3b9b444353d302c4dac4c5..87487c36df6dc4eca3da43eaab95f83847ba5d1f 100644 --- a/crates/worktree/src/ignore.rs +++ b/crates/worktree/src/ignore.rs @@ -13,6 +13,10 @@ pub enum IgnoreStackEntry { Global { ignore: Arc, }, + RepoExclude { + ignore: Arc, + parent: Arc, + }, Some { abs_base_path: Arc, ignore: Arc, @@ -21,6 +25,12 @@ pub enum IgnoreStackEntry { All, } +#[derive(Debug)] +pub enum IgnoreKind { + Gitignore(Arc), + RepoExclude, +} + impl IgnoreStack { pub fn none() -> Self { Self { @@ -43,13 +53,19 @@ impl IgnoreStack { } } - pub fn append(self, abs_base_path: Arc, ignore: Arc) -> Self { + pub fn append(self, kind: IgnoreKind, ignore: Arc) -> 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, diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index e1ce31c038de9136109c3c8566e5e497dfa4f239..6ec19493840da0b9de3eb55ac483488339ec5e8d 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -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>, + /// 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, 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, 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, bool)>, + Option>, 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( diff --git a/crates/worktree/src/worktree_tests.rs b/crates/worktree/src/worktree_tests.rs index e58e99ea68ebde51a6c12abfd859296b3cd883c4..12f2863aab6c4b4376157f3499fa332051a4822f 100644 --- a/crates/worktree/src/worktree_tests.rs +++ b/crates/worktree/src/worktree_tests.rs @@ -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, diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 141de1139fb571020377ef9b115ed8204bad100b..955540843489ac21d79042854eb6fcebf5f64318 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -2,7 +2,7 @@ description = "The fast, collaborative code editor." edition.workspace = true name = "zed" -version = "0.218.0" +version = "0.219.0" publish.workspace = true license = "GPL-3.0-or-later" authors = ["Zed Team "] diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 89260c4da665cb60761a771d9e9bb530ffd3ba95..f3553473316d446ae5b1d831cbef07b179525532 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -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}; @@ -406,6 +406,14 @@ pub fn main() { }); app.run(move |cx| { + let trusted_paths = match workspace::WORKSPACE_DB.fetch_trusted_worktrees(None, None, cx) { + Ok(trusted_paths) => trusted_paths, + Err(e) => { + log::error!("Failed to do initial trusted worktrees fetch: {e:#}"); + HashMap::default() + } + }; + trusted_worktrees::init(trusted_paths, None, None, cx); menu::init(); zed_actions::init(); @@ -474,6 +482,7 @@ pub fn main() { tx.send(Some(options)).log_err(); }) .detach(); + let node_runtime = NodeRuntime::new(client.http_client(), Some(shell_env_loaded_rx), rx); debug_adapter_extension::init(extension_host_proxy.clone(), cx); @@ -806,7 +815,7 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut workspace::get_any_active_workspace(app_state, cx.clone()).await?; workspace.update(cx, |workspace, window, cx| { if let Some(panel) = workspace.panel::(cx) { - panel.focus_handle(cx).focus(window); + panel.focus_handle(cx).focus(window, cx); } }) }) @@ -887,6 +896,44 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut }) .detach_and_log_err(cx); } + OpenRequestKind::GitCommit { sha } => { + cx.spawn(async move |cx| { + let paths_with_position = + derive_paths_with_position(app_state.fs.as_ref(), request.open_paths).await; + let (workspace, _results) = open_paths_with_positions( + &paths_with_position, + &[], + app_state, + workspace::OpenOptions::default(), + cx, + ) + .await?; + + workspace + .update(cx, |workspace, window, cx| { + let Some(repo) = workspace.project().read(cx).active_repository(cx) + else { + log::error!("no active repository found for commit view"); + return Err(anyhow::anyhow!("no active repository found")); + }; + + git_ui::commit_view::CommitView::open( + sha, + repo.downgrade(), + workspace.weak_handle(), + None, + None, + window, + cx, + ); + Ok(()) + }) + .log_err(); + + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } } return; diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index c1d98936aa2ad20e6eef7f18bfed2d2c0615395a..f6218c97c31b98db76a2ae46b3f89876d426ac33 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -353,6 +353,8 @@ pub fn initialize_workspace( ) { let mut _on_close_subscription = bind_on_window_closed(cx); cx.observe_global::(move |cx| { + // A 1.92 regression causes unused-assignment to trigger on this variable. + _ = _on_close_subscription.is_some(); _on_close_subscription = bind_on_window_closed(cx); }) .detach(); @@ -475,7 +477,7 @@ pub fn initialize_workspace( initialize_panels(prompt_builder.clone(), window, cx); register_actions(app_state.clone(), workspace, window, cx); - workspace.focus_handle(cx).focus(window); + workspace.focus_handle(cx).focus(window, cx); }) .detach(); } @@ -1109,7 +1111,21 @@ fn register_actions( cx, |workspace, window, cx| { cx.activate(true); - Editor::new_file(workspace, &Default::default(), window, cx) + // Create buffer synchronously to avoid flicker + let project = workspace.project().clone(); + let buffer = project.update(cx, |project, cx| { + project.create_local_buffer("", None, true, cx) + }); + let editor = cx.new(|cx| { + Editor::for_buffer(buffer, Some(project), window, cx) + }); + workspace.add_item_to_active_pane( + Box::new(editor), + None, + true, + window, + cx, + ); }, ) .detach(); diff --git a/crates/zed/src/zed/component_preview.rs b/crates/zed/src/zed/component_preview.rs index 14a46d8882d1d3d371c50e9886062a124917a48d..e3c7fc8df542448d5b8b290e96405546be7b4b1e 100644 --- a/crates/zed/src/zed/component_preview.rs +++ b/crates/zed/src/zed/component_preview.rs @@ -161,7 +161,7 @@ impl ComponentPreview { component_preview.update_component_list(cx); let focus_handle = component_preview.filter_editor.read(cx).focus_handle(cx); - window.focus(&focus_handle); + window.focus(&focus_handle, cx); Ok(component_preview) } @@ -770,7 +770,7 @@ impl Item for ComponentPreview { self.workspace_id = workspace.database_id(); let focus_handle = self.filter_editor.read(cx).focus_handle(cx); - window.focus(&focus_handle); + window.focus(&focus_handle, cx); } } diff --git a/crates/zed/src/zed/open_listener.rs b/crates/zed/src/zed/open_listener.rs index 6352c20e5c0dcd0bd25063ca3a7bbcae87e48e3f..d61de0a291f3d3e7869225c0e07424cc3523f69b 100644 --- a/crates/zed/src/zed/open_listener.rs +++ b/crates/zed/src/zed/open_listener.rs @@ -58,6 +58,9 @@ pub enum OpenRequestKind { /// `None` opens settings without navigating to a specific path. setting_path: Option, }, + GitCommit { + sha: String, + }, } impl OpenRequest { @@ -110,6 +113,8 @@ impl OpenRequest { this.kind = Some(OpenRequestKind::Setting { setting_path: Some(setting_path.to_string()), }); + } else if let Some(commit_path) = url.strip_prefix("zed://git/commit/") { + this.parse_git_commit_url(commit_path)? } else if url.starts_with("ssh://") { this.parse_ssh_file_path(&url, cx)? } else if let Some(zed_link) = parse_zed_link(&url, cx) { @@ -138,6 +143,28 @@ impl OpenRequest { } } + fn parse_git_commit_url(&mut self, commit_path: &str) -> Result<()> { + // Format: ?repo= + let (sha, query) = commit_path + .split_once('?') + .context("invalid git commit url: missing query string")?; + anyhow::ensure!(!sha.is_empty(), "invalid git commit url: missing sha"); + + let repo = url::form_urlencoded::parse(query.as_bytes()) + .find_map(|(key, value)| (key == "repo").then_some(value)) + .filter(|s| !s.is_empty()) + .context("invalid git commit url: missing repo query parameter")? + .to_string(); + + self.open_paths.push(repo); + + self.kind = Some(OpenRequestKind::GitCommit { + sha: sha.to_string(), + }); + + Ok(()) + } + fn parse_ssh_file_path(&mut self, file: &str, cx: &App) -> Result<()> { let url = url::Url::parse(file)?; let host = url @@ -688,6 +715,86 @@ mod tests { assert_eq!(request.open_paths, vec!["/"]); } + #[gpui::test] + fn test_parse_git_commit_url(cx: &mut TestAppContext) { + let _app_state = init_test(cx); + + // Test basic git commit URL + let request = cx.update(|cx| { + OpenRequest::parse( + RawOpenRequest { + urls: vec!["zed://git/commit/abc123?repo=path/to/repo".into()], + ..Default::default() + }, + cx, + ) + .unwrap() + }); + + match request.kind.unwrap() { + OpenRequestKind::GitCommit { sha } => { + assert_eq!(sha, "abc123"); + } + _ => panic!("expected GitCommit variant"), + } + // Verify path was added to open_paths for workspace routing + assert_eq!(request.open_paths, vec!["path/to/repo"]); + + // Test with URL encoded path + let request = cx.update(|cx| { + OpenRequest::parse( + RawOpenRequest { + urls: vec!["zed://git/commit/def456?repo=path%20with%20spaces".into()], + ..Default::default() + }, + cx, + ) + .unwrap() + }); + + match request.kind.unwrap() { + OpenRequestKind::GitCommit { sha } => { + assert_eq!(sha, "def456"); + } + _ => panic!("expected GitCommit variant"), + } + assert_eq!(request.open_paths, vec!["path with spaces"]); + + // Test with empty path + cx.update(|cx| { + assert!( + OpenRequest::parse( + RawOpenRequest { + urls: vec!["zed://git/commit/abc123?repo=".into()], + ..Default::default() + }, + cx, + ) + .unwrap_err() + .to_string() + .contains("missing repo") + ); + }); + + // Test error case: missing SHA + let result = cx.update(|cx| { + OpenRequest::parse( + RawOpenRequest { + urls: vec!["zed://git/commit/abc123?foo=bar".into()], + ..Default::default() + }, + cx, + ) + }); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("missing repo query parameter") + ); + } + #[gpui::test] async fn test_open_workspace_with_directory(cx: &mut TestAppContext) { let app_state = init_test(cx); diff --git a/crates/ztracing/src/lib.rs b/crates/ztracing/src/lib.rs index b9b318cc3565d8ced2a5496f1240409542d23c5a..c9007be1ed43150ef877d51c882aee77845e5bd6 100644 --- a/crates/ztracing/src/lib.rs +++ b/crates/ztracing/src/lib.rs @@ -1,8 +1,8 @@ -pub use tracing::Level; +pub use tracing::{Level, field}; #[cfg(ztracing)] pub use tracing::{ - debug_span, error_span, event, info_span, instrument, span, trace_span, warn_span, + Span, debug_span, error_span, event, info_span, instrument, span, trace_span, warn_span, }; #[cfg(not(ztracing))] pub use ztracing_macro::instrument; @@ -26,17 +26,23 @@ pub use __consume_all_tokens as span; #[macro_export] macro_rules! __consume_all_tokens { ($($t:tt)*) => { - $crate::FakeSpan + $crate::Span }; } -pub struct FakeSpan; -impl FakeSpan { +#[cfg(not(ztracing))] +pub struct Span; + +#[cfg(not(ztracing))] +impl Span { + pub fn current() -> Self { + Self + } + pub fn enter(&self) {} -} -// #[cfg(not(ztracing))] -// pub use span; + pub fn record(&self, _t: T, _s: S) {} +} #[cfg(ztracing)] pub fn init() { diff --git a/docs/.rules b/docs/.rules new file mode 100644 index 0000000000000000000000000000000000000000..4e6ca312f13b12a54a73d736ffeed8a8e09061ef --- /dev/null +++ b/docs/.rules @@ -0,0 +1,158 @@ +# 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 +- LLM-isms and filler words ("entirely," "certainly,", "deeply," "definitely," "actually")—these add nothing + +## 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. +``` diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 9d1f6f61d446b67256c00bf6322aed73af922c5e..1f9c5750ea76b35a2f7f5464b7b6684401108d2b 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -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) @@ -43,6 +46,7 @@ - [Tasks](./tasks.md) - [Tab Switcher](./tab-switcher.md) - [Remote Development](./remote-development.md) +- [Dev Containers](./dev-containers.md) - [Environment Variables](./environment.md) - [REPL](./repl.md) @@ -69,8 +73,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 +88,13 @@ - [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) +- [PyCharm](./migrate/pycharm.md) +- [WebStorm](./migrate/webstorm.md) +- [RustRover](./migrate/rustrover.md) # Language Support diff --git a/docs/src/ai/privacy-and-security.md b/docs/src/ai/privacy-and-security.md index 6921567b9165e863cd4303752a669e641e6fcdca..d72cc8c476a83f60d8342962fcdd410e541e7356 100644 --- a/docs/src/ai/privacy-and-security.md +++ b/docs/src/ai/privacy-and-security.md @@ -2,7 +2,7 @@ ## Philosophy -Zed aims to collect on the minimum data necessary to serve and improve our product. +Zed aims to collect only the minimum data necessary to serve and improve our product. We believe in opt-in data sharing as the default in building AI products, rather than opt-out, like most of our competitors. Privacy Mode is not a setting to be toggled, it's a default stance. @@ -12,6 +12,8 @@ It is entirely possible to use Zed, including Zed's AI capabilities, without sha ## Documentation +- [Worktree trust](../worktree-trust.md): How Zed opens files and directories in restricted mode. + - [Telemetry](../telemetry.md): How Zed collects general telemetry data. - [AI Improvement](./ai-improvement.md): Zed's opt-in-only approach to data collection for AI improvement, whether our Agentic offering or Edit Predictions. diff --git a/docs/src/configuring-zed.md b/docs/src/configuring-zed.md index a962e1b65496054e425c914273cfdbb9e08bca34..4af732083e135ce3f097077765bd210705a083b8 100644 --- a/docs/src/configuring-zed.md +++ b/docs/src/configuring-zed.md @@ -1451,6 +1451,47 @@ or `boolean` values +### Session + +- Description: Controls Zed lifecycle-related behavior. +- Setting: `session` +- Default: + +```json +{ + "session": { + "restore_unsaved_buffers": true, + "trust_all_worktrees": false + } +} +``` + +**Options** + +1. Whether or not to restore unsaved buffers on restart: + +```json [settings] +{ + "session": { + "restore_unsaved_buffers": true + } +} +``` + +If this is true, user won't be prompted whether to save/discard dirty files when closing the application. + +2. Whether or not to skip worktree and workspace trust checks: + +```json [settings] +{ + "session": { + "trust_all_worktrees": false + } +} +``` + +When trusted, project settings are synchronized automatically, language and MCP servers are downloaded and started automatically. + ### Drag And Drop Selection - Description: Whether to allow drag and drop text selection in buffer. `delay` is the milliseconds that must elapse before drag and drop is allowed. Otherwise, a new text selection is created. diff --git a/docs/src/dev-containers.md b/docs/src/dev-containers.md new file mode 100644 index 0000000000000000000000000000000000000000..c87b204ee9cded48edb95752dd234fa55df71338 --- /dev/null +++ b/docs/src/dev-containers.md @@ -0,0 +1,50 @@ +# Dev Containers + +Dev Containers provide a consistent, reproducible development environment by defining your project's dependencies, tools, and settings in a container configuration. + +If your repository includes a `.devcontainer/devcontainer.json` file, Zed can open a project inside a development container. + +## Requirements + +- Docker must be installed and available in your `PATH`. Zed requires the `docker` command to be present. If you use Podman, you can alias it to `docker` (e.g., `alias docker=podman`). +- Your project must contain a `.devcontainer/devcontainer.json` directory/file. + +## Using Dev Containers in Zed + +### Automatic prompt + +When you open a project that contains the `.devcontainer/devcontainer.json` directory/file, Zed will display a prompt asking whether to open the project inside the dev container. Choosing "Open in Container" will: + +1. Build the dev container image (if needed). +2. Launch the container. +3. Reopen the project connected to the container environment. + +### Manual open + +If you dismiss the prompt or want to reopen the project inside a container later, you can use Zed's command palette to run the "Project: Open Remote" command and select the option to open the project in a dev container. +Alternatively, you can reach for the Remote Projects modal (through the {#kb projects::OpenRemote} binding) and choose the "Connect Dev Container" option. + +## Editing the dev container configuration + +If you modify `.devcontainer/devcontainer.json`, Zed does not currently rebuild or reload the container automatically. After changing configuration: + +- Stop or kill the existing container manually (e.g., via `docker kill `). +- Reopen the project in the container. + +## Working in a Dev Container + +Once connected, Zed operates inside the container environment for tasks, terminals, and language servers. +Files are linked from your workspace into the container according to the dev container specification. + +## Known Limitations + +> **Note:** This feature is still in development. + +- **Extensions:** Zed does not yet manage extensions separately for container environments. The host's extensions are used as-is. +- **Port forwarding:** Only the `appPort` field is supported. `forwardPorts` and other advanced port-forwarding features are not implemented. +- **Configuration changes:** Updates to `devcontainer.json` do not trigger automatic rebuilds or reloads; containers must be manually restarted. + +## See also + +- [Remote Development](./remote-development.md) for connecting to remote servers over SSH. +- [Tasks](./tasks.md) for running commands in the integrated terminal. diff --git a/docs/src/languages/javascript.md b/docs/src/languages/javascript.md index 1b87dac5553f0dc44153d4706be1dd4bd2e341d5..f043c642b305a8dba2b0985a75954438bb024c4c 100644 --- a/docs/src/languages/javascript.md +++ b/docs/src/languages/javascript.md @@ -175,6 +175,34 @@ You can configure ESLint's `workingDirectory` setting: } ``` +## Using the Tailwind CSS Language Server with JavaScript + +To get all the features (autocomplete, linting, etc.) from the [Tailwind CSS language server](https://github.com/tailwindlabs/tailwindcss-intellisense/tree/HEAD/packages/tailwindcss-language-server#readme) in vanilla JavaScript files (`.js`), you can customize the `classRegex` field under it in your `settings.json`: + +```json [settings] +{ + "lsp": { + "tailwindcss-language-server": { + "settings": { + "experimental": { + "classRegex": [ + "\\.className\\s*[+]?=\\s*['\"]([^'\"]*)['\"]", + "\\.setAttributeNS\\(.*,\\s*['\"]class['\"],\\s*['\"]([^'\"]*)['\"]", + "\\.setAttribute\\(['\"]class['\"],\\s*['\"]([^'\"]*)['\"]", + "\\.classList\\.add\\(['\"]([^'\"]*)['\"]", + "\\.classList\\.remove\\(['\"]([^'\"]*)['\"]", + "\\.classList\\.toggle\\(['\"]([^'\"]*)['\"]", + "\\.classList\\.contains\\(['\"]([^'\"]*)['\"]", + "\\.classList\\.replace\\(\\s*['\"]([^'\"]*)['\"]", + "\\.classList\\.replace\\([^,)]+,\\s*['\"]([^'\"]*)['\"]" + ] + } + } + } + } +} +``` + ## Debugging Zed supports debugging JavaScript code out of the box with `vscode-js-debug`. @@ -186,7 +214,7 @@ The following can be debugged without writing additional configuration: Run {#action debugger::Start} ({#kb debugger::Start}) to see a contextual list of these predefined debug tasks. > **Note:** Bun test is automatically detected when `@types/bun` is present in `package.json`. -> + > **Note:** Node test is automatically detected when `@types/node` is present in `package.json` (requires Node.js 20+). As for all languages, configurations from `.vscode/launch.json` are also available for debugging in Zed. diff --git a/docs/src/languages/ruby.md b/docs/src/languages/ruby.md index 7e072ac5d32ab990584a2c2b0be57eb3076b1ec9..f7f0ccce83354fb24372f6916f27c63156f8cb3c 100644 --- a/docs/src/languages/ruby.md +++ b/docs/src/languages/ruby.md @@ -258,17 +258,10 @@ To enable Steep, add `\"steep\"` to the `language_servers` list for Ruby in your ## Using the Tailwind CSS Language Server with Ruby -It's possible to use the [Tailwind CSS Language Server](https://github.com/tailwindlabs/tailwindcss-intellisense/tree/HEAD/packages/tailwindcss-language-server#readme) in Ruby and ERB files. - -In order to do that, you need to configure the language server so that it knows about where to look for CSS classes in Ruby/ERB files by adding the following to your `settings.json`: +To get all the features (autocomplete, linting, etc.) from the [Tailwind CSS language server](https://github.com/tailwindlabs/tailwindcss-intellisense/tree/HEAD/packages/tailwindcss-language-server#readme) in Ruby/ERB files, you need to configure the language server so that it knows about where to look for CSS classes by adding the following to your `settings.json`: ```json [settings] { - "languages": { - "Ruby": { - "language_servers": ["tailwindcss-language-server", "..."] - } - }, "lsp": { "tailwindcss-language-server": { "settings": { @@ -281,7 +274,7 @@ In order to do that, you need to configure the language server so that it knows } ``` -With these settings you will get completions for Tailwind CSS classes in HTML attributes inside ERB files and inside Ruby/ERB strings that are coming after a `class:` key. Examples: +With these settings, you will get completions for Tailwind CSS classes in HTML attributes inside ERB files and inside Ruby/ERB strings that are coming after a `class:` key. Examples: ```rb # Ruby file: diff --git a/docs/src/languages/tailwindcss.md b/docs/src/languages/tailwindcss.md index be9c9437d1382dfd356120663ebea2c1fe012684..457c71f9768610f5bfdf345e72c27311632f1bef 100644 --- a/docs/src/languages/tailwindcss.md +++ b/docs/src/languages/tailwindcss.md @@ -4,9 +4,23 @@ Zed has built-in support for Tailwind CSS autocomplete, linting, and hover previ - Language Server: [tailwindlabs/tailwindcss-intellisense](https://github.com/tailwindlabs/tailwindcss-intellisense) +Languages which can be used with Tailwind CSS in Zed: + +- [Astro](./astro.md) +- [CSS](./css.md) +- [ERB](./ruby.md) +- [Gleam](./gleam.md) +- [HEEx](./elixir.md#heex) +- [HTML](./html.md) +- [TypeScript](./typescript.md) +- [JavaScript](./javascript.md) +- [PHP](./php.md) +- [Svelte](./svelte.md) +- [Vue](./vue.md) + ## Configuration -To configure the Tailwind CSS language server, refer [to the extension settings](https://github.com/tailwindlabs/tailwindcss-intellisense?tab=readme-ov-file#extension-settings) and add them to the `lsp` section of your `settings.json`: +If by default the language server isn't enough to make Tailwind work for a given language, you can configure the language server settings and add them to the `lsp` section of your `settings.json`: ```json [settings] { @@ -23,19 +37,7 @@ To configure the Tailwind CSS language server, refer [to the extension settings] } ``` -Languages which can be used with Tailwind CSS in Zed: - -- [Astro](./astro.md) -- [CSS](./css.md) -- [ERB](./ruby.md) -- [Gleam](./gleam.md) -- [HEEx](./elixir.md#heex) -- [HTML](./html.md) -- [TypeScript](./typescript.md) -- [JavaScript](./javascript.md) -- [PHP](./php.md) -- [Svelte](./svelte.md) -- [Vue](./vue.md) +Refer to [the Tailwind CSS language server settings docs](https://github.com/tailwindlabs/tailwindcss-intellisense?tab=readme-ov-file#extension-settings) for more information. ### Prettier Plugin diff --git a/docs/src/languages/typescript.md b/docs/src/languages/typescript.md index a6ec5b71ecb1815aeb4ff3811eec6f9a5c57a54b..d4fccc38f8a460e9ec097dee249a6441bd34a344 100644 --- a/docs/src/languages/typescript.md +++ b/docs/src/languages/typescript.md @@ -45,6 +45,34 @@ Prettier will also be used for TypeScript files by default. To disable this: } ``` +## Using the Tailwind CSS Language Server with TypeScript + +To get all the features (autocomplete, linting, etc.) from the [Tailwind CSS language server](https://github.com/tailwindlabs/tailwindcss-intellisense/tree/HEAD/packages/tailwindcss-language-server#readme) in vanilla TypeScript files (`.ts`), you can customize the `classRegex` field under it in your `settings.json`: + +```json [settings] +{ + "lsp": { + "tailwindcss-language-server": { + "settings": { + "experimental": { + "classRegex": [ + "\\.className\\s*[+]?=\\s*['\"]([^'\"]*)['\"]", + "\\.setAttributeNS\\(.*,\\s*['\"]class['\"],\\s*['\"]([^'\"]*)['\"]", + "\\.setAttribute\\(['\"]class['\"],\\s*['\"]([^'\"]*)['\"]", + "\\.classList\\.add\\(['\"]([^'\"]*)['\"]", + "\\.classList\\.remove\\(['\"]([^'\"]*)['\"]", + "\\.classList\\.toggle\\(['\"]([^'\"]*)['\"]", + "\\.classList\\.contains\\(['\"]([^'\"]*)['\"]", + "\\.classList\\.replace\\(\\s*['\"]([^'\"]*)['\"]", + "\\.classList\\.replace\\([^,)]+,\\s*['\"]([^'\"]*)['\"]" + ] + } + } + } + } +} +``` + ## Large projects `vtsls` may run out of memory on very large projects. We default the limit to 8092 (8 GiB) vs. the default of 3072 but this may not be sufficient for you: @@ -167,7 +195,7 @@ The following can be debugged without writing additional configuration: Run {#action debugger::Start} ({#kb debugger::Start}) to see a contextual list of these predefined debug tasks. > **Note:** Bun test is automatically detected when `@types/bun` is present in `package.json`. -> + > **Note:** Node test is automatically detected when `@types/node` is present in `package.json` (requires Node.js 20+). As for all languages, configurations from `.vscode/launch.json` are also available for debugging in Zed. diff --git a/docs/src/migrate/_research-notes.md b/docs/src/migrate/_research-notes.md new file mode 100644 index 0000000000000000000000000000000000000000..e23a3d3529a9762368e1721f97a6720382cd764b --- /dev/null +++ b/docs/src/migrate/_research-notes.md @@ -0,0 +1,73 @@ + + +# Migration Research Notes + +## Completed Guides + +All three JetBrains migration guides have been populated with full content: + +1. **pycharm.md** - Python development, virtual environments, Ruff/Pyright, Django/Flask workflows +2. **webstorm.md** - JavaScript/TypeScript development, npm workflows, framework considerations +3. **rustrover.md** - Rust development, rust-analyzer parity, Cargo workflows, licensing notes + +## Key Sources Used + +- IntelliJ IDEA migration doc (structural template) +- JetBrains PyCharm Getting Started docs +- JetBrains WebStorm Getting Started docs +- JetBrains RustRover Quick Start Guide +- External community feedback (Reddit, Hacker News, Medium) + +## External Quotes Incorporated + +### WebStorm Guide + +> "I work for AWS and the applications I deal with are massive. Often I need to keep many projects open due to tight dependencies. I'm talking about complex microservices and micro frontend infrastructure which oftentimes lead to 2-15 minutes of indexing wait time whenever I open a project or build the system locally." + +### RustRover Guide + +- Noted rust-analyzer shared foundation between RustRover and Zed +- Addressed licensing/telemetry concerns that motivate some users to switch +- Included debugger caveats based on community feedback + +## Cross-Cutting Themes Applied to All Guides + +### Universal Pain Points Addressed + +1. Indexing (instant in Zed) +2. Resource usage (Zed is lightweight) +3. Startup time (Zed is near-instant) +4. UI clutter (Zed is minimal by design) + +### Universal Missing Features Documented + +- No project model / SDK management +- No database tools +- No framework-specific integration +- No visual run configurations (use tasks) +- No built-in HTTP client + +### JetBrains Keymap Emphasized + +All three guides emphasize: + +- Select JetBrains keymap during onboarding or in settings +- `Shift Shift` for Search Everywhere works +- Most familiar shortcuts preserved + +## Next Steps (Optional Enhancements) + +- [ ] Cross-link guides to JetBrains docs for users who want to reference original IDE features +- [ ] Add a consolidated "hub page" linking to all migration guides +- [ ] Consider adding VS Code migration guide using similar structure +- [ ] Review for tone consistency against Zed Documentation Guidelines diff --git a/docs/src/migrate/intellij.md b/docs/src/migrate/intellij.md new file mode 100644 index 0000000000000000000000000000000000000000..24c85774ec5686f605d1d781913d0873ac0abd7f --- /dev/null +++ b/docs/src/migrate/intellij.md @@ -0,0 +1,357 @@ +# How to Migrate from IntelliJ IDEA to Zed + +This guide covers how to set up Zed if you're coming from IntelliJ IDEA, including keybindings, settings, and the differences you should expect. + +## Install Zed + +Zed is available on macOS, Windows, and Linux. + +For macOS, you can download it from zed.dev/download, or install via Homebrew: + +```sh +brew install --cask zed +``` + +For Windows, download the installer from zed.dev/download, or install via winget: + +```sh +winget install Zed.Zed +``` + +For most Linux users, the easiest way to install Zed is through our installation script: + +```sh +curl -f https://zed.dev/install.sh | sh +``` + +After installation, you can launch Zed from your Applications folder (macOS), Start menu (Windows), or directly from the terminal using: +`zed .` +This opens the current directory in Zed. + +## Set Up the JetBrains Keymap + +If you're coming from IntelliJ, the fastest way to feel at home is to use the JetBrains keymap. During onboarding, you can select it as your base keymap. If you missed that step, you can change it anytime: + +1. Open Settings with `Cmd+,` (macOS) or `Ctrl+,` (Linux/Windows) +2. Search for `Base Keymap` +3. Select `JetBrains` + +Or add this directly to your `settings.json`: + +```json +{ + "base_keymap": "JetBrains" +} +``` + +This maps familiar shortcuts like `Shift Shift` for Search Everywhere, `Cmd+O` for Go to Class, and `Cmd+Shift+A` for Find Action. + +## Set Up Editor Preferences + +You can configure settings manually in the Settings Editor. + +To edit your settings: + +1. `Cmd+,` to open the Settings Editor. +2. Run `zed: open settings` in the Command Palette. + +Settings IntelliJ users typically configure first: + +| Zed Setting | What it does | +| ----------------------- | ------------------------------------------------------------------------------- | +| `format_on_save` | Auto-format when saving. Set to `"on"` to enable. | +| `soft_wrap` | Wrap long lines. Options: `"none"`, `"editor_width"`, `"preferred_line_length"` | +| `preferred_line_length` | Column width for wrapping and rulers. Default is 80. | +| `inlay_hints` | Show parameter names and type hints inline, like IntelliJ's hints. | +| `relative_line_numbers` | Useful if you're coming from IdeaVim. | + +Zed also supports per-project settings. Create a `.zed/settings.json` file in your project root to override global settings for that project, similar to how you might use `.idea` folders in IntelliJ. + +> **Tip:** If you're joining an existing project, check `format_on_save` before making your first commit. Otherwise you might accidentally reformat an entire file when you only meant to change one line. + +## Open or Create a Project + +After setup, press `Cmd+Shift+O` (with JetBrains keymap) to open a folder. This becomes your workspace in Zed. Unlike IntelliJ, there's no project configuration wizard, no `.iml` files, and no SDK setup required. + +To start a new project, create a directory using your terminal or file manager, then open it in Zed. The editor will treat that folder as the root of your project. + +You can also launch Zed from the terminal inside any folder with: +`zed .` + +Once inside a project: + +- Use `Cmd+Shift+O` or `Cmd+E` to jump between files quickly (like IntelliJ's "Recent Files") +- Use `Cmd+Shift+A` or `Shift Shift` to open the Command Palette (like IntelliJ's "Search Everywhere") +- Use `Cmd+O` to search for symbols (like IntelliJ's "Go to Class") + +Open buffers appear as tabs across the top. The sidebar shows your file tree and Git status. Toggle it with `Cmd+1` (just like IntelliJ's Project tool window). + +## Differences in Keybindings + +If you chose the JetBrains keymap during onboarding, most of your shortcuts should already feel familiar. Here's a quick reference for how Zed compares to IntelliJ. + +### Common Shared Keybindings (Zed with JetBrains keymap ↔ IntelliJ) + +| Action | Shortcut | +| ----------------------------- | ----------------------- | +| Search Everywhere | `Shift Shift` | +| Find Action / Command Palette | `Cmd + Shift + A` | +| Go to File | `Cmd + Shift + O` | +| Go to Symbol / Class | `Cmd + O` | +| Recent Files | `Cmd + E` | +| Go to Definition | `Cmd + B` | +| Find Usages | `Alt + F7` | +| Rename Symbol | `Shift + F6` | +| Reformat Code | `Cmd + Alt + L` | +| Toggle Project Panel | `Cmd + 1` | +| Toggle Terminal | `Alt + F12` | +| Duplicate Line | `Cmd + D` | +| Delete Line | `Cmd + Backspace` | +| Move Line Up/Down | `Shift + Alt + Up/Down` | +| Expand/Shrink Selection | `Alt + Up/Down` | +| Comment Line | `Cmd + /` | +| Go Back / Forward | `Cmd + [` / `Cmd + ]` | +| Toggle Breakpoint | `Ctrl + F8` | + +### Different Keybindings (IntelliJ → Zed) + +| Action | IntelliJ | Zed (JetBrains keymap) | +| ---------------------- | ----------- | ------------------------ | +| File Structure | `Cmd + F12` | `Cmd + F12` (outline) | +| Navigate to Next Error | `F2` | `F2` | +| Run | `Ctrl + R` | `Ctrl + Alt + R` (tasks) | +| Debug | `Ctrl + D` | `Alt + Shift + F9` | +| Stop | `Cmd + F2` | `Ctrl + F2` | + +### Unique to Zed + +| Action | Shortcut | Notes | +| ----------------- | -------------------------- | ------------------------------ | +| Toggle Right Dock | `Cmd + R` | Assistant panel, notifications | +| Split Panes | `Cmd + K`, then arrow keys | Create splits in any direction | + +### How to Customize Keybindings + +- Open the Command Palette (`Cmd+Shift+A` or `Shift Shift`) +- Run `Zed: Open Keymap Editor` + +This opens a list of all available bindings. You can override individual shortcuts or remove conflicts. + +Zed also supports key sequences (multi-key shortcuts). + +## Differences in User Interfaces + +### No Indexing + +If you've used IntelliJ on large projects, you know the wait: "Indexing..." can take anywhere from 30 seconds to 15 minutes depending on project size. IntelliJ builds a comprehensive index of your entire codebase to power its code intelligence, and it re-indexes when dependencies change or after builds. + +Zed doesn't index. You open a folder and start working immediately. File search and navigation work instantly regardless of project size. + +IntelliJ's index powers features like finding all usages across your entire codebase, understanding class hierarchies, and detecting dead code. Zed delegates this work to language servers, which may not analyze at the same depth. + +**How to adapt:** + +- For project-wide symbol search, use `Cmd+O` / Go to Symbol (relies on your language server) +- For finding files by name, use `Cmd+Shift+O` / Go to File +- For text search across files, use `Cmd+Shift+F`—this is fast even on large codebases +- If you need deep static analysis for JVM code, consider running IntelliJ's inspections as a separate step or using standalone tools like Checkstyle, PMD, or SpotBugs + +### LSP vs. Native Language Intelligence + +IntelliJ has its own language analysis engine built from scratch for each supported language. For Java, Kotlin, and other JVM languages, this engine understands your code thoroughly: it resolves types, tracks data flow, knows about framework annotations, and offers dozens of specialized refactorings. + +Zed uses the Language Server Protocol (LSP) for code intelligence. Each language has its own server: `jdtls` for Java, `rust-analyzer` for Rust, and so on. + +For some languages, the LSP experience is excellent. TypeScript, Rust, and Go have mature language servers that provide fast, accurate completions, diagnostics, and refactorings. For JVM languages, the gap might be more noticeable. The Eclipse-based Java language server is capable, but it won't match IntelliJ's depth for things like: + +- Spring and Jakarta EE annotation processing +- Complex refactorings (extract interface, pull members up, change signature with all callers) +- Framework-aware inspections +- Automatic import optimization with custom ordering rules + +**How to adapt:** + +- Use `Alt+Enter` for available code actions—the list will vary by language server +- For Java, ensure `jdtls` is properly configured with your JDK path in settings + +### No Project Model + +IntelliJ manages projects through `.idea` folders containing XML configuration files, `.iml` module definitions, SDK assignments, and run configurations. This model enables IntelliJ to understand multi-module projects, manage dependencies automatically, and persist complex run/debug setups. + +Zed has no project model. A project is a folder. There's no wizard, no SDK selection screen, no module configuration. + +This means: + +- Build commands are manual. Zed doesn't detect Maven or Gradle projects. +- Run configurations don't exist. You define tasks or use the terminal. +- SDK management is external. Your language server uses whatever JDK is on your PATH. +- There are no module boundaries. Zed sees folders, not project structure. + +**How to adapt:** + +- Create a `.zed/settings.json` in your project root for project-specific settings +- Define common commands in `tasks.json` (open via Command Palette: `zed: open tasks`): + +```json +[ + { + "label": "build", + "command": "./gradlew build" + }, + { + "label": "run", + "command": "./gradlew bootRun" + }, + { + "label": "test current file", + "command": "./gradlew test --tests $ZED_STEM" + } +] +``` + +- Use `Ctrl+Alt+R` to run tasks quickly +- Lean on your terminal (`Alt+F12`) for anything tasks don't cover +- For multi-module projects, you can open each module as a separate Zed window, or open the root and navigate via file finder + +### No Framework Integration + +IntelliJ's value for enterprise Java development comes largely from its framework integration. Spring beans are understood and navigable. JPA entities get special treatment. Endpoints are indexed and searchable. Jakarta EE annotations modify how the IDE analyzes your code. + +Zed has none of this. The language server sees Java code as Java code, so it doesn't understand that `@Autowired` means something special or that this class is a REST controller. + +Similarly for other ecosystems: no Rails integration, no Django awareness, no Angular/React-specific tooling beyond what the TypeScript language server provides. + +**How to adapt:** + +- Use grep and file search liberally. `Cmd+Shift+F` with a regex can find endpoint definitions, bean names, or annotation usages. +- Rely on your language server's "find references" (`Alt+F7`) for navigation—it works, just without framework context +- For Spring Boot, keep the Actuator endpoints or a separate tool for understanding bean wiring +- Consider using framework-specific CLI tools (Spring CLI, Rails generators) from Zed's terminal + +> **Tip:** For database work, pick up a dedicated tool like DataGrip, DBeaver, or TablePlus. Many developers who switch to Zed keep DataGrip around specifically for SQL—it integrates well with your existing JetBrains license. + +If your daily work depends heavily on framework-aware navigation and refactoring, you'll feel the gap. Zed works best when you're comfortable navigating code through search rather than specialized tooling, or when your language has strong LSP support that covers most of what you need. + +### Tool Windows vs. Docks + +IntelliJ organizes auxiliary views into numbered tool windows (Project = 1, Git = 9, Terminal = Alt+F12, etc.). Zed uses a similar concept called "docks": + +| IntelliJ Tool Window | Zed Equivalent | Shortcut (JetBrains keymap) | +| -------------------- | -------------- | --------------------------- | +| Project (1) | Project Panel | `Cmd + 1` | +| Git (9 or Cmd+0) | Git Panel | `Cmd + 0` | +| Terminal (Alt+F12) | Terminal Panel | `Alt + F12` | +| Structure (7) | Outline Panel | `Cmd + 7` | +| Problems (6) | Diagnostics | `Cmd + 6` | +| Debug (5) | Debug Panel | `Cmd + 5` | + +Zed has three dock positions: left, bottom, and right. Panels can be moved between docks by dragging or through settings. + +> **Tip:** IntelliJ has an "Override IDE shortcuts" setting that lets terminal shortcuts like `Ctrl+Left/Right` work normally. In Zed, terminal keybindings are separate—check your keymap if familiar shortcuts aren't working in the terminal panel. + +### Debugging + +Both IntelliJ and Zed offer integrated debugging, but the experience differs: + +- Zed's debugger uses the Debug Adapter Protocol (DAP), supporting multiple languages +- Set breakpoints with `Ctrl+F8` +- Start debugging with `Alt+Shift+F9` +- Step through code with `F7` (step into), `F8` (step over), `Shift+F8` (step out) +- Continue execution with `F9` + +The Debug Panel (`Cmd+5`) shows variables, call stack, and breakpoints—similar to IntelliJ's Debug tool window. + +### Extensions vs. Plugins + +IntelliJ has a massive plugin ecosystem covering everything from language support to database tools to deployment integrations. + +Zed's extension ecosystem is smaller and more focused: + +- Language support and syntax highlighting +- Themes +- Slash commands for AI +- Context servers + +Several features that require plugins in other editors are built into Zed: + +- Real-time collaboration with voice chat +- AI coding assistance +- Built-in terminal +- Task runner +- LSP-based code intelligence + +You won't find one-to-one replacements for every IntelliJ plugin, especially for framework-specific tools, database clients, or application server integrations. For those workflows, you may need to use external tools alongside Zed. + +## Collaboration in Zed vs. IntelliJ + +IntelliJ offers Code With Me as a separate plugin for collaboration. Zed has collaboration built into the core experience. + +- Open the Collab Panel in the left dock +- Create a channel and [invite your collaborators](https://zed.dev/docs/collaboration#inviting-a-collaborator) to join +- [Share your screen or your codebase](https://zed.dev/docs/collaboration#share-a-project) directly + +Once connected, you'll see each other's cursors, selections, and edits in real time. Voice chat is included. There's no need for separate tools or third-party logins. + +## Using AI in Zed + +If you're used to AI assistants in IntelliJ (like GitHub Copilot or JetBrains AI), Zed offers similar capabilities with more flexibility. + +### Configuring GitHub Copilot + +1. Open Settings with `Cmd+,` (macOS) or `Ctrl+,` (Linux/Windows) +2. Navigate to **AI → Edit Predictions** +3. Click **Configure** next to "Configure Providers" +4. Under **GitHub Copilot**, click **Sign in to GitHub** + +Once signed in, just start typing. Zed will offer suggestions inline for you to accept. + +### Additional AI Options + +To use other AI models in Zed, you have several options: + +- Use Zed's hosted models, with higher rate limits. Requires [authentication](https://zed.dev/docs/accounts.html) and subscription to [Zed Pro](https://zed.dev/docs/ai/subscription.html). +- Bring your own [API keys](https://zed.dev/docs/ai/llm-providers.html), no authentication needed +- Use [external agents like Claude Code](https://zed.dev/docs/ai/external-agents.html) + +## Advanced Config and Productivity Tweaks + +Zed exposes advanced settings for power users who want to fine-tune their environment. + +Here are a few useful tweaks: + +**Format on Save:** + +```json +"format_on_save": "on" +``` + +**Enable direnv support:** + +```json +"load_direnv": "shell_hook" +``` + +**Configure language servers**: For Java development, you may want to configure the Java language server in your settings: + +```json +{ + "lsp": { + "jdtls": { + "settings": { + "java_home": "/path/to/jdk" + } + } + } +} +``` + +## Next Steps + +Now that you're set up, here are some resources to help you get the most out of Zed: + +- [Configuring Zed](../configuring-zed.md) — Customize settings, themes, and editor behavior +- [Key Bindings](../key-bindings.md) — Learn how to customize and extend your keymap +- [Tasks](../tasks.md) — Set up build and run commands for your projects +- [AI Features](../ai/overview.md) — Explore Zed's AI capabilities beyond code completion +- [Collaboration](../collaboration/overview.md) — Share your projects and code together in real time +- [Languages](../languages.md) — Language-specific setup guides, including Java and Kotlin diff --git a/docs/src/migrate/pycharm.md b/docs/src/migrate/pycharm.md new file mode 100644 index 0000000000000000000000000000000000000000..636bc69eeba1c09b3e0e8a0d74ccd859aedbb342 --- /dev/null +++ b/docs/src/migrate/pycharm.md @@ -0,0 +1,438 @@ +# How to Migrate from PyCharm to Zed + +This guide covers how to set up Zed if you're coming from PyCharm, including keybindings, settings, and the differences you should expect. + +## Install Zed + +Zed is available on macOS, Windows, and Linux. + +For macOS, you can download it from zed.dev/download, or install via Homebrew: + +```sh +brew install --cask zed +``` + +For Windows, download the installer from zed.dev/download, or install via winget: + +```sh +winget install Zed.Zed +``` + +For most Linux users, the easiest way to install Zed is through our installation script: + +```sh +curl -f https://zed.dev/install.sh | sh +``` + +After installation, you can launch Zed from your Applications folder (macOS), Start menu (Windows), or directly from the terminal using: +`zed .` +This opens the current directory in Zed. + +## Set Up the JetBrains Keymap + +If you're coming from PyCharm, the fastest way to feel at home is to use the JetBrains keymap. During onboarding, you can select it as your base keymap. If you missed that step, you can change it anytime: + +1. Open Settings with `Cmd+,` (macOS) or `Ctrl+,` (Linux/Windows) +2. Search for `Base Keymap` +3. Select `JetBrains` + +Or add this directly to your `settings.json`: + +```json +{ + "base_keymap": "JetBrains" +} +``` + +This maps familiar shortcuts like `Shift Shift` for Search Everywhere, `Cmd+O` for Go to Class, and `Cmd+Shift+A` for Find Action. + +## Set Up Editor Preferences + +You can configure settings manually in the Settings Editor. + +To edit your settings: + +1. `Cmd+,` to open the Settings Editor. +2. Run `zed: open settings` in the Command Palette. + +Settings PyCharm users typically configure first: + +| Zed Setting | What it does | +| ----------------------- | ------------------------------------------------------------------------------- | +| `format_on_save` | Auto-format when saving. Set to `"on"` to enable. | +| `soft_wrap` | Wrap long lines. Options: `"none"`, `"editor_width"`, `"preferred_line_length"` | +| `preferred_line_length` | Column width for wrapping and rulers. Default is 80, PEP 8 recommends 79. | +| `inlay_hints` | Show parameter names and type hints inline, like PyCharm's hints. | +| `relative_line_numbers` | Useful if you're coming from IdeaVim. | + +Zed also supports per-project settings. Create a `.zed/settings.json` file in your project root to override global settings for that project, similar to how you might use `.idea` folders in PyCharm. + +> **Tip:** If you're joining an existing project, check `format_on_save` before making your first commit. Otherwise you might accidentally reformat an entire file when you only meant to change one line. + +## Open or Create a Project + +After setup, press `Cmd+Shift+O` (with JetBrains keymap) to open a folder. This becomes your workspace in Zed. Unlike PyCharm, there's no project configuration wizard, no interpreter selection dialog, and no project structure setup required. + +To start a new project, create a directory using your terminal or file manager, then open it in Zed. The editor will treat that folder as the root of your project. + +You can also launch Zed from the terminal inside any folder with: +`zed .` + +Once inside a project: + +- Use `Cmd+Shift+O` or `Cmd+E` to jump between files quickly (like PyCharm's "Recent Files") +- Use `Cmd+Shift+A` or `Shift Shift` to open the Command Palette (like PyCharm's "Search Everywhere") +- Use `Cmd+O` to search for symbols (like PyCharm's "Go to Symbol") + +Open buffers appear as tabs across the top. The sidebar shows your file tree and Git status. Toggle it with `Cmd+1` (just like PyCharm's Project tool window). + +## Differences in Keybindings + +If you chose the JetBrains keymap during onboarding, most of your shortcuts should already feel familiar. Here's a quick reference for how Zed compares to PyCharm. + +### Common Shared Keybindings + +| Action | Shortcut | +| ----------------------------- | ----------------------- | +| Search Everywhere | `Shift Shift` | +| Find Action / Command Palette | `Cmd + Shift + A` | +| Go to File | `Cmd + Shift + O` | +| Go to Symbol | `Cmd + O` | +| Recent Files | `Cmd + E` | +| Go to Definition | `Cmd + B` | +| Find Usages | `Alt + F7` | +| Rename Symbol | `Shift + F6` | +| Reformat Code | `Cmd + Alt + L` | +| Toggle Project Panel | `Cmd + 1` | +| Toggle Terminal | `Alt + F12` | +| Duplicate Line | `Cmd + D` | +| Delete Line | `Cmd + Backspace` | +| Move Line Up/Down | `Shift + Alt + Up/Down` | +| Expand/Shrink Selection | `Alt + Up/Down` | +| Comment Line | `Cmd + /` | +| Go Back / Forward | `Cmd + [` / `Cmd + ]` | +| Toggle Breakpoint | `Ctrl + F8` | + +### Different Keybindings (PyCharm → Zed) + +| Action | PyCharm | Zed (JetBrains keymap) | +| ---------------------- | ----------- | ------------------------ | +| File Structure | `Cmd + F12` | `Cmd + F12` (outline) | +| Navigate to Next Error | `F2` | `F2` | +| Run | `Ctrl + R` | `Ctrl + Alt + R` (tasks) | +| Debug | `Ctrl + D` | `Alt + Shift + F9` | +| Stop | `Cmd + F2` | `Ctrl + F2` | + +### Unique to Zed + +| Action | Shortcut | Notes | +| ----------------- | -------------------------- | ------------------------------ | +| Toggle Right Dock | `Cmd + R` | Assistant panel, notifications | +| Split Panes | `Cmd + K`, then arrow keys | Create splits in any direction | + +### How to Customize Keybindings + +- Open the Command Palette (`Cmd+Shift+A` or `Shift Shift`) +- Run `Zed: Open Keymap Editor` + +This opens a list of all available bindings. You can override individual shortcuts or remove conflicts. + +Zed also supports key sequences (multi-key shortcuts). + +## Differences in User Interfaces + +### No Indexing + +If you've used PyCharm on large projects, you know the wait: "Indexing..." can take anywhere from 30 seconds to several minutes depending on project size and dependencies. PyCharm builds a comprehensive index of your entire codebase to power its code intelligence, and it re-indexes when dependencies change or when you install new packages. + +Zed doesn't index. You open a folder and start working immediately. File search and navigation work instantly regardless of project size. For many PyCharm users, this alone is reason enough to switch—no more waiting, no more "Indexing paused" interruptions. + +PyCharm's index powers features like finding all usages across your entire codebase, understanding class hierarchies, and detecting unused imports project-wide. Zed delegates this work to language servers, which may not analyze as deeply or as broadly. + +**How to adapt:** + +- For project-wide symbol search, use `Cmd+O` / Go to Symbol (relies on your language server) +- For finding files by name, use `Cmd+Shift+O` / Go to File +- For text search across files, use `Cmd+Shift+F`—this is fast even on large codebases +- For deep static analysis, consider running tools like `mypy`, `pylint`, or `ruff check` from the terminal + +### LSP vs. Native Language Intelligence + +PyCharm has its own language analysis engine built specifically for Python. This engine understands your code deeply: it resolves types without annotations, tracks data flow, knows about Django models and Flask routes, and offers specialized refactorings. + +Zed uses the Language Server Protocol (LSP) for code intelligence. For Python, Zed provides several language servers out of the box: + +- **basedpyright** (default) — Fast type checking and completions +- **Ruff** (default) — Linting and formatting +- **Ty** — Up-and-coming language server from Astral, built for speed +- **Pyright** — Microsoft's type checker +- **PyLSP** — Plugin-based server with tool integrations + +The LSP experience for Python is strong. basedpyright provides accurate completions, type checking, and navigation. Ruff handles formatting and linting with excellent performance. + +Where you might notice differences: + +- Framework-specific intelligence (Django ORM, Flask routes) isn't built-in +- Some complex refactorings (extract method with proper scope analysis) may be less sophisticated +- Auto-import suggestions depend on what the language server knows about your environment + +**How to adapt:** + +- Use `Alt+Enter` for available code actions—the list will vary by language server +- Ensure your virtual environment is selected so the language server can resolve your dependencies +- Use Ruff for fast, consistent formatting (it's enabled by default) +- For code inspection similar to PyCharm's "Inspect Code," run `ruff check .` or check the Diagnostics panel (`Cmd+6`)—basedpyright and Ruff together catch many of the same issues + +### Virtual Environments and Interpreters + +In PyCharm, you select a Python interpreter through a GUI, and PyCharm manages the connection between your project and that interpreter. It shows available packages, lets you install new ones, and keeps track of which environment each project uses. + +Zed handles virtual environments through its toolchain system: + +- Zed automatically discovers virtual environments in common locations (`.venv`, `venv`, `.env`, `env`) +- When a virtual environment is detected, the terminal auto-activates it +- Language servers are automatically configured to use the discovered environment +- You can manually select a toolchain if auto-detection picks the wrong one + +**How to adapt:** + +- Create your virtual environment with `python -m venv .venv` or `uv sync` +- Open the folder in Zed—it will detect the environment automatically +- If you need to switch environments, use the toolchain selector +- For conda environments, ensure they're activated in your shell before launching Zed + +> **Tip:** If basedpyright shows import errors for packages you've installed, check that Zed has selected the correct virtual environment. Use the toolchain selector to verify or change the active environment. + +### No Project Model + +PyCharm manages projects through `.idea` folders containing XML configuration files, interpreter assignments, and run configurations. This model lets PyCharm remember your interpreter choice, manage dependencies through the UI, and persist complex run/debug setups. + +Zed has no project model. A project is a folder. There's no wizard, no interpreter selection screen, no project structure configuration. + +This means: + +- Run configurations don't exist. You define tasks or use the terminal. Your existing PyCharm run configs in `.idea/` won't be read—you'll recreate the ones you need in `tasks.json`. +- Interpreter management is external. Zed discovers environments but doesn't create them. +- Dependencies are managed through pip, uv, poetry, or conda—not through the editor. +- There's no Python Console (interactive REPL) panel. Use `python` or `ipython` in the terminal instead. + +**How to adapt:** + +- Create a `.zed/settings.json` in your project root for project-specific settings +- Define common commands in `tasks.json` (open via Command Palette: `zed: open tasks`): + +```json +[ + { + "label": "run", + "command": "python main.py" + }, + { + "label": "test", + "command": "pytest" + }, + { + "label": "test current file", + "command": "pytest $ZED_FILE" + } +] +``` + +- Use `Ctrl+Alt+R` to run tasks quickly +- Lean on your terminal (`Alt+F12`) for anything tasks don't cover + +### No Framework Integration + +PyCharm Professional's value for web development comes largely from its framework integration. Django templates are understood and navigable. Flask routes are indexed. SQLAlchemy models get special treatment. Template variables autocomplete. + +Zed has none of this. The language server sees Python code as Python code—it doesn't understand that `@app.route` defines an endpoint or that a Django model class creates database tables. + +**How to adapt:** + +- Use grep and file search liberally. `Cmd+Shift+F` with a regex can find route definitions, model classes, or template usages. +- Rely on your language server's "find references" (`Alt+F7`) for navigation—it works, just without framework context +- Consider using framework-specific CLI tools (`python manage.py`, `flask routes`) from Zed's terminal + +> **Tip:** For database work, pick up a dedicated tool like DataGrip, DBeaver, or TablePlus. Many developers who switch to Zed keep DataGrip around specifically for SQL. + +### Tool Windows vs. Docks + +PyCharm organizes auxiliary views into numbered tool windows (Project = 1, Python Console = 4, Terminal = Alt+F12, etc.). Zed uses a similar concept called "docks": + +| PyCharm Tool Window | Zed Equivalent | Shortcut (JetBrains keymap) | +| ------------------- | -------------- | --------------------------- | +| Project (1) | Project Panel | `Cmd + 1` | +| Git (9 or Cmd+0) | Git Panel | `Cmd + 0` | +| Terminal (Alt+F12) | Terminal Panel | `Alt + F12` | +| Structure (7) | Outline Panel | `Cmd + 7` | +| Problems (6) | Diagnostics | `Cmd + 6` | +| Debug (5) | Debug Panel | `Cmd + 5` | + +Zed has three dock positions: left, bottom, and right. Panels can be moved between docks by dragging or through settings. + +### Debugging + +Both PyCharm and Zed offer integrated debugging, but the experience differs: + +- Zed uses `debugpy` (the same debug adapter that VS Code uses) +- Set breakpoints with `Ctrl+F8` +- Start debugging with `Alt+Shift+F9` or press `F4` and select a debug target +- Step through code with `F7` (step into), `F8` (step over), `Shift+F8` (step out) +- Continue execution with `F9` + +Zed can automatically detect debuggable entry points. Press `F4` to see available options, including: + +- Python scripts +- Modules +- pytest tests + +For more control, create a `.zed/debug.json` file: + +```json +[ + { + "label": "Debug Current File", + "adapter": "Debugpy", + "program": "$ZED_FILE", + "request": "launch" + }, + { + "label": "Debug Flask App", + "adapter": "Debugpy", + "request": "launch", + "module": "flask", + "args": ["run", "--debug"], + "env": { + "FLASK_APP": "app.py" + } + } +] +``` + +### Running Tests + +PyCharm has a dedicated test runner with a visual interface showing pass/fail status for each test. Zed provides test running through: + +- **Gutter icons** — Click the play button next to test functions or classes +- **Tasks** — Define pytest or unittest commands in `tasks.json` +- **Terminal** — Run `pytest` directly + +The test output appears in the terminal panel. For pytest, use `--tb=short` for concise tracebacks or `-v` for verbose output. + +### Extensions vs. Plugins + +PyCharm has a plugin ecosystem covering everything from additional language support to database tools to deployment integrations. + +Zed's extension ecosystem is smaller and more focused: + +- Language support and syntax highlighting +- Themes +- Slash commands for AI +- Context servers + +Several features that require plugins in PyCharm are built into Zed: + +- Real-time collaboration with voice chat +- AI coding assistance +- Built-in terminal +- Task runner +- LSP-based code intelligence +- Ruff formatting and linting + +### What's Not in Zed + +To set expectations clearly, here's what PyCharm offers that Zed doesn't have: + +- **Scientific Mode / Jupyter integration** — For notebooks and data science workflows, use JupyterLab or VS Code with the Jupyter extension alongside Zed for your Python editing +- **Database tools** — Use DataGrip, DBeaver, or TablePlus +- **Django/Flask template navigation** — Use file search and grep +- **Visual package manager** — Use pip, uv, or poetry from the terminal +- **Remote interpreters** — Zed has remote development, but it works differently +- **Profiler integration** — Use cProfile, py-spy, or similar tools externally + +## Collaboration in Zed vs. PyCharm + +PyCharm offers Code With Me as a separate plugin for collaboration. Zed has collaboration built into the core experience. + +- Open the Collab Panel in the left dock +- Create a channel and [invite your collaborators](https://zed.dev/docs/collaboration#inviting-a-collaborator) to join +- [Share your screen or your codebase](https://zed.dev/docs/collaboration#share-a-project) directly + +Once connected, you'll see each other's cursors, selections, and edits in real time. Voice chat is included. There's no need for separate tools or third-party logins. + +## Using AI in Zed + +If you're used to AI assistants in PyCharm (like GitHub Copilot or JetBrains AI Assistant), Zed offers similar capabilities with more flexibility. + +### Configuring GitHub Copilot + +1. Open Settings with `Cmd+,` (macOS) or `Ctrl+,` (Linux/Windows) +2. Navigate to **AI → Edit Predictions** +3. Click **Configure** next to "Configure Providers" +4. Under **GitHub Copilot**, click **Sign in to GitHub** + +Once signed in, just start typing. Zed will offer suggestions inline for you to accept. + +### Additional AI Options + +To use other AI models in Zed, you have several options: + +- Use Zed's hosted models, with higher rate limits. Requires [authentication](https://zed.dev/docs/accounts.html) and subscription to [Zed Pro](https://zed.dev/docs/ai/subscription.html). +- Bring your own [API keys](https://zed.dev/docs/ai/llm-providers.html), no authentication needed +- Use [external agents like Claude Code](https://zed.dev/docs/ai/external-agents.html) + +## Advanced Config and Productivity Tweaks + +Zed exposes advanced settings for power users who want to fine-tune their environment. + +Here are a few useful tweaks: + +**Format on Save:** + +```json +"format_on_save": "on" +``` + +**Enable direnv support (useful for Python projects using direnv):** + +```json +"load_direnv": "shell_hook" +``` + +**Customize virtual environment detection:** + +```json +{ + "terminal": { + "detect_venv": { + "on": { + "directories": [".venv", "venv", ".env", "env"], + "activate_script": "default" + } + } + } +} +``` + +**Configure basedpyright type checking strictness:** + +If you find basedpyright too strict or too lenient, configure it in your project's `pyrightconfig.json`: + +```json +{ + "typeCheckingMode": "basic" +} +``` + +Options are `"off"`, `"basic"`, `"standard"` (default), `"strict"`, or `"all"`. + +## Next Steps + +Now that you're set up, here are some resources to help you get the most out of Zed: + +- [Configuring Zed](../configuring-zed.md) — Customize settings, themes, and editor behavior +- [Key Bindings](../key-bindings.md) — Learn how to customize and extend your keymap +- [Tasks](../tasks.md) — Set up build and run commands for your projects +- [AI Features](../ai/overview.md) — Explore Zed's AI capabilities beyond code completion +- [Collaboration](../collaboration/overview.md) — Share your projects and code together in real time +- [Python in Zed](../languages/python.md) — Python-specific setup and configuration diff --git a/docs/src/migrate/rustrover.md b/docs/src/migrate/rustrover.md new file mode 100644 index 0000000000000000000000000000000000000000..4d0e85cfe9b981243044290929070e87876987d3 --- /dev/null +++ b/docs/src/migrate/rustrover.md @@ -0,0 +1,501 @@ +# How to Migrate from RustRover to Zed + +This guide covers how to set up Zed if you're coming from RustRover, including keybindings, settings, and the differences you should expect as a Rust developer. + +## Install Zed + +Zed is available on macOS, Windows, and Linux. + +For macOS, you can download it from zed.dev/download, or install via Homebrew: + +```sh +brew install --cask zed +``` + +For Windows, download the installer from zed.dev/download, or install via winget: + +```sh +winget install Zed.Zed +``` + +For most Linux users, the easiest way to install Zed is through our installation script: + +```sh +curl -f https://zed.dev/install.sh | sh +``` + +After installation, you can launch Zed from your Applications folder (macOS), Start menu (Windows), or directly from the terminal using: +`zed .` +This opens the current directory in Zed. + +## Set Up the JetBrains Keymap + +If you're coming from RustRover, the fastest way to feel at home is to use the JetBrains keymap. During onboarding, you can select it as your base keymap. If you missed that step, you can change it anytime: + +1. Open Settings with `Cmd+,` (macOS) or `Ctrl+,` (Linux/Windows) +2. Search for `Base Keymap` +3. Select `JetBrains` + +Or add this directly to your `settings.json`: + +```json +{ + "base_keymap": "JetBrains" +} +``` + +This maps familiar shortcuts like `Shift Shift` for Search Everywhere, `Cmd+O` for Go to Class, and `Cmd+Shift+A` for Find Action. + +## Set Up Editor Preferences + +You can configure settings manually in the Settings Editor. + +To edit your settings: + +1. `Cmd+,` to open the Settings Editor. +2. Run `zed: open settings` in the Command Palette. + +Settings RustRover users typically configure first: + +| Zed Setting | What it does | +| ----------------------- | ------------------------------------------------------------------------------- | +| `format_on_save` | Auto-format when saving. Set to `"on"` to enable (uses rustfmt by default). | +| `soft_wrap` | Wrap long lines. Options: `"none"`, `"editor_width"`, `"preferred_line_length"` | +| `preferred_line_length` | Column width for wrapping and rulers. Rust convention is 100. | +| `inlay_hints` | Show type hints, parameter names, and chaining hints inline. | +| `relative_line_numbers` | Useful if you're coming from IdeaVim. | + +Zed also supports per-project settings. Create a `.zed/settings.json` file in your project root to override global settings for that project, similar to how you might use `.idea` folders in RustRover. + +> **Tip:** If you're joining an existing project, check `format_on_save` before making your first commit. Otherwise you might accidentally reformat an entire file when you only meant to change one line. + +## Open or Create a Project + +After setup, press `Cmd+Shift+O` (with JetBrains keymap) to open a folder. This becomes your workspace in Zed. Unlike RustRover, there's no project configuration wizard, no toolchain selection dialog, and no Cargo project setup screen. + +To start a new project, use Cargo from the terminal: + +```sh +cargo new my_project +cd my_project +zed . +``` + +Or for a library: + +```sh +cargo new --lib my_library +``` + +You can also launch Zed from the terminal inside any existing Cargo project with: +`zed .` + +Once inside a project: + +- Use `Cmd+Shift+O` or `Cmd+E` to jump between files quickly (like RustRover's "Recent Files") +- Use `Cmd+Shift+A` or `Shift Shift` to open the Command Palette (like RustRover's "Search Everywhere") +- Use `Cmd+O` to search for symbols (like RustRover's "Go to Symbol") + +Open buffers appear as tabs across the top. The sidebar shows your file tree and Git status. Toggle it with `Cmd+1` (just like RustRover's Project tool window). + +## Differences in Keybindings + +If you chose the JetBrains keymap during onboarding, most of your shortcuts should already feel familiar. Here's a quick reference for how Zed compares to RustRover. + +### Common Shared Keybindings + +| Action | Shortcut | +| ----------------------------- | ----------------------- | +| Search Everywhere | `Shift Shift` | +| Find Action / Command Palette | `Cmd + Shift + A` | +| Go to File | `Cmd + Shift + O` | +| Go to Symbol | `Cmd + O` | +| Recent Files | `Cmd + E` | +| Go to Definition | `Cmd + B` | +| Find Usages | `Alt + F7` | +| Rename Symbol | `Shift + F6` | +| Reformat Code | `Cmd + Alt + L` | +| Toggle Project Panel | `Cmd + 1` | +| Toggle Terminal | `Alt + F12` | +| Duplicate Line | `Cmd + D` | +| Delete Line | `Cmd + Backspace` | +| Move Line Up/Down | `Shift + Alt + Up/Down` | +| Expand/Shrink Selection | `Alt + Up/Down` | +| Comment Line | `Cmd + /` | +| Go Back / Forward | `Cmd + [` / `Cmd + ]` | +| Toggle Breakpoint | `Ctrl + F8` | + +### Different Keybindings (RustRover → Zed) + +| Action | RustRover | Zed (JetBrains keymap) | +| ---------------------- | ----------- | ------------------------ | +| File Structure | `Cmd + F12` | `Cmd + F12` (outline) | +| Navigate to Next Error | `F2` | `F2` | +| Run | `Ctrl + R` | `Ctrl + Alt + R` (tasks) | +| Debug | `Ctrl + D` | `Alt + Shift + F9` | +| Stop | `Cmd + F2` | `Ctrl + F2` | +| Expand Macro | `Alt+Enter` | `Cmd + Shift + M` | + +### Unique to Zed + +| Action | Shortcut | Notes | +| ----------------- | -------------------------- | ------------------------------ | +| Toggle Right Dock | `Cmd + R` | Assistant panel, notifications | +| Split Panes | `Cmd + K`, then arrow keys | Create splits in any direction | + +### How to Customize Keybindings + +- Open the Command Palette (`Cmd+Shift+A` or `Shift Shift`) +- Run `Zed: Open Keymap Editor` + +This opens a list of all available bindings. You can override individual shortcuts or remove conflicts. + +Zed also supports key sequences (multi-key shortcuts). + +## Differences in User Interfaces + +### No Indexing + +RustRover indexes your project when you first open it to build a model of your codebase. This process runs whenever you open a project or when dependencies change via Cargo. + +Zed skips the indexing step. You open a folder and start working right away. Since both editors rely on rust-analyzer for Rust intelligence, the analysis still happens—but in Zed it runs in the background without blocking the UI or showing modal progress dialogs. + +**How to adapt:** + +- Use `Cmd+O` to search symbols across your crate (rust-analyzer handles this) +- Jump to files by name with `Cmd+Shift+O` +- `Cmd+Shift+F` gives you fast text search across the entire project +- For linting and deeper checks, run `cargo clippy` in the terminal + +### rust-analyzer: Shared Foundation, Different Integration + +Here's what makes the RustRover-to-Zed transition unique: **both editors use rust-analyzer** for Rust language intelligence. This means the core code analysis—completions, go-to-definition, find references, type inference—is fundamentally the same. + +RustRover integrates rust-analyzer into its JetBrains platform, adding a GUI layer, additional refactorings, and its own indexing on top. Zed uses rust-analyzer more directly through the Language Server Protocol (LSP). + +What this means for you: + +- **Completions** — Same quality, powered by rust-analyzer +- **Type inference** — Identical, it's the same engine +- **Go to definition / Find usages** — Works the same way +- **Macro expansion** — Available in both (use `Cmd+Shift+M` in Zed) +- **Inlay hints** — Both support type hints, parameter hints, and chaining hints + +Where you might notice differences: + +- Some refactorings available in RustRover may not have rust-analyzer equivalents +- RustRover's GUI for configuring rust-analyzer is replaced by JSON configuration in Zed +- RustRover-specific inspections (beyond Clippy) won't exist in Zed + +**How to adapt:** + +- Use `Alt+Enter` for available code actions—rust-analyzer provides many +- Configure rust-analyzer settings in `.zed/settings.json` for project-specific needs +- Run `cargo clippy` for linting (it integrates with rust-analyzer diagnostics) + +### No Project Model + +RustRover manages projects through `.idea` folders containing XML configuration files, toolchain assignments, and run configurations. The Cargo tool window provides a visual interface for your project structure, targets, and dependencies. + +Zed keeps it simpler: a project is a folder with a `Cargo.toml`. No project wizard, no toolchain dialogs, no visual Cargo management layer. + +In practice: + +- Run configurations don't carry over. Your `.idea/` setup stays behind—define the commands you need in `tasks.json` instead. +- Toolchains are managed externally via `rustup`. +- Dependencies live in `Cargo.toml`. Edit the file directly; rust-analyzer provides completions for crate names and versions. + +**How to adapt:** + +- Create a `.zed/settings.json` in your project root for project-specific settings +- Define common commands in `tasks.json` (open via Command Palette: `zed: open tasks`): + +```json +[ + { + "label": "cargo run", + "command": "cargo run" + }, + { + "label": "cargo build", + "command": "cargo build" + }, + { + "label": "cargo test", + "command": "cargo test" + }, + { + "label": "cargo clippy", + "command": "cargo clippy" + }, + { + "label": "cargo run --release", + "command": "cargo run --release" + } +] +``` + +- Use `Ctrl+Alt+R` to run tasks quickly +- Lean on your terminal (`Alt+F12`) for anything tasks don't cover + +### No Cargo Integration UI + +RustRover's Cargo tool window provides visual access to your project's targets, dependencies, and common Cargo commands. You can run builds, tests, and benchmarks with a click. + +Zed doesn't have a Cargo GUI. You work with Cargo through: + +- **Terminal** — Run any Cargo command directly +- **Tasks** — Define shortcuts for common commands +- **Gutter icons** — Run tests and binaries with clickable icons + +**How to adapt:** + +- Get comfortable with Cargo CLI commands: `cargo build`, `cargo run`, `cargo test`, `cargo clippy`, `cargo doc` +- Use tasks for commands you run frequently +- For dependency management, edit `Cargo.toml` directly (rust-analyzer provides completions for crate names and versions) + +### Tool Windows vs. Docks + +RustRover organizes auxiliary views into numbered tool windows (Project = 1, Cargo = Alt+1, Terminal = Alt+F12, etc.). Zed uses a similar concept called "docks": + +| RustRover Tool Window | Zed Equivalent | Shortcut (JetBrains keymap) | +| --------------------- | -------------- | --------------------------- | +| Project (1) | Project Panel | `Cmd + 1` | +| Git (9 or Cmd+0) | Git Panel | `Cmd + 0` | +| Terminal (Alt+F12) | Terminal Panel | `Alt + F12` | +| Structure (7) | Outline Panel | `Cmd + 7` | +| Problems (6) | Diagnostics | `Cmd + 6` | +| Debug (5) | Debug Panel | `Cmd + 5` | + +Zed has three dock positions: left, bottom, and right. Panels can be moved between docks by dragging or through settings. + +Note that there's no dedicated Cargo tool window in Zed. Use the terminal or define tasks for your common Cargo commands. + +### Debugging + +Both RustRover and Zed offer integrated debugging for Rust, but using different backends: + +- RustRover uses its own debugger integration +- Zed uses **CodeLLDB** (the same debug adapter popular in VS Code) + +To debug Rust code in Zed: + +- Set breakpoints with `Ctrl+F8` +- Start debugging with `Alt+Shift+F9` or press `F4` and select a debug target +- Step through code with `F7` (step into), `F8` (step over), `Shift+F8` (step out) +- Continue execution with `F9` + +Zed can automatically detect debuggable targets in your Cargo project. Press `F4` to see available options. + +For more control, create a `.zed/debug.json` file: + +```json +[ + { + "label": "Debug Binary", + "adapter": "CodeLLDB", + "request": "launch", + "program": "${workspaceFolder}/target/debug/my_project" + }, + { + "label": "Debug Tests", + "adapter": "CodeLLDB", + "request": "launch", + "cargo": { + "args": ["test", "--no-run"], + "filter": { + "kind": "test" + } + } + }, + { + "label": "Debug with Arguments", + "adapter": "CodeLLDB", + "request": "launch", + "program": "${workspaceFolder}/target/debug/my_project", + "args": ["--config", "dev.toml"] + } +] +``` + +> **Note:** Some users have reported that RustRover's debugger can have issues with variable inspection and breakpoints in certain scenarios. CodeLLDB in Zed provides a solid alternative, though debugging Rust can be challenging in any editor due to optimizations and macro-generated code. + +### Running Tests + +RustRover has a dedicated test runner with a visual interface showing pass/fail status for each test. Zed provides test running through: + +- **Gutter icons** — Click the play button next to `#[test]` functions or test modules +- **Tasks** — Define `cargo test` commands in `tasks.json` +- **Terminal** — Run `cargo test` directly + +The test output appears in the terminal panel. For more detailed output, use: + +- `cargo test -- --nocapture` to see println! output +- `cargo test -- --test-threads=1` for sequential test execution +- `cargo test specific_test_name` to run a single test + +### Extensions vs. Plugins + +RustRover has a plugin ecosystem, though it's more limited than other JetBrains IDEs since Rust support is built-in. + +Zed's extension ecosystem is smaller and more focused: + +- Language support and syntax highlighting +- Themes +- Slash commands for AI +- Context servers + +Several features that might require plugins in other editors are built into Zed: + +- Real-time collaboration with voice chat +- AI coding assistance +- Built-in terminal +- Task runner +- rust-analyzer integration +- rustfmt formatting + +### What's Not in Zed + +To set expectations clearly, here's what RustRover offers that Zed doesn't have: + +- **Cargo.toml GUI editor** — Edit the file directly (rust-analyzer helps with completions) +- **Visual dependency management** — Use `cargo add`, `cargo remove`, or edit `Cargo.toml` +- **Profiler integration** — Use `cargo flamegraph`, `perf`, or external profiling tools +- **Database tools** — Use DataGrip, DBeaver, or TablePlus +- **HTTP Client** — Use tools like `curl`, `httpie`, or Postman +- **Coverage visualization** — Use `cargo tarpaulin` or `cargo llvm-cov` externally + +## A Note on Licensing and Telemetry + +If you're moving from RustRover partly due to licensing concerns or telemetry policies, you should know: + +- **Zed is open source** (MIT licensed for the editor, AGPL for collaboration services) +- **Telemetry is optional** and can be disabled during onboarding or in settings +- **No license tiers**: All features are available to everyone + +## Collaboration in Zed vs. RustRover + +RustRover offers Code With Me as a separate feature for collaboration. Zed has collaboration built into the core experience. + +- Open the Collab Panel in the left dock +- Create a channel and [invite your collaborators](https://zed.dev/docs/collaboration#inviting-a-collaborator) to join +- [Share your screen or your codebase](https://zed.dev/docs/collaboration#share-a-project) directly + +Once connected, you'll see each other's cursors, selections, and edits in real time. Voice chat is included. There's no need for separate tools or third-party logins. + +## Using AI in Zed + +If you're used to AI assistants in RustRover (like JetBrains AI Assistant), Zed offers similar capabilities with more flexibility. + +### Configuring GitHub Copilot + +1. Open Settings with `Cmd+,` (macOS) or `Ctrl+,` (Linux/Windows) +2. Navigate to **AI → Edit Predictions** +3. Click **Configure** next to "Configure Providers" +4. Under **GitHub Copilot**, click **Sign in to GitHub** + +Once signed in, just start typing. Zed will offer suggestions inline for you to accept. + +### Additional AI Options + +To use other AI models in Zed, you have several options: + +- Use Zed's hosted models, with higher rate limits. Requires [authentication](https://zed.dev/docs/accounts.html) and subscription to [Zed Pro](https://zed.dev/docs/ai/subscription.html). +- Bring your own [API keys](https://zed.dev/docs/ai/llm-providers.html), no authentication needed +- Use [external agents like Claude Code](https://zed.dev/docs/ai/external-agents.html) + +## Advanced Config and Productivity Tweaks + +Zed exposes advanced settings for power users who want to fine-tune their environment. + +Here are a few useful tweaks for Rust developers: + +**Format on Save (uses rustfmt by default):** + +```json +"format_on_save": "on" +``` + +**Configure inlay hints for Rust:** + +```json +{ + "inlay_hints": { + "enabled": true, + "show_type_hints": true, + "show_parameter_hints": true, + "show_other_hints": true + } +} +``` + +**Configure rust-analyzer settings:** + +```json +{ + "lsp": { + "rust-analyzer": { + "initialization_options": { + "checkOnSave": { + "command": "clippy" + }, + "cargo": { + "allFeatures": true + }, + "procMacro": { + "enable": true + } + } + } + } +} +``` + +**Use a separate target directory for rust-analyzer (faster builds):** + +```json +{ + "lsp": { + "rust-analyzer": { + "initialization_options": { + "rust-analyzer.cargo.targetDir": true + } + } + } +} +``` + +This tells rust-analyzer to use `target/rust-analyzer` instead of `target`, so IDE analysis doesn't conflict with your manual `cargo build` commands. + +**Enable direnv support (useful for Rust projects using direnv):** + +```json +"load_direnv": "shell_hook" +``` + +**Configure linked projects for workspaces:** + +If you work with multiple Cargo projects that aren't in a workspace, you can tell rust-analyzer about them: + +```json +{ + "lsp": { + "rust-analyzer": { + "initialization_options": { + "linkedProjects": ["./project-a/Cargo.toml", "./project-b/Cargo.toml"] + } + } + } +} +``` + +## Next Steps + +Now that you're set up, here are some resources to help you get the most out of Zed: + +- [Configuring Zed](../configuring-zed.md) — Customize settings, themes, and editor behavior +- [Key Bindings](../key-bindings.md) — Learn how to customize and extend your keymap +- [Tasks](../tasks.md) — Set up build and run commands for your projects +- [AI Features](../ai/overview.md) — Explore Zed's AI capabilities beyond code completion +- [Collaboration](../collaboration/overview.md) — Share your projects and code together in real time +- [Rust in Zed](../languages/rust.md) — Rust-specific setup and configuration diff --git a/docs/src/migrate/webstorm.md b/docs/src/migrate/webstorm.md new file mode 100644 index 0000000000000000000000000000000000000000..78b80b355b47370a821f08fd6108d947182f0acf --- /dev/null +++ b/docs/src/migrate/webstorm.md @@ -0,0 +1,455 @@ +# How to Migrate from WebStorm to Zed + +This guide covers how to set up Zed if you're coming from WebStorm, including keybindings, settings, and the differences you should expect as a JavaScript/TypeScript developer. + +## Install Zed + +Zed is available on macOS, Windows, and Linux. + +For macOS, you can download it from zed.dev/download, or install via Homebrew: + +```sh +brew install --cask zed +``` + +For Windows, download the installer from zed.dev/download, or install via winget: + +```sh +winget install Zed.Zed +``` + +For most Linux users, the easiest way to install Zed is through our installation script: + +```sh +curl -f https://zed.dev/install.sh | sh +``` + +After installation, you can launch Zed from your Applications folder (macOS), Start menu (Windows), or directly from the terminal using: +`zed .` +This opens the current directory in Zed. + +## Set Up the JetBrains Keymap + +If you're coming from WebStorm, the fastest way to feel at home is to use the JetBrains keymap. During onboarding, you can select it as your base keymap. If you missed that step, you can change it anytime: + +1. Open Settings with `Cmd+,` (macOS) or `Ctrl+,` (Linux/Windows) +2. Search for `Base Keymap` +3. Select `JetBrains` + +Or add this directly to your `settings.json`: + +```json +{ + "base_keymap": "JetBrains" +} +``` + +This maps familiar shortcuts like `Shift Shift` for Search Everywhere, `Cmd+O` for Go to Class, and `Cmd+Shift+A` for Find Action. + +## Set Up Editor Preferences + +You can configure settings manually in the Settings Editor. + +To edit your settings: + +1. `Cmd+,` to open the Settings Editor. +2. Run `zed: open settings` in the Command Palette. + +Settings WebStorm users typically configure first: + +| Zed Setting | What it does | +| ----------------------- | ------------------------------------------------------------------------------- | +| `format_on_save` | Auto-format when saving. Set to `"on"` to enable. | +| `soft_wrap` | Wrap long lines. Options: `"none"`, `"editor_width"`, `"preferred_line_length"` | +| `preferred_line_length` | Column width for wrapping and rulers. Default is 80. | +| `inlay_hints` | Show parameter names and type hints inline, like WebStorm's hints. | +| `relative_line_numbers` | Useful if you're coming from IdeaVim. | + +Zed also supports per-project settings. Create a `.zed/settings.json` file in your project root to override global settings for that project, similar to how you might use `.idea` folders in WebStorm. + +> **Tip:** If you're joining an existing project, check `format_on_save` before making your first commit. Otherwise you might accidentally reformat an entire file when you only meant to change one line. + +## Open or Create a Project + +After setup, press `Cmd+Shift+O` (with JetBrains keymap) to open a folder. This becomes your workspace in Zed. Unlike WebStorm, there's no project configuration wizard, no framework selection dialog, and no project structure setup required. + +To start a new project, create a directory using your terminal or file manager, then open it in Zed. The editor will treat that folder as the root of your project. For new projects, you'd typically run `npm init`, `pnpm create`, or your framework's CLI tool first, then open the resulting folder in Zed. + +You can also launch Zed from the terminal inside any folder with: +`zed .` + +Once inside a project: + +- Use `Cmd+Shift+O` or `Cmd+E` to jump between files quickly (like WebStorm's "Recent Files") +- Use `Cmd+Shift+A` or `Shift Shift` to open the Command Palette (like WebStorm's "Search Everywhere") +- Use `Cmd+O` to search for symbols (like WebStorm's "Go to Symbol") + +Open buffers appear as tabs across the top. The sidebar shows your file tree and Git status. Toggle it with `Cmd+1` (just like WebStorm's Project tool window). + +## Differences in Keybindings + +If you chose the JetBrains keymap during onboarding, most of your shortcuts should already feel familiar. Here's a quick reference for how Zed compares to WebStorm. + +### Common Shared Keybindings + +| Action | Shortcut | +| ----------------------------- | ----------------------- | +| Search Everywhere | `Shift Shift` | +| Find Action / Command Palette | `Cmd + Shift + A` | +| Go to File | `Cmd + Shift + O` | +| Go to Symbol | `Cmd + O` | +| Recent Files | `Cmd + E` | +| Go to Definition | `Cmd + B` | +| Find Usages | `Alt + F7` | +| Rename Symbol | `Shift + F6` | +| Reformat Code | `Cmd + Alt + L` | +| Toggle Project Panel | `Cmd + 1` | +| Toggle Terminal | `Alt + F12` | +| Duplicate Line | `Cmd + D` | +| Delete Line | `Cmd + Backspace` | +| Move Line Up/Down | `Shift + Alt + Up/Down` | +| Expand/Shrink Selection | `Alt + Up/Down` | +| Comment Line | `Cmd + /` | +| Go Back / Forward | `Cmd + [` / `Cmd + ]` | +| Toggle Breakpoint | `Ctrl + F8` | + +### Different Keybindings (WebStorm → Zed) + +| Action | WebStorm | Zed (JetBrains keymap) | +| ---------------------- | ----------- | ------------------------ | +| File Structure | `Cmd + F12` | `Cmd + F12` (outline) | +| Navigate to Next Error | `F2` | `F2` | +| Run | `Ctrl + R` | `Ctrl + Alt + R` (tasks) | +| Debug | `Ctrl + D` | `Alt + Shift + F9` | +| Stop | `Cmd + F2` | `Ctrl + F2` | + +### Unique to Zed + +| Action | Shortcut | Notes | +| ----------------- | -------------------------- | ------------------------------ | +| Toggle Right Dock | `Cmd + R` | Assistant panel, notifications | +| Split Panes | `Cmd + K`, then arrow keys | Create splits in any direction | + +### How to Customize Keybindings + +- Open the Command Palette (`Cmd+Shift+A` or `Shift Shift`) +- Run `Zed: Open Keymap Editor` + +This opens a list of all available bindings. You can override individual shortcuts or remove conflicts. + +Zed also supports key sequences (multi-key shortcuts). + +## Differences in User Interfaces + +### No Indexing + +If you've used WebStorm on large projects, you know the wait. Opening a project with many dependencies can mean watching "Indexing..." for anywhere from 30 seconds to several minutes. WebStorm indexes your entire codebase and `node_modules` to power its code intelligence, and re-indexes when dependencies change. + +Zed doesn't index. You open a folder and start coding immediately—no progress bars, no "Indexing paused" banners. File search and navigation stay fast regardless of project size or how many `node_modules` dependencies you have. + +WebStorm's index enables features like finding all usages across your entire codebase, tracking import hierarchies, and flagging unused exports project-wide. Zed relies on language servers for this analysis, which may not cover as much ground. + +**How to adapt:** + +- Search symbols across the project with `Cmd+O` (powered by the TypeScript language server) +- Find files by name with `Cmd+Shift+O` +- Use `Cmd+Shift+F` for text search—it stays fast even in large monorepos +- Run `tsc --noEmit` or `eslint .` from the terminal when you need deeper project-wide analysis + +### LSP vs. Native Language Intelligence + +WebStorm has its own JavaScript and TypeScript analysis engine built by JetBrains. This engine understands your code deeply: it resolves types, tracks data flow, knows about framework-specific patterns, and offers specialized refactorings. + +Zed uses the Language Server Protocol (LSP) for code intelligence. For JavaScript and TypeScript, Zed supports: + +- **vtsls** (default) — Fast TypeScript language server with excellent performance +- **typescript-language-server** — The standard TypeScript LSP implementation +- **ESLint** — Linting integration +- **Prettier** — Code formatting (built-in) + +The TypeScript LSP experience is mature and robust. You get accurate completions, type checking, go-to-definition, and find-references. The experience is comparable to VS Code, which uses the same underlying TypeScript services. + +Where you might notice differences: + +- Framework-specific intelligence (Angular templates, Vue SFCs) may be less integrated +- Some complex refactorings (extract component with proper imports) may be less sophisticated +- Auto-import suggestions depend on what the language server knows about your project + +**How to adapt:** + +- Use `Alt+Enter` for available code actions—the list will vary by language server +- Ensure your `tsconfig.json` is properly configured so the language server understands your project structure +- Use Prettier for consistent formatting (it's enabled by default for JS/TS) +- For code inspection similar to WebStorm's "Inspect Code," check the Diagnostics panel (`Cmd+6`)—ESLint and TypeScript together catch many of the same issues + +### No Project Model + +WebStorm manages projects through `.idea` folders containing XML configuration files, framework detection, and run configurations. This model lets WebStorm remember your project settings, manage npm scripts through the UI, and persist run/debug setups. + +Zed takes a different approach: a project is just a folder. There's no setup wizard, no framework detection dialog, no project structure to configure. + +What this means in practice: + +- Run configurations aren't a thing. Define reusable commands in `tasks.json` instead. Note that your existing `.idea/` configurations won't carry over—you'll set up the ones you need fresh. +- npm scripts live in the terminal. Run `npm run dev`, `pnpm build`, or `yarn test` directly—there's no dedicated npm panel. +- No framework detection. Zed treats React, Angular, Vue, and vanilla JS/TS the same way. + +**How to adapt:** + +- Create a `.zed/settings.json` in your project root for project-specific settings +- Define common commands in `tasks.json` (open via Command Palette: `zed: open tasks`): + +```json +[ + { + "label": "dev", + "command": "npm run dev" + }, + { + "label": "build", + "command": "npm run build" + }, + { + "label": "test", + "command": "npm test" + }, + { + "label": "test current file", + "command": "npm test -- $ZED_FILE" + } +] +``` + +- Use `Ctrl+Alt+R` to run tasks quickly +- Lean on your terminal (`Alt+F12`) for anything tasks don't cover + +### No Framework Integration + +WebStorm's value for web development comes largely from its framework integration. React components get special treatment. Angular has dedicated tooling. Vue single-file components are fully understood. The npm tool window shows all your scripts. + +Zed has none of this built-in. The TypeScript language server sees your code as TypeScript—it doesn't understand that a function is a React component or that a file is an Angular service. + +**How to adapt:** + +- Use grep and file search liberally. `Cmd+Shift+F` with a regex can find component definitions, route configurations, or API endpoints. +- Rely on your language server's "find references" (`Alt+F7`) for navigation—it works, just without framework context +- Consider using framework-specific CLI tools (`ng`, `next`, `vite`) from Zed's terminal +- For React, JSX/TSX syntax and TypeScript types still provide good intelligence + +> **Tip:** For projects with complex configurations, keep your framework's documentation handy. Zed's speed comes with less hand-holding for framework-specific features. + +### Tool Windows vs. Docks + +WebStorm organizes auxiliary views into numbered tool windows (Project = 1, npm = Alt+F11, Terminal = Alt+F12, etc.). Zed uses a similar concept called "docks": + +| WebStorm Tool Window | Zed Equivalent | Shortcut (JetBrains keymap) | +| -------------------- | -------------- | --------------------------- | +| Project (1) | Project Panel | `Cmd + 1` | +| Git (9 or Cmd+0) | Git Panel | `Cmd + 0` | +| Terminal (Alt+F12) | Terminal Panel | `Alt + F12` | +| Structure (7) | Outline Panel | `Cmd + 7` | +| Problems (6) | Diagnostics | `Cmd + 6` | +| Debug (5) | Debug Panel | `Cmd + 5` | + +Zed has three dock positions: left, bottom, and right. Panels can be moved between docks by dragging or through settings. + +Note that there's no dedicated npm tool window in Zed. Use the terminal or define tasks for your common npm scripts. + +### Debugging + +Both WebStorm and Zed offer integrated debugging for JavaScript and TypeScript: + +- Zed uses `vscode-js-debug` (the same debug adapter that VS Code uses) +- Set breakpoints with `Ctrl+F8` +- Start debugging with `Alt+Shift+F9` or press `F4` and select a debug target +- Step through code with `F7` (step into), `F8` (step over), `Shift+F8` (step out) +- Continue execution with `F9` + +Zed can debug: + +- Node.js applications and scripts +- Chrome/browser JavaScript +- Jest, Mocha, Vitest, and other test frameworks +- Next.js (both server and client-side) + +For more control, create a `.zed/debug.json` file: + +```json +[ + { + "label": "Debug Current File", + "adapter": "JavaScript", + "program": "$ZED_FILE", + "request": "launch" + }, + { + "label": "Debug Node Server", + "adapter": "JavaScript", + "request": "launch", + "program": "${workspaceFolder}/src/server.js" + }, + { + "label": "Attach to Chrome", + "adapter": "JavaScript", + "request": "attach", + "port": 9222 + } +] +``` + +Zed also recognizes `.vscode/launch.json` configurations, so existing VS Code debug setups often work out of the box. + +### Running Tests + +WebStorm has a dedicated test runner with a visual interface showing pass/fail status for each test. Zed provides test running through: + +- **Gutter icons** — Click the play button next to test functions or describe blocks +- **Tasks** — Define test commands in `tasks.json` +- **Terminal** — Run `npm test`, `jest`, `vitest`, etc. directly + +Zed supports auto-detection for common test frameworks: + +- Jest +- Mocha +- Vitest +- Jasmine +- Bun test +- Node.js test runner + +The test output appears in the terminal panel. For Jest, use `--verbose` for detailed output or `--watch` for continuous testing during development. + +### Extensions vs. Plugins + +WebStorm has a plugin ecosystem covering additional language support, themes, and tool integrations. + +Zed's extension ecosystem is smaller and more focused: + +- Language support and syntax highlighting +- Themes +- Slash commands for AI +- Context servers + +Several features that require plugins in WebStorm are built into Zed: + +- Real-time collaboration with voice chat +- AI coding assistance +- Built-in terminal +- Task runner +- LSP-based code intelligence +- Prettier formatting +- ESLint integration + +### What's Not in Zed + +To set expectations clearly, here's what WebStorm offers that Zed doesn't have: + +- **npm tool window** — Use the terminal or tasks instead +- **HTTP Client** — Use tools like Postman, Insomnia, or curl +- **Database tools** — Use DataGrip, DBeaver, or TablePlus +- **Framework-specific tooling** (Angular schematics, React refactorings) — Use CLI tools +- **Visual package.json editor** — Edit the file directly +- **Built-in REST client** — Use external tools or extensions +- **Profiler integration** — Use Chrome DevTools or Node.js profiling tools + +## Collaboration in Zed vs. WebStorm + +WebStorm offers Code With Me as a separate feature for collaboration. Zed has collaboration built into the core experience. + +- Open the Collab Panel in the left dock +- Create a channel and [invite your collaborators](https://zed.dev/docs/collaboration#inviting-a-collaborator) to join +- [Share your screen or your codebase](https://zed.dev/docs/collaboration#share-a-project) directly + +Once connected, you'll see each other's cursors, selections, and edits in real time. Voice chat is included. There's no need for separate tools or third-party logins. + +## Using AI in Zed + +If you're used to AI assistants in WebStorm (like GitHub Copilot, JetBrains AI Assistant, or Junie), Zed offers similar capabilities with more flexibility. + +### Configuring GitHub Copilot + +1. Open Settings with `Cmd+,` (macOS) or `Ctrl+,` (Linux/Windows) +2. Navigate to **AI → Edit Predictions** +3. Click **Configure** next to "Configure Providers" +4. Under **GitHub Copilot**, click **Sign in to GitHub** + +Once signed in, just start typing. Zed will offer suggestions inline for you to accept. + +### Additional AI Options + +To use other AI models in Zed, you have several options: + +- Use Zed's hosted models, with higher rate limits. Requires [authentication](https://zed.dev/docs/accounts.html) and subscription to [Zed Pro](https://zed.dev/docs/ai/subscription.html). +- Bring your own [API keys](https://zed.dev/docs/ai/llm-providers.html), no authentication needed +- Use [external agents like Claude Code](https://zed.dev/docs/ai/external-agents.html) + +## Advanced Config and Productivity Tweaks + +Zed exposes advanced settings for power users who want to fine-tune their environment. + +Here are a few useful tweaks for JavaScript/TypeScript developers: + +**Format on Save:** + +```json +"format_on_save": "on" +``` + +**Configure Prettier as the default formatter:** + +```json +{ + "formatter": { + "external": { + "command": "prettier", + "arguments": ["--stdin-filepath", "{buffer_path}"] + } + } +} +``` + +**Enable ESLint code actions:** + +```json +{ + "lsp": { + "eslint": { + "settings": { + "codeActionOnSave": { + "rules": ["import/order"] + } + } + } + } +} +``` + +**Configure TypeScript strict mode hints:** + +In your `tsconfig.json`, enable strict mode for better type checking: + +```json +{ + "compilerOptions": { + "strict": true, + "noUncheckedIndexedAccess": true + } +} +``` + +**Enable direnv support (useful for projects using direnv for environment variables):** + +```json +"load_direnv": "shell_hook" +``` + +## Next Steps + +Now that you're set up, here are some resources to help you get the most out of Zed: + +- [Configuring Zed](../configuring-zed.md) — Customize settings, themes, and editor behavior +- [Key Bindings](../key-bindings.md) — Learn how to customize and extend your keymap +- [Tasks](../tasks.md) — Set up build and run commands for your projects +- [AI Features](../ai/overview.md) — Explore Zed's AI capabilities beyond code completion +- [Collaboration](../collaboration/overview.md) — Share your projects and code together in real time +- [JavaScript in Zed](../languages/javascript.md) — JavaScript-specific setup and configuration +- [TypeScript in Zed](../languages/typescript.md) — TypeScript-specific setup and configuration diff --git a/docs/src/worktree-trust.md b/docs/src/worktree-trust.md new file mode 100644 index 0000000000000000000000000000000000000000..590f063a75ac5d77e60d50f03af4795d6ec2961f --- /dev/null +++ b/docs/src/worktree-trust.md @@ -0,0 +1,58 @@ +# Zed and trusted worktrees + +A worktree in Zed is either a directory or a single file that Zed opens as a standalone "project". +Zed opens a worktree every time `zed some/path` is invoked, on drag and dropping a file or directory into Zed, on opening user settings.json, etc. + +Every worktree opened may contain a `.zed/settings.json` file with extra configuration options that may require installing and spawning language servers or MCP servers. +In order to provide users the opportunity to make their own choices according to their unique threat model and risk tolerance, all worktrees will be started in Restricted mode, which prevents download and execution of any related items from `.zed/settings.json`. Until configured to trust the worktree(s), Zed will not perform any related untrusted actions and will wait for user confirmation. This gives users a chance to review and understand any pre-configured settings, MCP servers, or language servers associated with a project. + +Note that at this point, Zed trusts the tools it installs itself, hence global entities such as global MCP servers, language servers like prettier and copilot are still in installed and started as usual, independent of worktree trust. + +If a worktree is not trusted, Zed will indicate this with an exclamation mark icon in the title bar. Clicking this icon or using `workspace::ToggleWorktreeSecurity` action will bring up the security modal that allows the user to trust the worktree. + +Trusting any worktree will persist this information between restarts. It's possible to clear all trusted worktrees with `workspace::ClearTrustedWorktrees` command. +This command will restart Zed, to ensure no untrusted settings, language servers or MCP servers persist. + +This feature works locally and on SSH and WSL remote hosts. Zed tracks trust information per host in these cases. + +## What is restricted + +Restricted Mode prevents: + +- Project settings (`.zed/settings.json`) from being parsed and applied +- Language servers from being installed and spawned +- MCP servers from being installed and spawned + +## Configuring broad worktree trust + +By default, Zed won't trust any new worktrees and users will be required to trust each new worktree. Though not recommended, users may elect to trust all worktrees by configuring the following setting: + +```json [settings] +"session": { + "trust_all_worktrees": true +} +``` + +Note that auto trusted worktrees are not persisted between restarts, only manually trusted worktrees are. This ensures that new trust decisions must be made if a users elects to disable the `trust_all_worktrees` setting. + +## Trust hierarchy + +These are mostly internal details and may change in the future, but are helpful to understand how multiple different trust requests can be approved at once. +Zed has multiple layers of trust, based on the requests, from the least to most trusted level: + +- "single file worktree" + +After opening an empty Zed it's possible to open just a file, same as after opening a directory in Zed it's possible to open a file outside of this directory. +A typical scenario where a directory might be open and a single file is subsequently opened is opening Zed's settings.json file via `zed: open settings file` command: that starts a language server for a new file open, which originates from a newly created, single file worktree. + +Spawning a language server presents a risk should the language server experience a supply-chain attack; therefore, Zed restricts that by default. Each single file worktree requires a separate trust grant, unless the directory containing it is trusted or all worktrees are trusted. + +- "directory worktree" + +If a directory is open in Zed, it's a full worktree which may spawn multiple language servers associated with it or spawn MCP servers if contained in a project settings file.Therefore, each directory worktree requires a separate trust grant unless a parent directory worktree trust is granted (see below). + +When a directory worktree is trusted, language and MCP servers are permitted to be downloaded and started, hence we also enable single file worktree trust for the host in question automatically when this occurs: this helps when opening single files when using language server features in the trusted directory worktree. + +- "parent directory worktree" + +To permit trust decisions for multiple directory worktrees at once, it's possible to trust all subdirectories of a given parent directory worktree opened in Zed by checking the appropriate checkbox. This will grant trust to all its subdirectories, including all current and potential directory worktrees. diff --git a/flake.lock b/flake.lock index 3074b947ef51c387b5d20aba85478636f48de557..561919d5745a2355aad14a4fe9972bf9fbf3d8d2 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "crane": { "locked": { - "lastModified": 1762538466, - "narHash": "sha256-8zrIPl6J+wLm9MH5ksHcW7BUHo7jSNOu0/hA0ohOOaM=", + "lastModified": 1765145449, + "narHash": "sha256-aBVHGWWRzSpfL++LubA0CwOOQ64WNLegrYHwsVuVN7A=", "owner": "ipetkov", "repo": "crane", - "rev": "0cea393fffb39575c46b7a0318386467272182fe", + "rev": "69f538cdce5955fcd47abfed4395dc6d5194c1c5", "type": "github" }, "original": { @@ -17,11 +17,11 @@ }, "flake-compat": { "locked": { - "lastModified": 1761588595, - "narHash": "sha256-XKUZz9zewJNUj46b4AJdiRZJAvSZ0Dqj2BNfXvFlJC4=", + "lastModified": 1765121682, + "narHash": "sha256-4VBOP18BFeiPkyhy9o4ssBNQEvfvv1kXkasAYd0+rrA=", "owner": "edolstra", "repo": "flake-compat", - "rev": "f387cd2afec9419c8ee37694406ca490c3f34ee5", + "rev": "65f23138d8d09a92e30f1e5c87611b23ef451bf3", "type": "github" }, "original": { @@ -32,11 +32,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 315532800, - "narHash": "sha256-5CwQ80ucRHiqVbMEEbTFnjz70/axSJ0aliyzSaFSkmY=", - "rev": "f6b44b2401525650256b977063dbcf830f762369", + "lastModified": 1765772535, + "narHash": "sha256-I715zWsdVZ+CipmLtoCAeNG0etQywiWRE5PaWntnaYk=", + "rev": "09b8fda8959d761445f12b55f380d90375a1d6bb", "type": "tarball", - "url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.11pre891648.f6b44b240152/nixexprs.tar.xz" + "url": "https://releases.nixos.org/nixpkgs/nixpkgs-26.05pre911985.09b8fda8959d/nixexprs.tar.xz" }, "original": { "type": "tarball", @@ -53,16 +53,14 @@ }, "rust-overlay": { "inputs": { - "nixpkgs": [ - "nixpkgs" - ] + "nixpkgs": ["nixpkgs"] }, "locked": { - "lastModified": 1762915112, - "narHash": "sha256-d9j1g8nKmYDHy+/bIOPQTh9IwjRliqaTM0QLHMV92Ic=", + "lastModified": 1765465581, + "narHash": "sha256-fCXT0aZXmTalM3NPCTedVs9xb0egBG5BOZkcrYo5PGE=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "aa1e85921cfa04de7b6914982a94621fbec5cc02", + "rev": "99cc5667eece98bb35dcf35f7e511031a8b7a125", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index fe7a09701beb714506742f2712cb3a74b676bc19..744ca708a3a2104f0050cd85e8ee05f04e49a713 100644 --- a/flake.nix +++ b/flake.nix @@ -37,14 +37,14 @@ rustToolchain = rustBin.fromRustupToolchainFile ./rust-toolchain.toml; }; in - rec { + { packages = forAllSystems (pkgs: rec { default = mkZed pkgs; debug = default.override { profile = "dev"; }; }); devShells = forAllSystems (pkgs: { default = pkgs.callPackage ./nix/shell.nix { - zed-editor = packages.${pkgs.hostPlatform.system}.default; + zed-editor = mkZed pkgs; }; }); formatter = forAllSystems (pkgs: pkgs.nixfmt-rfc-style); diff --git a/nix/build.nix b/nix/build.nix index 484049a421f8de839fc157a45795637a12bd23b4..16b03e9a53bd2118c9b5bf45cf8fb7720ee5022b 100644 --- a/nix/build.nix +++ b/nix/build.nix @@ -83,70 +83,94 @@ let cargoLock = ../Cargo.lock; - nativeBuildInputs = - [ - cmake - copyDesktopItems - curl - perl - pkg-config - protobuf - cargo-about - rustPlatform.bindgenHook - ] - ++ lib.optionals stdenv'.hostPlatform.isLinux [ makeWrapper ] - ++ lib.optionals stdenv'.hostPlatform.isDarwin [ - (cargo-bundle.overrideAttrs ( - new: old: { - version = "0.6.1-zed"; - src = fetchFromGitHub { - owner = "zed-industries"; - repo = "cargo-bundle"; - rev = "2be2669972dff3ddd4daf89a2cb29d2d06cad7c7"; - hash = "sha256-cSvW0ND148AGdIGWg/ku0yIacVgW+9f1Nsi+kAQxVrI="; - }; - cargoHash = "sha256-urn+A3yuw2uAO4HGmvQnKvWtHqvG9KHxNCCWTiytE4k="; - - # NOTE: can drop once upstream uses `finalAttrs` here: - # https://github.com/NixOS/nixpkgs/blob/10214747f5e6e7cb5b9bdf9e018a3c7b3032f5af/pkgs/build-support/rust/build-rust-package/default.nix#L104 - # - # See (for context): https://github.com/NixOS/nixpkgs/pull/382550 - cargoDeps = rustPlatform.fetchCargoVendor { - inherit (new) src; - hash = new.cargoHash; - patches = new.cargoPatches or []; - name = new.cargoDepsName or new.finalPackage.name; - }; - } - )) - ]; - - buildInputs = - [ - curl - fontconfig - freetype - # TODO: need staticlib of this for linking the musl remote server. - # should make it a separate derivation/flake output - # see https://crane.dev/examples/cross-musl.html - libgit2 - openssl - sqlite - zlib - zstd - ] - ++ lib.optionals stdenv'.hostPlatform.isLinux [ - alsa-lib - libxkbcommon - wayland - gpu-lib - xorg.libX11 - xorg.libxcb - ] - ++ lib.optionals stdenv'.hostPlatform.isDarwin [ - apple-sdk_15 - (darwinMinVersionHook "10.15") - ]; + nativeBuildInputs = [ + cmake + copyDesktopItems + curl + perl + pkg-config + protobuf + # Pin cargo-about to 0.8.2. Newer versions don't work with the current license identifiers + # See https://github.com/zed-industries/zed/pull/44012 + (cargo-about.overrideAttrs ( + new: old: rec { + version = "0.8.2"; + + src = fetchFromGitHub { + owner = "EmbarkStudios"; + repo = "cargo-about"; + tag = version; + sha256 = "sha256-cNKZpDlfqEXeOE5lmu79AcKOawkPpk4PQCsBzNtIEbs="; + }; + + cargoHash = "sha256-NnocSs6UkuF/mCM3lIdFk+r51Iz2bHuYzMT/gEbT/nk="; + + # NOTE: can drop once upstream uses `finalAttrs` here: + # https://github.com/NixOS/nixpkgs/blob/10214747f5e6e7cb5b9bdf9e018a3c7b3032f5af/pkgs/build-support/rust/build-rust-package/default.nix#L104 + # + # See (for context): https://github.com/NixOS/nixpkgs/pull/382550 + cargoDeps = rustPlatform.fetchCargoVendor { + inherit (new) src; + hash = new.cargoHash; + patches = new.cargoPatches or [ ]; + name = new.cargoDepsName or new.finalPackage.name; + }; + } + )) + rustPlatform.bindgenHook + ] + ++ lib.optionals stdenv'.hostPlatform.isLinux [ makeWrapper ] + ++ lib.optionals stdenv'.hostPlatform.isDarwin [ + (cargo-bundle.overrideAttrs ( + new: old: { + version = "0.6.1-zed"; + src = fetchFromGitHub { + owner = "zed-industries"; + repo = "cargo-bundle"; + rev = "2be2669972dff3ddd4daf89a2cb29d2d06cad7c7"; + hash = "sha256-cSvW0ND148AGdIGWg/ku0yIacVgW+9f1Nsi+kAQxVrI="; + }; + cargoHash = "sha256-urn+A3yuw2uAO4HGmvQnKvWtHqvG9KHxNCCWTiytE4k="; + + # NOTE: can drop once upstream uses `finalAttrs` here: + # https://github.com/NixOS/nixpkgs/blob/10214747f5e6e7cb5b9bdf9e018a3c7b3032f5af/pkgs/build-support/rust/build-rust-package/default.nix#L104 + # + # See (for context): https://github.com/NixOS/nixpkgs/pull/382550 + cargoDeps = rustPlatform.fetchCargoVendor { + inherit (new) src; + hash = new.cargoHash; + patches = new.cargoPatches or [ ]; + name = new.cargoDepsName or new.finalPackage.name; + }; + } + )) + ]; + + buildInputs = [ + curl + fontconfig + freetype + # TODO: need staticlib of this for linking the musl remote server. + # should make it a separate derivation/flake output + # see https://crane.dev/examples/cross-musl.html + libgit2 + openssl + sqlite + zlib + zstd + ] + ++ lib.optionals stdenv'.hostPlatform.isLinux [ + alsa-lib + libxkbcommon + wayland + gpu-lib + xorg.libX11 + xorg.libxcb + ] + ++ lib.optionals stdenv'.hostPlatform.isDarwin [ + apple-sdk_15 + (darwinMinVersionHook "10.15") + ]; cargoExtraArgs = "-p zed -p cli --locked --features=gpui/runtime_shaders"; @@ -177,7 +201,7 @@ let ZED_UPDATE_EXPLANATION = "Zed has been installed using Nix. Auto-updates have thus been disabled."; RELEASE_VERSION = version; LK_CUSTOM_WEBRTC = livekit-libwebrtc; - PROTOC="${protobuf}/bin/protoc"; + PROTOC = "${protobuf}/bin/protoc"; CARGO_PROFILE = profile; # need to handle some profiles specially https://github.com/rust-lang/cargo/issues/11053 @@ -217,14 +241,13 @@ let # `webrtc-sys` expects a staticlib; nixpkgs' `livekit-webrtc` has been patched to # produce a `dylib`... patching `webrtc-sys`'s build script is the easier option # TODO: send livekit sdk a PR to make this configurable - postPatch = - '' - substituteInPlace webrtc-sys/build.rs --replace-fail \ - "cargo:rustc-link-lib=static=webrtc" "cargo:rustc-link-lib=dylib=webrtc" - '' - + lib.optionalString withGLES '' - cat ${glesConfig} >> .cargo/config/config.toml - ''; + postPatch = '' + substituteInPlace webrtc-sys/build.rs --replace-fail \ + "cargo:rustc-link-lib=static=webrtc" "cargo:rustc-link-lib=dylib=webrtc" + '' + + lib.optionalString withGLES '' + cat ${glesConfig} >> .cargo/config/config.toml + ''; in crates: drv: if hasWebRtcSys crates then diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 59765d94abe9c04e6668203de31b598dd6b34dc7..e7cc22421d71ba35b592dd2163da1927c4abf118 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,5 +1,5 @@ [toolchain] -channel = "1.91.1" +channel = "1.92" profile = "minimal" components = [ "rustfmt", "clippy" ] targets = [ diff --git a/tooling/xtask/src/tasks/workflows/autofix_pr.rs b/tooling/xtask/src/tasks/workflows/autofix_pr.rs index 835750e282dad39a3455fc0b5eb69bf82cc42201..f4a8e0e2b09df93cc430f0931c3db3f9e67b07df 100644 --- a/tooling/xtask/src/tasks/workflows/autofix_pr.rs +++ b/tooling/xtask/src/tasks/workflows/autofix_pr.rs @@ -8,31 +8,55 @@ use crate::tasks::workflows::{ pub fn autofix_pr() -> Workflow { let pr_number = WorkflowInput::string("pr_number", None); - let autofix = run_autofix(&pr_number); + let run_clippy = WorkflowInput::bool("run_clippy", Some(true)); + let run_autofix = run_autofix(&pr_number, &run_clippy); + let commit_changes = commit_changes(&pr_number, &run_autofix); named::workflow() .run_name(format!("autofix PR #{pr_number}")) .on(Event::default().workflow_dispatch( - WorkflowDispatch::default().add_input(pr_number.name, pr_number.input()), + WorkflowDispatch::default() + .add_input(pr_number.name, pr_number.input()) + .add_input(run_clippy.name, run_clippy.input()), )) - .add_job(autofix.name, autofix.job) + .concurrency( + Concurrency::new(Expression::new(format!( + "${{{{ github.workflow }}}}-{pr_number}" + ))) + .cancel_in_progress(true), + ) + .add_job(run_autofix.name.clone(), run_autofix.job) + .add_job(commit_changes.name, commit_changes.job) } -fn run_autofix(pr_number: &WorkflowInput) -> NamedJob { - fn authenticate_as_zippy() -> (Step, StepOutput) { - let step = named::uses( +const PATCH_ARTIFACT_NAME: &str = "autofix-patch"; +const PATCH_FILE_PATH: &str = "autofix.patch"; + +fn upload_patch_artifact() -> Step { + Step::new(format!("upload artifact {}", PATCH_ARTIFACT_NAME)) + .uses( "actions", - "create-github-app-token", - "bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1", + "upload-artifact", + "330a01c490aca151604b8cf639adc76d48f6c5d4", // v5 ) - .add_with(("app-id", vars::ZED_ZIPPY_APP_ID)) - .add_with(("private-key", vars::ZED_ZIPPY_APP_PRIVATE_KEY)) - .id("get-app-token"); - let output = StepOutput::new(&step, "token"); - (step, output) - } + .add_with(("name", PATCH_ARTIFACT_NAME)) + .add_with(("path", PATCH_FILE_PATH)) + .add_with(("if-no-files-found", "ignore")) + .add_with(("retention-days", "1")) +} - fn checkout_pr(pr_number: &WorkflowInput, token: &StepOutput) -> Step { - named::bash(&format!("gh pr checkout {pr_number}")).add_env(("GITHUB_TOKEN", token)) +fn download_patch_artifact() -> Step { + named::uses( + "actions", + "download-artifact", + "018cc2cf5baa6db3ef3c5f8a56943fffe632ef53", // v6.0.0 + ) + .add_with(("name", PATCH_ARTIFACT_NAME)) +} + +fn run_autofix(pr_number: &WorkflowInput, run_clippy: &WorkflowInput) -> NamedJob { + fn checkout_pr(pr_number: &WorkflowInput) -> Step { + named::bash(&format!("gh pr checkout {pr_number}")) + .add_env(("GITHUB_TOKEN", vars::GITHUB_TOKEN)) } fn run_cargo_fmt() -> Step { @@ -49,16 +73,68 @@ fn run_autofix(pr_number: &WorkflowInput) -> NamedJob { named::bash("./script/prettier --write") } - fn commit_and_push(token: &StepOutput) -> Step { + fn create_patch() -> Step { named::bash(indoc::indoc! {r#" if git diff --quiet; then echo "No changes to commit" + echo "has_changes=false" >> "$GITHUB_OUTPUT" else - git add -A - git commit -m "Autofix" - git push + git diff > autofix.patch + echo "has_changes=true" >> "$GITHUB_OUTPUT" fi "#}) + .id("create-patch") + } + + named::job( + Job::default() + .runs_on(runners::LINUX_DEFAULT) + .outputs([( + "has_changes".to_owned(), + "${{ steps.create-patch.outputs.has_changes }}".to_owned(), + )]) + .add_step(steps::checkout_repo()) + .add_step(checkout_pr(pr_number)) + .add_step(steps::setup_cargo_config(runners::Platform::Linux)) + .add_step(steps::cache_rust_dependencies_namespace()) + .map(steps::install_linux_dependencies) + .add_step(steps::setup_pnpm()) + .add_step(run_prettier_fix()) + .add_step(run_cargo_fmt()) + .add_step(run_clippy_fix().if_condition(Expression::new(run_clippy.to_string()))) + .add_step(create_patch()) + .add_step(upload_patch_artifact()) + .add_step(steps::cleanup_cargo_config(runners::Platform::Linux)), + ) +} + +fn commit_changes(pr_number: &WorkflowInput, autofix_job: &NamedJob) -> NamedJob { + fn authenticate_as_zippy() -> (Step, StepOutput) { + let step = named::uses( + "actions", + "create-github-app-token", + "bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1", + ) + .add_with(("app-id", vars::ZED_ZIPPY_APP_ID)) + .add_with(("private-key", vars::ZED_ZIPPY_APP_PRIVATE_KEY)) + .id("get-app-token"); + let output = StepOutput::new(&step, "token"); + (step, output) + } + + fn checkout_pr(pr_number: &WorkflowInput, token: &StepOutput) -> Step { + named::bash(&format!("gh pr checkout {pr_number}")).add_env(("GITHUB_TOKEN", token)) + } + + fn apply_patch() -> Step { + named::bash("git apply autofix.patch") + } + + fn commit_and_push(token: &StepOutput) -> Step { + named::bash(indoc::indoc! {r#" + git commit -am "Autofix" + git push + "#}) .add_env(("GIT_COMMITTER_NAME", "Zed Zippy")) .add_env(( "GIT_COMMITTER_EMAIL", @@ -76,18 +152,17 @@ fn run_autofix(pr_number: &WorkflowInput) -> NamedJob { named::job( Job::default() - .runs_on(runners::LINUX_DEFAULT) + .runs_on(runners::LINUX_SMALL) + .needs(vec![autofix_job.name.clone()]) + .cond(Expression::new(format!( + "needs.{}.outputs.has_changes == 'true'", + autofix_job.name + ))) .add_step(authenticate) .add_step(steps::checkout_repo_with_token(&token)) .add_step(checkout_pr(pr_number, &token)) - .add_step(steps::setup_cargo_config(runners::Platform::Linux)) - .add_step(steps::cache_rust_dependencies_namespace()) - .map(steps::install_linux_dependencies) - .add_step(steps::setup_pnpm()) - .add_step(run_prettier_fix()) - .add_step(run_cargo_fmt()) - .add_step(run_clippy_fix()) - .add_step(commit_and_push(&token)) - .add_step(steps::cleanup_cargo_config(runners::Platform::Linux)), + .add_step(download_patch_artifact()) + .add_step(apply_patch()) + .add_step(commit_and_push(&token)), ) } diff --git a/tooling/xtask/src/tasks/workflows/run_tests.rs b/tooling/xtask/src/tasks/workflows/run_tests.rs index 0bb3e152fb390e044ebac456fd3347707c66f612..13639fd6c4bf33fe090dcb9d5f3cafdf45a36e76 100644 --- a/tooling/xtask/src/tasks/workflows/run_tests.rs +++ b/tooling/xtask/src/tasks/workflows/run_tests.rs @@ -237,10 +237,11 @@ fn check_style() -> NamedJob { .add_step(steps::cache_rust_dependencies_namespace()) .add_step(steps::setup_pnpm()) .add_step(steps::script("./script/prettier")) + .add_step(steps::cargo_fmt()) + .add_step(steps::trigger_autofix(false)) .add_step(steps::script("./script/check-todos")) .add_step(steps::script("./script/check-keymaps")) - .add_step(check_for_typos()) - .add_step(steps::cargo_fmt()), + .add_step(check_for_typos()), ) } @@ -326,7 +327,8 @@ pub(crate) fn run_platform_tests(platform: Platform) -> NamedJob { .add_step(steps::setup_node()) .add_step(steps::clippy(platform)) .when(platform == Platform::Linux, |job| { - job.add_step(steps::cargo_install_nextest()) + job.add_step(steps::trigger_autofix(true)) + .add_step(steps::cargo_install_nextest()) }) .add_step(steps::clear_target_dir_if_large(platform)) .add_step(steps::cargo_nextest(platform)) diff --git a/tooling/xtask/src/tasks/workflows/steps.rs b/tooling/xtask/src/tasks/workflows/steps.rs index 7d55df2db433d6e6eae96a5ae62a0c033689d904..54873c011ce9d1fb7d4e7e0b734695c7c1a30fad 100644 --- a/tooling/xtask/src/tasks/workflows/steps.rs +++ b/tooling/xtask/src/tasks/workflows/steps.rs @@ -344,3 +344,13 @@ pub fn git_checkout(ref_name: &dyn std::fmt::Display) -> Step { "git fetch origin {ref_name} && git checkout {ref_name}" )) } + +pub fn trigger_autofix(run_clippy: bool) -> Step { + named::bash(format!( + "gh workflow run autofix_pr.yml -f pr_number=${{{{ github.event.pull_request.number }}}} -f run_clippy={run_clippy}" + )) + .if_condition(Expression::new( + "failure() && github.event_name == 'pull_request' && github.actor != 'zed-zippy[bot]'", + )) + .add_env(("GITHUB_TOKEN", vars::GITHUB_TOKEN)) +}