diff --git a/.github/ISSUE_TEMPLATE/10_bug_report.yml b/.github/ISSUE_TEMPLATE/10_bug_report.yml index 711ad6de245d2a194984bc493217c9fbf859aa27..cae10f02ec3b1bcb024f0d1f1bce0691a39054b4 100644 --- a/.github/ISSUE_TEMPLATE/10_bug_report.yml +++ b/.github/ISSUE_TEMPLATE/10_bug_report.yml @@ -75,6 +75,22 @@ body: validations: required: false + - type: textarea + attributes: + label: Relevant Keymap + description: | + Open the command palette in Zed, then type “zed: open keymap file” and copy/paste the file's contents. + value: | +
keymap.json + + + ```json + + ``` + +
+ validations: + required: false - type: textarea attributes: label: (for AI issues) Model provider details diff --git a/.github/workflows/after_release.yml b/.github/workflows/after_release.yml index 2e75659de0bdf51f4586ef57770fd54dc4eeb074..21b9a8fe0e184773b35752565da6530bb666c6ec 100644 --- a/.github/workflows/after_release.yml +++ b/.github/workflows/after_release.yml @@ -5,13 +5,27 @@ on: release: types: - published + workflow_dispatch: + inputs: + tag_name: + description: tag_name + required: true + type: string + prerelease: + description: prerelease + required: true + type: boolean + body: + description: body + type: string + default: '' jobs: rebuild_releases_page: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') runs-on: namespace-profile-2x4-ubuntu-2404 steps: - name: after_release::rebuild_releases_page::refresh_cloud_releases - run: curl -fX POST https://cloud.zed.dev/releases/refresh?expect_tag=${{ github.event.release.tag_name }} + run: curl -fX POST https://cloud.zed.dev/releases/refresh?expect_tag=${{ github.event.release.tag_name || inputs.tag_name }} shell: bash -euxo pipefail {0} - name: after_release::rebuild_releases_page::redeploy_zed_dev run: npm exec --yes -- vercel@37 --token="$VERCEL_TOKEN" --scope zed-industries redeploy https://zed.dev @@ -27,7 +41,7 @@ jobs: - id: get-release-url name: after_release::post_to_discord::get_release_url run: | - if [ "${{ github.event.release.prerelease }}" == "true" ]; then + if [ "${{ github.event.release.prerelease || inputs.prerelease }}" == "true" ]; then URL="https://zed.dev/releases/preview" else URL="https://zed.dev/releases/stable" @@ -40,9 +54,9 @@ jobs: uses: 2428392/gh-truncate-string-action@b3ff790d21cf42af3ca7579146eedb93c8fb0757 with: stringToTruncate: | - 📣 Zed [${{ github.event.release.tag_name }}](<${{ steps.get-release-url.outputs.URL }}>) was just released! + 📣 Zed [${{ github.event.release.tag_name || inputs.tag_name }}](<${{ steps.get-release-url.outputs.URL }}>) was just released! - ${{ github.event.release.body }} + ${{ github.event.release.body || inputs.body }} maxLength: 2000 truncationSymbol: '...' - name: after_release::post_to_discord::discord_webhook_action @@ -56,7 +70,7 @@ jobs: - id: set-package-name name: after_release::publish_winget::set_package_name run: | - if ("${{ github.event.release.prerelease }}" -eq "true") { + if ("${{ github.event.release.prerelease || inputs.prerelease }}" -eq "true") { $PACKAGE_NAME = "ZedIndustries.Zed.Preview" } else { $PACKAGE_NAME = "ZedIndustries.Zed" @@ -68,6 +82,7 @@ jobs: uses: vedantmgoyal9/winget-releaser@19e706d4c9121098010096f9c495a70a7518b30f with: identifier: ${{ steps.set-package-name.outputs.PACKAGE_NAME }} + release-tag: ${{ github.event.release.tag_name || inputs.tag_name }} max-versions-to-keep: 5 token: ${{ secrets.WINGET_TOKEN }} create_sentry_release: diff --git a/.github/workflows/autofix_pr.yml b/.github/workflows/autofix_pr.yml new file mode 100644 index 0000000000000000000000000000000000000000..d3688a722aa107efb3dfb95351404f43c9aece65 --- /dev/null +++ b/.github/workflows/autofix_pr.yml @@ -0,0 +1,128 @@ +# Generated from xtask::workflows::autofix_pr +# Rebuild with `cargo xtask workflows`. +name: autofix_pr +run-name: 'autofix PR #${{ inputs.pr_number }}' +on: + workflow_dispatch: + inputs: + pr_number: + 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: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: autofix_pr::run_autofix::checkout_pr + run: gh pr checkout ${{ inputs.pr_number }} + shell: bash -euxo pipefail {0} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: steps::setup_cargo_config + run: | + mkdir -p ./../.cargo + cp ./.cargo/ci-config.toml ./../.cargo/config.toml + shell: bash -euxo pipefail {0} + - name: steps::cache_rust_dependencies_namespace + uses: namespacelabs/nscloud-cache-action@v1 + with: + cache: rust + - name: steps::setup_linux + run: ./script/linux + shell: bash -euxo pipefail {0} + - name: steps::install_mold + run: ./script/install-mold + shell: bash -euxo pipefail {0} + - name: steps::download_wasi_sdk + run: ./script/download-wasi-sdk + shell: bash -euxo pipefail {0} + - name: steps::setup_pnpm + uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 + with: + version: '9' + - name: autofix_pr::run_autofix::run_prettier_fix + run: ./script/prettier --write + shell: bash -euxo pipefail {0} + - name: autofix_pr::run_autofix::run_cargo_fmt + 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} + - 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 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: steps::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 }} +concurrency: + group: ${{ github.workflow }}-${{ inputs.pr_number }} + cancel-in-progress: true diff --git a/.github/workflows/cherry_pick.yml b/.github/workflows/cherry_pick.yml index bc01aae17e7141a2359b162c3de94c1aec7b765c..d4dee5154f2209521f3e9d183c05c118e8861521 100644 --- a/.github/workflows/cherry_pick.yml +++ b/.github/workflows/cherry_pick.yml @@ -30,7 +30,7 @@ jobs: with: clean: false - id: get-app-token - name: cherry_pick::run_cherry_pick::authenticate_as_zippy + name: steps::authenticate_as_zippy uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1 with: app-id: ${{ secrets.ZED_ZIPPY_APP_ID }} 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/extension_bump.yml b/.github/workflows/extension_bump.yml index c7582378f1c9e87254e1a0b4e202d9f56b99877b..31676e5c914719a34f8b2e61193475ed107cd2db 100644 --- a/.github/workflows/extension_bump.yml +++ b/.github/workflows/extension_bump.yml @@ -113,6 +113,7 @@ jobs: delete-branch: true token: ${{ steps.generate-token.outputs.token }} sign-commits: true + assignees: ${{ github.actor }} timeout-minutes: 1 create_version_label: needs: diff --git a/.github/workflows/extension_tests.yml b/.github/workflows/extension_tests.yml index 9f0917e388c74cffed8f342f7504bc111e6f5147..7a7fff9b97d694c1b02dd426f5d59301fe2be81e 100644 --- a/.github/workflows/extension_tests.yml +++ b/.github/workflows/extension_tests.yml @@ -61,7 +61,8 @@ jobs: uses: namespacelabs/nscloud-cache-action@v1 with: cache: rust - - name: steps::cargo_fmt + - id: cargo_fmt + name: steps::cargo_fmt run: cargo fmt --all -- --check shell: bash -euxo pipefail {0} - name: extension_tests::run_clippy diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7afac285b5a34df2aadd04952400809059e12222..317d5a8df37a62887ce4ddcdd67c8d77b48d56d6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,7 +26,8 @@ jobs: uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: '20' - - name: steps::clippy + - id: clippy + name: steps::clippy run: ./script/clippy shell: bash -euxo pipefail {0} - name: steps::clear_target_dir_if_large @@ -71,9 +72,15 @@ jobs: uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: '20' - - name: steps::clippy + - id: clippy + name: steps::clippy run: ./script/clippy shell: bash -euxo pipefail {0} + - id: record_clippy_failure + name: steps::record_clippy_failure + if: always() + run: echo "failed=${{ steps.clippy.outcome == 'failure' }}" >> "$GITHUB_OUTPUT" + shell: bash -euxo pipefail {0} - name: steps::cargo_install_nextest uses: taiki-e/install-action@nextest - name: steps::clear_target_dir_if_large @@ -87,6 +94,8 @@ jobs: run: | rm -rf ./../.cargo shell: bash -euxo pipefail {0} + outputs: + clippy_failed: ${{ steps.record_clippy_failure.outputs.failed == 'true' }} timeout-minutes: 60 run_tests_windows: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') @@ -105,7 +114,8 @@ jobs: uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: '20' - - name: steps::clippy + - id: clippy + name: steps::clippy run: ./script/clippy.ps1 shell: pwsh - name: steps::clear_target_dir_if_large @@ -472,11 +482,17 @@ jobs: if: startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-pre') && !endsWith(github.ref, '.0-pre') runs-on: namespace-profile-2x4-ubuntu-2404 steps: + - id: get-app-token + name: steps::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: gh release edit "$GITHUB_REF_NAME" --repo=zed-industries/zed --draft=false run: gh release edit "$GITHUB_REF_NAME" --repo=zed-industries/zed --draft=false shell: bash -euxo pipefail {0} env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ steps.get-app-token.outputs.token }} notify_on_failure: needs: - upload_release_assets diff --git a/.github/workflows/release_nightly.yml b/.github/workflows/release_nightly.yml index d76244175accc3e816cbd7d5dc322d2529a0a236..b23e4b7518a672c0d586ea5ba437db5cf8f94bb6 100644 --- a/.github/workflows/release_nightly.yml +++ b/.github/workflows/release_nightly.yml @@ -20,7 +20,8 @@ jobs: with: clean: false fetch-depth: 0 - - name: steps::cargo_fmt + - id: cargo_fmt + name: steps::cargo_fmt run: cargo fmt --all -- --check shell: bash -euxo pipefail {0} - name: ./script/clippy @@ -44,7 +45,8 @@ jobs: uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: '20' - - name: steps::clippy + - id: clippy + name: steps::clippy run: ./script/clippy.ps1 shell: pwsh - name: steps::clear_target_dir_if_large diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index ad228103e33bd17dbe180d1c267c5141f5433080..fac3221d63a080fa53b7ba1c5b7249e6a405c73c 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -74,9 +74,19 @@ jobs: uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 with: version: '9' - - name: ./script/prettier + - id: prettier + name: steps::prettier run: ./script/prettier shell: bash -euxo pipefail {0} + - id: cargo_fmt + name: steps::cargo_fmt + run: cargo fmt --all -- --check + shell: bash -euxo pipefail {0} + - id: record_style_failure + name: steps::record_style_failure + if: always() + run: echo "failed=${{ steps.prettier.outcome == 'failure' || steps.cargo_fmt.outcome == 'failure' }}" >> "$GITHUB_OUTPUT" + shell: bash -euxo pipefail {0} - name: ./script/check-todos run: ./script/check-todos shell: bash -euxo pipefail {0} @@ -87,9 +97,8 @@ jobs: uses: crate-ci/typos@2d0ce569feab1f8752f1dde43cc2f2aa53236e06 with: config: ./typos.toml - - name: steps::cargo_fmt - run: cargo fmt --all -- --check - shell: bash -euxo pipefail {0} + outputs: + style_failed: ${{ steps.record_style_failure.outputs.failed == 'true' }} timeout-minutes: 60 run_tests_windows: needs: @@ -110,7 +119,8 @@ jobs: uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: '20' - - name: steps::clippy + - id: clippy + name: steps::clippy run: ./script/clippy.ps1 shell: pwsh - name: steps::clear_target_dir_if_large @@ -157,9 +167,15 @@ jobs: uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: '20' - - name: steps::clippy + - id: clippy + name: steps::clippy run: ./script/clippy shell: bash -euxo pipefail {0} + - id: record_clippy_failure + name: steps::record_clippy_failure + if: always() + run: echo "failed=${{ steps.clippy.outcome == 'failure' }}" >> "$GITHUB_OUTPUT" + shell: bash -euxo pipefail {0} - name: steps::cargo_install_nextest uses: taiki-e/install-action@nextest - name: steps::clear_target_dir_if_large @@ -173,6 +189,8 @@ jobs: run: | rm -rf ./../.cargo shell: bash -euxo pipefail {0} + outputs: + clippy_failed: ${{ steps.record_clippy_failure.outputs.failed == 'true' }} timeout-minutes: 60 run_tests_mac: needs: @@ -193,7 +211,8 @@ jobs: uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: '20' - - name: steps::clippy + - id: clippy + name: steps::clippy run: ./script/clippy shell: bash -euxo pipefail {0} - name: steps::clear_target_dir_if_large @@ -497,6 +516,8 @@ jobs: env: GIT_AUTHOR_NAME: Protobuf Action GIT_AUTHOR_EMAIL: ci@zed.dev + GIT_COMMITTER_NAME: Protobuf Action + GIT_COMMITTER_EMAIL: ci@zed.dev steps: - name: steps::checkout_repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 @@ -571,6 +592,24 @@ jobs: exit $EXIT_CODE shell: bash -euxo pipefail {0} + call_autofix: + needs: + - check_style + - run_tests_linux + if: always() && (needs.check_style.outputs.style_failed == 'true' || needs.run_tests_linux.outputs.clippy_failed == 'true') && github.event_name == 'pull_request' && github.actor != 'zed-zippy[bot]' + runs-on: namespace-profile-2x4-ubuntu-2404 + steps: + - id: get-app-token + name: steps::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: run_tests::call_autofix::dispatch_autofix + run: gh workflow run autofix_pr.yml -f pr_number=${{ github.event.pull_request.number }} -f run_clippy=${{ needs.run_tests_linux.outputs.clippy_failed == 'true' }} + shell: bash -euxo pipefail {0} + env: + GITHUB_TOKEN: ${{ steps.get-app-token.outputs.token }} concurrency: group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }} cancel-in-progress: true diff --git a/.gitignore b/.gitignore index ccf4f471d5a7b70be0dc8d619ac64050dd6681ec..54faaf1374299ee8f97925a95a93b375c349d707 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ .DS_Store .blob_store .build +.claude/settings.local.json .envrc .flatpak-builder .idea @@ -41,4 +42,4 @@ xcuserdata/ .env.secret.toml # `nix build` output -/result +/result 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/.rules b/.rules index 82d15eb9e88299ee7c7fe6c717b2da2646e676a7..7c98c65d7e0eaf3ed0d57898dbd8acee28a220ae 100644 --- a/.rules +++ b/.rules @@ -26,6 +26,12 @@ }); ``` +# Timers in tests + +* In GPUI tests, prefer GPUI executor timers over `smol::Timer::after(...)` when you need timeouts, delays, or to drive `run_until_parked()`: + - Use `cx.background_executor().timer(duration).await` (or `cx.background_executor.timer(duration).await` in `TestAppContext`) so the work is scheduled on GPUI's dispatcher. + - Avoid `smol::Timer::after(...)` for test timeouts when you rely on `run_until_parked()`, because it may not be tracked by GPUI's scheduler and can lead to "nothing left to run" when pumping. + # GPUI GPUI is a UI framework which also provides primitives for state and concurrency management. 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 49b9d6069ccdfadf2d5145808fd7b758f9b389bb..0d83b2b9b912ab112d9b38fd1ef1d5ff21f9049c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,6 +37,7 @@ dependencies = [ "terminal", "ui", "url", + "urlencoding", "util", "uuid", "watch", @@ -110,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" @@ -216,9 +226,9 @@ dependencies = [ [[package]] name = "agent-client-protocol" -version = "0.9.0" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2ffe7d502c1e451aafc5aff655000f84d09c9af681354ac0012527009b1af13" +checksum = "d3e527d7dfe0f334313d42d1d9318f0a79665f6f21c440d0798f230a77a7ed6c" dependencies = [ "agent-client-protocol-schema", "anyhow", @@ -233,9 +243,9 @@ dependencies = [ [[package]] name = "agent-client-protocol-schema" -version = "0.10.0" +version = "0.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8af81cc2d5c3f9c04f73db452efd058333735ba9d51c2cf7ef33c9fee038e7e6" +checksum = "6903a00e8ac822f9bacac59a1932754d7387c72ebb7c9c7439ad021505591da4" dependencies = [ "anyhow", "derive_more 2.0.1", @@ -291,6 +301,7 @@ dependencies = [ name = "agent_settings" version = "0.1.0" dependencies = [ + "agent-client-protocol", "anyhow", "cloud_llm_client", "collections", @@ -388,7 +399,6 @@ dependencies = [ "streaming_diff", "task", "telemetry", - "telemetry_events", "terminal", "terminal_view", "text", @@ -401,11 +411,43 @@ dependencies = [ "unindent", "url", "util", + "uuid", "watch", "workspace", "zed_actions", ] +[[package]] +name = "agent_ui_v2" +version = "0.1.0" +dependencies = [ + "agent", + "agent_servers", + "agent_settings", + "agent_ui", + "anyhow", + "assistant_text_thread", + "chrono", + "db", + "editor", + "feature_flags", + "fs", + "fuzzy", + "gpui", + "menu", + "project", + "prompt_store", + "serde", + "serde_json", + "settings", + "text", + "time", + "time_format", + "ui", + "util", + "workspace", +] + [[package]] name = "ahash" version = "0.7.8" @@ -751,7 +793,7 @@ dependencies = [ "url", "wayland-backend", "wayland-client", - "wayland-protocols 0.32.9", + "wayland-protocols", "zbus", ] @@ -834,7 +876,6 @@ dependencies = [ "fs", "futures 0.3.31", "fuzzy", - "globset", "gpui", "html_to_markdown", "http_client", @@ -893,7 +934,7 @@ dependencies = [ "settings", "smallvec", "smol", - "telemetry_events", + "telemetry", "text", "ui", "unindent", @@ -1400,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", @@ -1466,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", @@ -1491,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", @@ -1573,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", @@ -1595,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", @@ -1617,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", @@ -1640,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", @@ -1699,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", @@ -1710,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", @@ -1720,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", @@ -1731,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", @@ -1761,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", ] @@ -1789,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", @@ -1813,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", @@ -1830,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", @@ -1856,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", @@ -1966,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", @@ -2769,9 +2811,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.41" +version = "1.2.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac9fe6cdbb24b6ade63616c0a0688e45bb56732262c158df3c0c4bea4ca47cb7" +checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" dependencies = [ "find-msvc-tools", "jobserver", @@ -2854,6 +2896,17 @@ dependencies = [ "util", ] +[[package]] +name = "chardetng" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14b8f0b65b7b08ae3c8187e8d77174de20cb6777864c6b832d8ad365999cf1ea" +dependencies = [ + "cfg-if", + "encoding_rs", + "memchr", +] + [[package]] name = "chrono" version = "0.4.42" @@ -3110,21 +3163,11 @@ dependencies = [ "uuid", ] -[[package]] -name = "cloud_zeta2_prompt" -version = "0.1.0" -dependencies = [ - "anyhow", - "cloud_llm_client", - "indoc", - "serde", -] - [[package]] name = "cmake" -version = "0.1.54" +version = "0.1.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0" +checksum = "b042e5d8a74ae91bb0961acd039822472ec99f8ab0948cbf6d1369588f8be586" dependencies = [ "cc", ] @@ -3592,8 +3635,10 @@ dependencies = [ "serde", "serde_json", "settings", + "slotmap", "smol", "tempfile", + "terminal", "url", "util", ] @@ -3651,6 +3696,7 @@ dependencies = [ "task", "theme", "ui", + "url", "util", "workspace", "zlog", @@ -3901,20 +3947,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", @@ -3922,11 +3986,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", @@ -3935,9 +4000,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", @@ -3949,33 +4015,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", @@ -3984,9 +4053,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", @@ -3996,21 +4065,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" @@ -5117,10 +5192,8 @@ dependencies = [ "clock", "cloud_api_types", "cloud_llm_client", - "cloud_zeta2_prompt", "collections", "copilot", - "credentials_provider", "ctor", "db", "edit_prediction_context", @@ -5141,6 +5214,7 @@ dependencies = [ "postage", "pretty_assertions", "project", + "pulldown-cmark 0.12.2", "rand 0.9.2", "regex", "release_channel", @@ -5148,8 +5222,6 @@ dependencies = [ "serde", "serde_json", "settings", - "smol", - "strsim", "strum 0.27.2", "telemetry", "telemetry_events", @@ -5160,6 +5232,7 @@ dependencies = [ "workspace", "worktree", "zed_actions", + "zeta_prompt", "zlog", ] @@ -5173,11 +5246,10 @@ dependencies = [ "clap", "client", "cloud_llm_client", - "cloud_zeta2_prompt", "collections", "debug_adapter_extension", + "dirs 4.0.0", "edit_prediction", - "edit_prediction_context", "extension", "fs", "futures 0.3.31", @@ -5190,13 +5262,13 @@ dependencies = [ "language_model", "language_models", "languages", + "libc", "log", "node_runtime", "paths", "pretty_assertions", "project", "prompt_store", - "pulldown-cmark 0.12.2", "release_channel", "reqwest_client", "serde", @@ -5207,10 +5279,10 @@ dependencies = [ "sqlez", "sqlez_macros", "terminal_view", - "toml 0.8.23", "util", + "wasmtime", "watch", - "zlog", + "zeta_prompt", ] [[package]] @@ -5237,6 +5309,7 @@ dependencies = [ "text", "tree-sitter", "util", + "zeta_prompt", "zlog", ] @@ -5258,7 +5331,6 @@ dependencies = [ "buffer_diff", "client", "cloud_llm_client", - "cloud_zeta2_prompt", "codestral", "command_palette_hooks", "copilot", @@ -5268,9 +5340,11 @@ dependencies = [ "feature_flags", "fs", "futures 0.3.31", + "git", "gpui", "indoc", "language", + "log", "lsp", "markdown", "menu", @@ -5284,11 +5358,12 @@ dependencies = [ "telemetry", "text", "theme", + "time", "ui", - "ui_input", "util", "workspace", "zed_actions", + "zeta_prompt", ] [[package]] @@ -6102,9 +6177,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" +checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" [[package]] name = "fixedbitset" @@ -7056,6 +7131,8 @@ dependencies = [ "picker", "pretty_assertions", "project", + "prompt_store", + "rand 0.9.2", "recent_projects", "remote", "schemars", @@ -7248,6 +7325,7 @@ dependencies = [ "libc", "log", "lyon", + "mach2 0.5.0", "media", "metal", "naga", @@ -7292,7 +7370,7 @@ dependencies = [ "wayland-backend", "wayland-client", "wayland-cursor", - "wayland-protocols 0.31.2", + "wayland-protocols", "wayland-protocols-plasma", "wayland-protocols-wlr", "windows 0.61.3", @@ -8730,6 +8808,7 @@ dependencies = [ "ctor", "diffy", "ec4rs", + "encoding_rs", "fs", "futures 0.3.31", "fuzzy", @@ -8748,6 +8827,7 @@ dependencies = [ "regex", "rpc", "schemars", + "semver", "serde", "serde_json", "settings", @@ -8810,6 +8890,7 @@ dependencies = [ "cloud_api_types", "cloud_llm_client", "collections", + "credentials_provider", "futures 0.3.31", "gpui", "http_client", @@ -8825,9 +8906,9 @@ dependencies = [ "serde_json", "settings", "smol", - "telemetry_events", "thiserror 2.0.17", "util", + "zed_env_vars", ] [[package]] @@ -8884,7 +8965,6 @@ dependencies = [ "util", "vercel", "x_ai", - "zed_env_vars", ] [[package]] @@ -8982,6 +9062,7 @@ dependencies = [ "regex", "rope", "rust-embed", + "semver", "serde", "serde_json", "serde_json_lenient", @@ -10818,7 +10899,6 @@ dependencies = [ "documented", "fs", "fuzzy", - "git", "gpui", "menu", "notifications", @@ -12396,6 +12476,8 @@ dependencies = [ "context_server", "dap", "dap_adapters", + "db", + "encoding_rs", "extension", "fancy-regex", "fs", @@ -12566,6 +12648,8 @@ dependencies = [ "paths", "rope", "serde", + "strum 0.27.2", + "tempfile", "text", "util", "uuid", @@ -12769,13 +12853,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", ] @@ -13155,6 +13238,7 @@ dependencies = [ "askpass", "auto_update", "dap", + "db", "editor", "extension_host", "file_finder", @@ -13166,6 +13250,7 @@ dependencies = [ "log", "markdown", "menu", + "node_runtime", "ordered-float 2.10.1", "paths", "picker", @@ -13184,6 +13269,7 @@ dependencies = [ "util", "windows-registry 0.6.1", "workspace", + "worktree", "zed_actions", ] @@ -13271,9 +13357,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", @@ -14221,6 +14307,7 @@ dependencies = [ "schemars", "serde", "serde_json", + "settings", "theme", ] @@ -14451,12 +14538,14 @@ dependencies = [ "settings", "smol", "theme", + "tracing", "ui", "unindent", "util", "util_macros", "workspace", "zed_actions", + "ztracing", ] [[package]] @@ -14781,6 +14870,8 @@ dependencies = [ "assets", "bm25", "client", + "copilot", + "edit_prediction", "editor", "feature_flags", "fs", @@ -14789,6 +14880,7 @@ dependencies = [ "gpui", "heck 0.5.0", "language", + "language_models", "log", "menu", "node_runtime", @@ -16369,13 +16461,13 @@ dependencies = [ "alacritty_terminal", "anyhow", "collections", - "fancy-regex", "futures 0.3.31", "gpui", "itertools 0.14.0", "libc", "log", "rand 0.9.2", + "regex", "release_channel", "schemars", "serde", @@ -17276,9 +17368,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", @@ -18103,9 +18195,11 @@ dependencies = [ "language", "log", "lsp", + "markdown_preview", "menu", "multi_buffer", "nvim-rs", + "outline_panel", "parking_lot", "perf", "picker", @@ -18363,6 +18457,16 @@ dependencies = [ "wasmparser 0.227.1", ] +[[package]] +name = "wasm-encoder" +version = "0.229.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38ba1d491ecacb085a2552025c10a675a6fddcbd03b1fc9b36c536010ce265d2" +dependencies = [ + "leb128fmt", + "wasmparser 0.229.0", +] + [[package]] name = "wasm-metadata" version = "0.201.0" @@ -18447,23 +18551,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", @@ -18471,7 +18589,7 @@ dependencies = [ "cc", "cfg-if", "encoding_rs", - "hashbrown 0.14.5", + "hashbrown 0.15.5", "indexmap", "libc", "log", @@ -18479,12 +18597,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", @@ -18492,7 +18609,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", @@ -18509,18 +18626,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", @@ -18531,9 +18648,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", @@ -18541,9 +18658,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", @@ -18551,20 +18668,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", @@ -18574,22 +18691,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", @@ -18606,22 +18724,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", @@ -18629,9 +18747,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", @@ -18641,24 +18759,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", @@ -18667,9 +18785,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", @@ -18684,30 +18802,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", @@ -18715,14 +18846,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]] @@ -18798,18 +18929,6 @@ dependencies = [ "xcursor", ] -[[package]] -name = "wayland-protocols" -version = "0.31.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f81f365b8b4a97f422ac0e8737c438024b5951734506b0e1d775c73030561f4" -dependencies = [ - "bitflags 2.9.4", - "wayland-backend", - "wayland-client", - "wayland-scanner", -] - [[package]] name = "wayland-protocols" version = "0.32.9" @@ -18824,14 +18943,14 @@ dependencies = [ [[package]] name = "wayland-protocols-plasma" -version = "0.2.0" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23803551115ff9ea9bce586860c5c5a971e360825a0309264102a9495a5ff479" +checksum = "a07a14257c077ab3279987c4f8bb987851bf57081b93710381daea94f2c2c032" dependencies = [ "bitflags 2.9.4", "wayland-backend", "wayland-client", - "wayland-protocols 0.31.2", + "wayland-protocols", "wayland-scanner", ] @@ -18844,7 +18963,7 @@ dependencies = [ "bitflags 2.9.4", "wayland-backend", "wayland-client", - "wayland-protocols 0.32.9", + "wayland-protocols", "wayland-scanner", ] @@ -19004,6 +19123,20 @@ dependencies = [ "winsafe", ] +[[package]] +name = "which_key" +version = "0.1.0" +dependencies = [ + "command_palette", + "gpui", + "serde", + "settings", + "theme", + "ui", + "util", + "workspace", +] + [[package]] name = "whoami" version = "1.6.1" @@ -19016,14 +19149,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", @@ -19031,24 +19164,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", @@ -19089,18 +19221,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", ] @@ -19998,9 +20131,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", @@ -20011,14 +20144,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", @@ -20029,7 +20162,7 @@ dependencies = [ "serde_derive", "serde_json", "unicode-xid", - "wasmparser 0.227.1", + "wasmparser 0.229.0", ] [[package]] @@ -20058,13 +20191,16 @@ dependencies = [ "component", "dap", "db", + "feature_flags", "fs", "futures 0.3.31", + "git", "gpui", "http_client", "itertools 0.14.0", "language", "log", + "markdown", "menu", "node_runtime", "parking_lot", @@ -20098,8 +20234,10 @@ version = "0.1.0" dependencies = [ "anyhow", "async-lock 2.8.0", + "chardetng", "clock", "collections", + "encoding_rs", "fs", "futures 0.3.31", "fuzzy", @@ -20468,12 +20606,13 @@ dependencies = [ [[package]] name = "zed" -version = "0.217.0" +version = "0.219.0" dependencies = [ "acp_tools", "activity_indicator", "agent_settings", "agent_ui", + "agent_ui_v2", "anyhow", "ashpd 0.11.0", "askpass", @@ -20610,6 +20749,7 @@ dependencies = [ "watch", "web_search", "web_search_providers", + "which_key", "windows 0.61.3", "winresource", "workspace", @@ -20785,16 +20925,16 @@ dependencies = [ [[package]] name = "zed_html" -version = "0.2.3" +version = "0.3.0" dependencies = [ "zed_extension_api 0.7.0", ] [[package]] name = "zed_proto" -version = "0.2.3" +version = "0.3.0" dependencies = [ - "zed_extension_api 0.1.0", + "zed_extension_api 0.7.0", ] [[package]] @@ -20928,6 +21068,13 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "zeta_prompt" +version = "0.1.0" +dependencies = [ + "serde", +] + [[package]] name = "zip" version = "0.6.6" @@ -21020,6 +21167,7 @@ dependencies = [ "tracing", "tracing-subscriber", "tracing-tracy", + "zlog", "ztracing_macro", ] diff --git a/Cargo.toml b/Cargo.toml index 0ad4d2b14523988aa0dd6e3bfc935f84bcd0d8d9..825dc79e08978d8ccd03cea93883f698986ee12f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "crates/agent_servers", "crates/agent_settings", "crates/agent_ui", + "crates/agent_ui_v2", "crates/ai_onboarding", "crates/anthropic", "crates/askpass", @@ -32,7 +33,6 @@ members = [ "crates/cloud_api_client", "crates/cloud_api_types", "crates/cloud_llm_client", - "crates/cloud_zeta2_prompt", "crates/collab", "crates/collab_ui", "crates/collections", @@ -192,6 +192,7 @@ members = [ "crates/vercel", "crates/vim", "crates/vim_mode_setting", + "crates/which_key", "crates/watch", "crates/web_search", "crates/web_search_providers", @@ -202,6 +203,7 @@ members = [ "crates/zed_actions", "crates/zed_env_vars", "crates/edit_prediction_cli", + "crates/zeta_prompt", "crates/zlog", "crates/zlog_settings", "crates/ztracing", @@ -242,6 +244,7 @@ action_log = { path = "crates/action_log" } agent = { path = "crates/agent" } activity_indicator = { path = "crates/activity_indicator" } agent_ui = { path = "crates/agent_ui" } +agent_ui_v2 = { path = "crates/agent_ui_v2" } agent_settings = { path = "crates/agent_settings" } agent_servers = { path = "crates/agent_servers" } ai_onboarding = { path = "crates/ai_onboarding" } @@ -266,7 +269,6 @@ clock = { path = "crates/clock" } cloud_api_client = { path = "crates/cloud_api_client" } cloud_api_types = { path = "crates/cloud_api_types" } cloud_llm_client = { path = "crates/cloud_llm_client" } -cloud_zeta2_prompt = { path = "crates/cloud_zeta2_prompt" } collab_ui = { path = "crates/collab_ui" } collections = { path = "crates/collections", version = "0.1.0" } command_palette = { path = "crates/command_palette" } @@ -414,6 +416,7 @@ util_macros = { path = "crates/util_macros" } vercel = { path = "crates/vercel" } vim = { path = "crates/vim" } vim_mode_setting = { path = "crates/vim_mode_setting" } +which_key = { path = "crates/which_key" } watch = { path = "crates/watch" } web_search = { path = "crates/web_search" } @@ -425,6 +428,7 @@ zed = { path = "crates/zed" } zed_actions = { path = "crates/zed_actions" } zed_env_vars = { path = "crates/zed_env_vars" } edit_prediction = { path = "crates/edit_prediction" } +zeta_prompt = { path = "crates/zeta_prompt" } zlog = { path = "crates/zlog" } zlog_settings = { path = "crates/zlog_settings" } ztracing = { path = "crates/ztracing" } @@ -434,7 +438,7 @@ ztracing_macro = { path = "crates/ztracing_macro" } # External crates # -agent-client-protocol = { version = "=0.9.0", features = ["unstable"] } +agent-client-protocol = { version = "=0.9.2", features = ["unstable"] } aho-corasick = "1.1" alacritty_terminal = "0.25.1-rc1" any_vec = "0.14" @@ -453,15 +457,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" @@ -474,6 +478,7 @@ bytes = "1.0" cargo_metadata = "0.19" cargo_toml = "0.21" cfg-if = "1.0.3" +chardetng = "0.1" chrono = { version = "0.4", features = ["serde"] } ciborium = "0.2" circular-buffer = "1.0" @@ -497,6 +502,7 @@ dotenvy = "0.15.0" ec4rs = "1.1" emojis = "0.6.1" env_logger = "0.11" +encoding_rs = "0.8" exec = "0.3.1" fancy-regex = "0.16.0" fork = "0.4.0" @@ -631,7 +637,7 @@ shellexpand = "2.1.0" shlex = "1.3.0" simplelog = "0.12.2" slotmap = "1.0.6" -smallvec = { version = "1.6", features = ["union"] } +smallvec = { version = "1.6", features = ["union", "const_new"] } smol = "2.0" sqlformat = "0.2" stacksafe = "0.1" @@ -657,10 +663,11 @@ time = { version = "0.3", features = [ tiny_http = "0.8" tokio = { version = "1" } tokio-tungstenite = { version = "0.26", features = ["__rustls-tls"] } +tokio-socks = { version = "0.5.2", default-features = false, features = ["futures-io", "tokio"] } 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" } @@ -694,7 +701,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", @@ -703,7 +710,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" @@ -854,8 +861,6 @@ unexpected_cfgs = { level = "allow" } dbg_macro = "deny" todo = "deny" -# This is not a style lint, see https://github.com/rust-lang/rust-clippy/pull/15454 -# Remove when the lint gets promoted to `suspicious`. declare_interior_mutable_const = "deny" redundant_clone = "deny" 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/README.md b/README.md index d1e2a75beccc9b115bd3b2e09bcc812aebc98329..d3a5fd20526e5eae6826241dce2bb94e8533ecb3 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Welcome to Zed, a high-performance, multiplayer code editor from the creators of ### Installation -On macOS, Linux, and Windows you can [download Zed directly](https://zed.dev/download) or [install Zed via your local package manager](https://zed.dev/docs/linux#installing-via-a-package-manager). +On macOS, Linux, and Windows you can [download Zed directly](https://zed.dev/download) or install Zed via your local package manager ([macOS](https://zed.dev/docs/installation#macos)/[Linux](https://zed.dev/docs/linux#installing-via-a-package-manager)/[Windows](https://zed.dev/docs/windows#package-managers)). Other platforms are not yet available: 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/icons/box.svg b/assets/icons/box.svg new file mode 100644 index 0000000000000000000000000000000000000000..7e1276c629fb8bdc5a7ed48d9e2de6369d4c2bb0 --- /dev/null +++ b/assets/icons/box.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/zed_agent_two.svg b/assets/icons/zed_agent_two.svg new file mode 100644 index 0000000000000000000000000000000000000000..c352be84d2f1bea6da1f6a5be70b9420f019b6d6 --- /dev/null +++ b/assets/icons/zed_agent_two.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 2dffa90e32bad55617d1cad541e706b3293fcd2b..c9ac4b55aa4b740786de563b8b198306a53cdb33 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -25,7 +25,8 @@ "ctrl-shift-w": "workspace::CloseWindow", "shift-escape": "workspace::ToggleZoom", "open": "workspace::Open", - "ctrl-o": "workspace::Open", + "ctrl-o": "workspace::OpenFiles", + "ctrl-k ctrl-o": "workspace::Open", "ctrl-=": ["zed::IncreaseBufferFontSize", { "persist": false }], "ctrl-+": ["zed::IncreaseBufferFontSize", { "persist": false }], "ctrl--": ["zed::DecreaseBufferFontSize", { "persist": false }], @@ -43,15 +44,16 @@ "f11": "zed::ToggleFullScreen", "ctrl-alt-z": "edit_prediction::RatePredictions", "ctrl-alt-shift-i": "edit_prediction::ToggleMenu", - "ctrl-alt-l": "lsp_tool::ToggleMenu" - } + "ctrl-alt-l": "lsp_tool::ToggleMenu", + "ctrl-alt-shift-s": "workspace::ToggleWorktreeSecurity", + }, }, { "context": "Picker || menu", "bindings": { "up": "menu::SelectPrevious", - "down": "menu::SelectNext" - } + "down": "menu::SelectNext", + }, }, { "context": "Editor", @@ -62,7 +64,6 @@ "delete": "editor::Delete", "tab": "editor::Tab", "shift-tab": "editor::Backtab", - "ctrl-k": "editor::CutToEndOfLine", "ctrl-k ctrl-q": "editor::Rewrap", "ctrl-k q": "editor::Rewrap", "ctrl-backspace": ["editor::DeleteToPreviousWordStart", { "ignore_newlines": false, "ignore_brackets": false }], @@ -124,8 +125,8 @@ "shift-f10": "editor::OpenContextMenu", "ctrl-alt-shift-e": "editor::ToggleEditPrediction", "f9": "editor::ToggleBreakpoint", - "shift-f9": "editor::EditLogBreakpoint" - } + "shift-f9": "editor::EditLogBreakpoint", + }, }, { "context": "Editor && mode == full", @@ -144,44 +145,44 @@ "ctrl-alt-e": "editor::SelectEnclosingSymbol", "ctrl-shift-backspace": "editor::GoToPreviousChange", "ctrl-shift-alt-backspace": "editor::GoToNextChange", - "alt-enter": "editor::OpenSelectionsInMultibuffer" - } + "alt-enter": "editor::OpenSelectionsInMultibuffer", + }, }, { "context": "Editor && mode == full && edit_prediction", "bindings": { "alt-]": "editor::NextEditPrediction", - "alt-[": "editor::PreviousEditPrediction" - } + "alt-[": "editor::PreviousEditPrediction", + }, }, { "context": "Editor && !edit_prediction", "bindings": { - "alt-\\": "editor::ShowEditPrediction" - } + "alt-\\": "editor::ShowEditPrediction", + }, }, { "context": "Editor && mode == auto_height", "bindings": { "ctrl-enter": "editor::Newline", "shift-enter": "editor::Newline", - "ctrl-shift-enter": "editor::NewlineBelow" - } + "ctrl-shift-enter": "editor::NewlineBelow", + }, }, { "context": "Markdown", "bindings": { "copy": "markdown::Copy", "ctrl-insert": "markdown::Copy", - "ctrl-c": "markdown::Copy" - } + "ctrl-c": "markdown::Copy", + }, }, { "context": "Editor && jupyter && !ContextEditor", "bindings": { "ctrl-shift-enter": "repl::Run", - "ctrl-alt-enter": "repl::RunInPlace" - } + "ctrl-alt-enter": "repl::RunInPlace", + }, }, { "context": "Editor && !agent_diff", @@ -189,8 +190,8 @@ "ctrl-k ctrl-r": "git::Restore", "ctrl-alt-y": "git::ToggleStaged", "alt-y": "git::StageAndNext", - "alt-shift-y": "git::UnstageAndNext" - } + "alt-shift-y": "git::UnstageAndNext", + }, }, { "context": "Editor && editor_agent_diff", @@ -199,8 +200,8 @@ "ctrl-n": "agent::Reject", "ctrl-shift-y": "agent::KeepAll", "ctrl-shift-n": "agent::RejectAll", - "shift-ctrl-r": "agent::OpenAgentDiff" - } + "shift-ctrl-r": "agent::OpenAgentDiff", + }, }, { "context": "AgentDiff", @@ -208,8 +209,8 @@ "ctrl-y": "agent::Keep", "ctrl-n": "agent::Reject", "ctrl-shift-y": "agent::KeepAll", - "ctrl-shift-n": "agent::RejectAll" - } + "ctrl-shift-n": "agent::RejectAll", + }, }, { "context": "ContextEditor > Editor", @@ -225,8 +226,9 @@ "ctrl-k c": "assistant::CopyCode", "ctrl-g": "search::SelectNextMatch", "ctrl-shift-g": "search::SelectPreviousMatch", - "ctrl-k l": "agent::OpenRulesLibrary" - } + "ctrl-k l": "agent::OpenRulesLibrary", + "ctrl-shift-v": "agent::PasteRaw", + }, }, { "context": "AgentPanel", @@ -250,37 +252,38 @@ "alt-enter": "agent::ContinueWithBurnMode", "ctrl-y": "agent::AllowOnce", "ctrl-alt-y": "agent::AllowAlways", - "ctrl-alt-z": "agent::RejectOnce" - } + "ctrl-alt-z": "agent::RejectOnce", + "alt-tab": "agent::CycleFavoriteModels", + }, }, { "context": "AgentPanel > NavigationMenu", "bindings": { - "shift-backspace": "agent::DeleteRecentlyOpenThread" - } + "shift-backspace": "agent::DeleteRecentlyOpenThread", + }, }, { "context": "AgentPanel > Markdown", "bindings": { "copy": "markdown::CopyAsMarkdown", "ctrl-insert": "markdown::CopyAsMarkdown", - "ctrl-c": "markdown::CopyAsMarkdown" - } + "ctrl-c": "markdown::CopyAsMarkdown", + }, }, { "context": "AgentPanel && text_thread", "bindings": { "ctrl-n": "agent::NewTextThread", - "ctrl-alt-t": "agent::NewThread" - } + "ctrl-alt-t": "agent::NewThread", + }, }, { "context": "AgentPanel && acp_thread", "use_key_equivalents": true, "bindings": { "ctrl-n": "agent::NewExternalAgentThread", - "ctrl-alt-t": "agent::NewThread" - } + "ctrl-alt-t": "agent::NewThread", + }, }, { "context": "MessageEditor && !Picker > Editor && !use_modifier_to_send", @@ -290,8 +293,9 @@ "ctrl-i": "agent::ToggleProfileSelector", "shift-ctrl-r": "agent::OpenAgentDiff", "ctrl-shift-y": "agent::KeepAll", - "ctrl-shift-n": "agent::RejectAll" - } + "ctrl-shift-n": "agent::RejectAll", + "ctrl-shift-v": "agent::PasteRaw", + }, }, { "context": "MessageEditor && !Picker > Editor && use_modifier_to_send", @@ -301,30 +305,31 @@ "ctrl-i": "agent::ToggleProfileSelector", "shift-ctrl-r": "agent::OpenAgentDiff", "ctrl-shift-y": "agent::KeepAll", - "ctrl-shift-n": "agent::RejectAll" - } + "ctrl-shift-n": "agent::RejectAll", + "ctrl-shift-v": "agent::PasteRaw", + }, }, { "context": "EditMessageEditor > Editor", "bindings": { "escape": "menu::Cancel", "enter": "menu::Confirm", - "alt-enter": "editor::Newline" - } + "alt-enter": "editor::Newline", + }, }, { "context": "AgentFeedbackMessageEditor > Editor", "bindings": { "escape": "menu::Cancel", "enter": "menu::Confirm", - "alt-enter": "editor::Newline" - } + "alt-enter": "editor::Newline", + }, }, { "context": "AcpThread > ModeSelector", "bindings": { - "ctrl-enter": "menu::Confirm" - } + "ctrl-enter": "menu::Confirm", + }, }, { "context": "AcpThread > Editor && !use_modifier_to_send", @@ -333,8 +338,8 @@ "enter": "agent::Chat", "shift-ctrl-r": "agent::OpenAgentDiff", "ctrl-shift-y": "agent::KeepAll", - "ctrl-shift-n": "agent::RejectAll" - } + "ctrl-shift-n": "agent::RejectAll", + }, }, { "context": "AcpThread > Editor && use_modifier_to_send", @@ -344,14 +349,15 @@ "shift-ctrl-r": "agent::OpenAgentDiff", "ctrl-shift-y": "agent::KeepAll", "ctrl-shift-n": "agent::RejectAll", - "shift-tab": "agent::CycleModeSelector" - } + "shift-tab": "agent::CycleModeSelector", + "alt-tab": "agent::CycleFavoriteModels", + }, }, { "context": "ThreadHistory", "bindings": { - "backspace": "agent::RemoveSelectedThread" - } + "backspace": "agent::RemoveSelectedThread", + }, }, { "context": "RulesLibrary", @@ -359,8 +365,8 @@ "new": "rules_library::NewRule", "ctrl-n": "rules_library::NewRule", "ctrl-shift-s": "rules_library::ToggleDefaultRule", - "ctrl-w": "workspace::CloseWindow" - } + "ctrl-w": "workspace::CloseWindow", + }, }, { "context": "BufferSearchBar", @@ -373,22 +379,22 @@ "find": "search::FocusSearch", "ctrl-f": "search::FocusSearch", "ctrl-h": "search::ToggleReplace", - "ctrl-l": "search::ToggleSelection" - } + "ctrl-l": "search::ToggleSelection", + }, }, { "context": "BufferSearchBar && in_replace > Editor", "bindings": { "enter": "search::ReplaceNext", - "ctrl-enter": "search::ReplaceAll" - } + "ctrl-enter": "search::ReplaceAll", + }, }, { "context": "BufferSearchBar && !in_replace > Editor", "bindings": { "up": "search::PreviousHistoryQuery", - "down": "search::NextHistoryQuery" - } + "down": "search::NextHistoryQuery", + }, }, { "context": "ProjectSearchBar", @@ -399,22 +405,22 @@ "ctrl-shift-f": "search::FocusSearch", "ctrl-shift-h": "search::ToggleReplace", "alt-ctrl-g": "search::ToggleRegex", - "alt-ctrl-x": "search::ToggleRegex" - } + "alt-ctrl-x": "search::ToggleRegex", + }, }, { "context": "ProjectSearchBar > Editor", "bindings": { "up": "search::PreviousHistoryQuery", - "down": "search::NextHistoryQuery" - } + "down": "search::NextHistoryQuery", + }, }, { "context": "ProjectSearchBar && in_replace > Editor", "bindings": { "enter": "search::ReplaceNext", - "ctrl-alt-enter": "search::ReplaceAll" - } + "ctrl-alt-enter": "search::ReplaceAll", + }, }, { "context": "ProjectSearchView", @@ -422,8 +428,8 @@ "escape": "project_search::ToggleFocus", "ctrl-shift-h": "search::ToggleReplace", "alt-ctrl-g": "search::ToggleRegex", - "alt-ctrl-x": "search::ToggleRegex" - } + "alt-ctrl-x": "search::ToggleRegex", + }, }, { "context": "Pane", @@ -472,8 +478,8 @@ "ctrl-alt-shift-r": "search::ToggleRegex", "ctrl-alt-shift-x": "search::ToggleRegex", "alt-r": "search::ToggleRegex", - "ctrl-k shift-enter": "pane::TogglePinTab" - } + "ctrl-k shift-enter": "pane::TogglePinTab", + }, }, // Bindings from VS Code { @@ -500,6 +506,7 @@ "ctrl-k ctrl-i": "editor::Hover", "ctrl-k ctrl-b": "editor::BlameHover", "ctrl-/": ["editor::ToggleComments", { "advance_downwards": false }], + "ctrl-k ctrl-c": ["editor::ToggleComments", { "advance_downwards": false }], "f8": ["editor::GoToDiagnostic", { "severity": { "min": "hint", "max": "error" } }], "shift-f8": ["editor::GoToPreviousDiagnostic", { "severity": { "min": "hint", "max": "error" } }], "f2": "editor::Rename", @@ -536,31 +543,31 @@ "ctrl-\\": "pane::SplitRight", "ctrl-alt-shift-c": "editor::DisplayCursorNames", "alt-.": "editor::GoToHunk", - "alt-,": "editor::GoToPreviousHunk" - } + "alt-,": "editor::GoToPreviousHunk", + }, }, { "context": "Editor && extension == md", "use_key_equivalents": true, "bindings": { "ctrl-k v": "markdown::OpenPreviewToTheSide", - "ctrl-shift-v": "markdown::OpenPreview" - } + "ctrl-shift-v": "markdown::OpenPreview", + }, }, { "context": "Editor && extension == svg", "use_key_equivalents": true, "bindings": { "ctrl-k v": "svg::OpenPreviewToTheSide", - "ctrl-shift-v": "svg::OpenPreview" - } + "ctrl-shift-v": "svg::OpenPreview", + }, }, { "context": "Editor && mode == full", "bindings": { "ctrl-shift-o": "outline::Toggle", - "ctrl-g": "go_to_line::Toggle" - } + "ctrl-g": "go_to_line::Toggle", + }, }, { "context": "Workspace", @@ -654,28 +661,28 @@ // "foo-bar": ["task::Spawn", { "task_tag": "MyTag" }], "f5": "debugger::Rerun", "ctrl-f4": "workspace::CloseActiveDock", - "ctrl-w": "workspace::CloseActiveDock" - } + "ctrl-w": "workspace::CloseActiveDock", + }, }, { "context": "Workspace && debugger_running", "bindings": { - "f5": "zed::NoAction" - } + "f5": "zed::NoAction", + }, }, { "context": "Workspace && debugger_stopped", "bindings": { - "f5": "debugger::Continue" - } + "f5": "debugger::Continue", + }, }, { "context": "ApplicationMenu", "bindings": { "f10": "menu::Cancel", "left": "app_menu::ActivateMenuLeft", - "right": "app_menu::ActivateMenuRight" - } + "right": "app_menu::ActivateMenuRight", + }, }, // Bindings from Sublime Text { @@ -693,8 +700,8 @@ "ctrl-alt-shift-left": "editor::SelectToPreviousSubwordStart", "ctrl-alt-shift-b": "editor::SelectToPreviousSubwordStart", "ctrl-alt-shift-right": "editor::SelectToNextSubwordEnd", - "ctrl-alt-shift-f": "editor::SelectToNextSubwordEnd" - } + "ctrl-alt-shift-f": "editor::SelectToNextSubwordEnd", + }, }, // Bindings from Atom { @@ -703,37 +710,37 @@ "ctrl-k up": "pane::SplitUp", "ctrl-k down": "pane::SplitDown", "ctrl-k left": "pane::SplitLeft", - "ctrl-k right": "pane::SplitRight" - } + "ctrl-k right": "pane::SplitRight", + }, }, // Bindings that should be unified with bindings for more general actions { "context": "Editor && renaming", "bindings": { - "enter": "editor::ConfirmRename" - } + "enter": "editor::ConfirmRename", + }, }, { "context": "Editor && showing_completions", "bindings": { "enter": "editor::ConfirmCompletion", "shift-enter": "editor::ConfirmCompletionReplace", - "tab": "editor::ComposeCompletion" - } + "tab": "editor::ComposeCompletion", + }, }, { "context": "Editor && in_snippet && has_next_tabstop && !showing_completions", "use_key_equivalents": true, "bindings": { - "tab": "editor::NextSnippetTabstop" - } + "tab": "editor::NextSnippetTabstop", + }, }, { "context": "Editor && in_snippet && has_previous_tabstop && !showing_completions", "use_key_equivalents": true, "bindings": { - "shift-tab": "editor::PreviousSnippetTabstop" - } + "shift-tab": "editor::PreviousSnippetTabstop", + }, }, // Bindings for accepting edit predictions // @@ -745,22 +752,24 @@ "alt-tab": "editor::AcceptEditPrediction", "alt-l": "editor::AcceptEditPrediction", "tab": "editor::AcceptEditPrediction", - "alt-right": "editor::AcceptPartialEditPrediction" - } + "alt-right": "editor::AcceptNextWordEditPrediction", + "alt-down": "editor::AcceptNextLineEditPrediction", + }, }, { "context": "Editor && edit_prediction_conflict", "bindings": { "alt-tab": "editor::AcceptEditPrediction", "alt-l": "editor::AcceptEditPrediction", - "alt-right": "editor::AcceptPartialEditPrediction" - } + "alt-right": "editor::AcceptNextWordEditPrediction", + "alt-down": "editor::AcceptNextLineEditPrediction", + }, }, { "context": "Editor && showing_code_actions", "bindings": { - "enter": "editor::ConfirmCodeAction" - } + "enter": "editor::ConfirmCodeAction", + }, }, { "context": "Editor && (showing_code_actions || showing_completions)", @@ -770,29 +779,29 @@ "ctrl-n": "editor::ContextMenuNext", "down": "editor::ContextMenuNext", "pageup": "editor::ContextMenuFirst", - "pagedown": "editor::ContextMenuLast" - } + "pagedown": "editor::ContextMenuLast", + }, }, { "context": "Editor && showing_signature_help && !showing_completions", "bindings": { "up": "editor::SignatureHelpPrevious", - "down": "editor::SignatureHelpNext" - } + "down": "editor::SignatureHelpNext", + }, }, // Custom bindings { "bindings": { "ctrl-alt-shift-f": "workspace::FollowNextCollaborator", // Only available in debug builds: opens an element inspector for development. - "ctrl-alt-i": "dev::ToggleInspector" - } + "ctrl-alt-i": "dev::ToggleInspector", + }, }, { "context": "!Terminal", "bindings": { - "ctrl-shift-c": "collab_panel::ToggleFocus" - } + "ctrl-shift-c": "collab_panel::ToggleFocus", + }, }, { "context": "!ContextEditor > Editor && mode == full", @@ -804,15 +813,17 @@ "ctrl-f8": "editor::GoToHunk", "ctrl-shift-f8": "editor::GoToPreviousHunk", "ctrl-enter": "assistant::InlineAssist", - "ctrl-:": "editor::ToggleInlayHints" - } + "ctrl-:": "editor::ToggleInlayHints", + }, }, { "context": "PromptEditor", "bindings": { "ctrl-[": "agent::CyclePreviousInlineAssist", - "ctrl-]": "agent::CycleNextInlineAssist" - } + "ctrl-]": "agent::CycleNextInlineAssist", + "ctrl-shift-enter": "inline_assistant::ThumbsUpResult", + "ctrl-shift-backspace": "inline_assistant::ThumbsDownResult", + }, }, { "context": "Prompt", @@ -820,14 +831,14 @@ "left": "menu::SelectPrevious", "right": "menu::SelectNext", "h": "menu::SelectPrevious", - "l": "menu::SelectNext" - } + "l": "menu::SelectNext", + }, }, { "context": "ProjectSearchBar && !in_replace", "bindings": { - "ctrl-enter": "project_search::SearchInNew" - } + "ctrl-enter": "project_search::SearchInNew", + }, }, { "context": "OutlinePanel && not_editing", @@ -844,8 +855,8 @@ "shift-down": "menu::SelectNext", "shift-up": "menu::SelectPrevious", "alt-enter": "editor::OpenExcerpts", - "ctrl-alt-enter": "editor::OpenExcerptsSplit" - } + "ctrl-alt-enter": "editor::OpenExcerptsSplit", + }, }, { "context": "ProjectPanel", @@ -888,20 +899,22 @@ "ctrl-alt-shift-f": "project_panel::NewSearchInDirectory", "shift-down": "menu::SelectNext", "shift-up": "menu::SelectPrevious", - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "ProjectPanel && not_editing", "bindings": { - "space": "project_panel::Open" - } + "space": "project_panel::Open", + }, }, { "context": "GitPanel && ChangesList", "bindings": { - "up": "menu::SelectPrevious", - "down": "menu::SelectNext", + "left": "git_panel::CollapseSelectedEntry", + "right": "git_panel::ExpandSelectedEntry", + "up": "git_panel::PreviousEntry", + "down": "git_panel::NextEntry", "enter": "menu::Confirm", "alt-y": "git::StageFile", "alt-shift-y": "git::UnstageFile", @@ -916,15 +929,15 @@ "backspace": ["git::RestoreFile", { "skip_prompt": false }], "shift-delete": ["git::RestoreFile", { "skip_prompt": false }], "ctrl-backspace": ["git::RestoreFile", { "skip_prompt": false }], - "ctrl-delete": ["git::RestoreFile", { "skip_prompt": false }] - } + "ctrl-delete": ["git::RestoreFile", { "skip_prompt": false }], + }, }, { "context": "GitPanel && CommitEditor", "use_key_equivalents": true, "bindings": { - "escape": "git::Cancel" - } + "escape": "git::Cancel", + }, }, { "context": "GitCommit > Editor", @@ -933,8 +946,8 @@ "enter": "editor::Newline", "ctrl-enter": "git::Commit", "ctrl-shift-enter": "git::Amend", - "alt-l": "git::GenerateCommitMessage" - } + "alt-l": "git::GenerateCommitMessage", + }, }, { "context": "GitPanel", @@ -950,8 +963,8 @@ "ctrl-space": "git::StageAll", "ctrl-shift-space": "git::UnstageAll", "ctrl-enter": "git::Commit", - "ctrl-shift-enter": "git::Amend" - } + "ctrl-shift-enter": "git::Amend", + }, }, { "context": "GitDiff > Editor", @@ -959,14 +972,14 @@ "ctrl-enter": "git::Commit", "ctrl-shift-enter": "git::Amend", "ctrl-space": "git::StageAll", - "ctrl-shift-space": "git::UnstageAll" - } + "ctrl-shift-space": "git::UnstageAll", + }, }, { "context": "AskPass > Editor", "bindings": { - "enter": "menu::Confirm" - } + "enter": "menu::Confirm", + }, }, { "context": "CommitEditor > Editor", @@ -978,16 +991,16 @@ "ctrl-enter": "git::Commit", "ctrl-shift-enter": "git::Amend", "alt-up": "git_panel::FocusChanges", - "alt-l": "git::GenerateCommitMessage" - } + "alt-l": "git::GenerateCommitMessage", + }, }, { "context": "DebugPanel", "bindings": { "ctrl-t": "debugger::ToggleThreadPicker", "ctrl-i": "debugger::ToggleSessionPicker", - "shift-alt-escape": "debugger::ToggleExpandItem" - } + "shift-alt-escape": "debugger::ToggleExpandItem", + }, }, { "context": "VariableList", @@ -999,8 +1012,8 @@ "ctrl-alt-c": "variable_list::CopyVariableName", "delete": "variable_list::RemoveWatch", "backspace": "variable_list::RemoveWatch", - "alt-enter": "variable_list::AddWatch" - } + "alt-enter": "variable_list::AddWatch", + }, }, { "context": "BreakpointList", @@ -1008,35 +1021,35 @@ "space": "debugger::ToggleEnableBreakpoint", "backspace": "debugger::UnsetBreakpoint", "left": "debugger::PreviousBreakpointProperty", - "right": "debugger::NextBreakpointProperty" - } + "right": "debugger::NextBreakpointProperty", + }, }, { "context": "CollabPanel && not_editing", "bindings": { "ctrl-backspace": "collab_panel::Remove", - "space": "menu::Confirm" - } + "space": "menu::Confirm", + }, }, { "context": "CollabPanel", "bindings": { "alt-up": "collab_panel::MoveChannelUp", "alt-down": "collab_panel::MoveChannelDown", - "alt-enter": "collab_panel::OpenSelectedChannelNotes" - } + "alt-enter": "collab_panel::OpenSelectedChannelNotes", + }, }, { "context": "(CollabPanel && editing) > Editor", "bindings": { - "space": "collab_panel::InsertSpace" - } + "space": "collab_panel::InsertSpace", + }, }, { "context": "ChannelModal", "bindings": { - "tab": "channel_modal::ToggleMode" - } + "tab": "channel_modal::ToggleMode", + }, }, { "context": "Picker > Editor", @@ -1045,29 +1058,29 @@ "up": "menu::SelectPrevious", "down": "menu::SelectNext", "tab": "picker::ConfirmCompletion", - "alt-enter": ["picker::ConfirmInput", { "secondary": false }] - } + "alt-enter": ["picker::ConfirmInput", { "secondary": false }], + }, }, { "context": "ChannelModal > Picker > Editor", "bindings": { - "tab": "channel_modal::ToggleMode" - } + "tab": "channel_modal::ToggleMode", + }, }, { "context": "ToolchainSelector", "use_key_equivalents": true, "bindings": { - "ctrl-shift-a": "toolchain::AddToolchain" - } + "ctrl-shift-a": "toolchain::AddToolchain", + }, }, { "context": "FileFinder || (FileFinder > Picker > Editor)", "bindings": { "ctrl-p": "file_finder::Toggle", "ctrl-shift-a": "file_finder::ToggleSplitMenu", - "ctrl-shift-i": "file_finder::ToggleFilterMenu" - } + "ctrl-shift-i": "file_finder::ToggleFilterMenu", + }, }, { "context": "FileFinder || (FileFinder > Picker > Editor) || (FileFinder > Picker > menu)", @@ -1076,8 +1089,8 @@ "ctrl-j": "pane::SplitDown", "ctrl-k": "pane::SplitUp", "ctrl-h": "pane::SplitLeft", - "ctrl-l": "pane::SplitRight" - } + "ctrl-l": "pane::SplitRight", + }, }, { "context": "TabSwitcher", @@ -1085,15 +1098,15 @@ "ctrl-shift-tab": "menu::SelectPrevious", "ctrl-up": "menu::SelectPrevious", "ctrl-down": "menu::SelectNext", - "ctrl-backspace": "tab_switcher::CloseSelectedItem" - } + "ctrl-backspace": "tab_switcher::CloseSelectedItem", + }, }, { "context": "StashList || (StashList > Picker > Editor)", "bindings": { "ctrl-shift-backspace": "stash_picker::DropStashItem", - "ctrl-shift-v": "stash_picker::ShowStashItem" - } + "ctrl-shift-v": "stash_picker::ShowStashItem", + }, }, { "context": "Terminal", @@ -1138,65 +1151,69 @@ "ctrl-shift-r": "terminal::RerunTask", "ctrl-alt-r": "terminal::RerunTask", "alt-t": "terminal::RerunTask", - "ctrl-shift-5": "pane::SplitRight" - } + "ctrl-shift-5": "pane::SplitRight", + }, }, { "context": "ZedPredictModal", "bindings": { - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "ConfigureContextServerModal > Editor", "bindings": { "escape": "menu::Cancel", "enter": "editor::Newline", - "ctrl-enter": "menu::Confirm" - } + "ctrl-enter": "menu::Confirm", + }, }, { "context": "ContextServerToolsModal", "use_key_equivalents": true, "bindings": { - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "OnboardingAiConfigurationModal", "use_key_equivalents": true, "bindings": { - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "Diagnostics", "use_key_equivalents": true, "bindings": { - "ctrl-r": "diagnostics::ToggleDiagnosticsRefresh" - } + "ctrl-r": "diagnostics::ToggleDiagnosticsRefresh", + }, }, { "context": "DebugConsole > Editor", "use_key_equivalents": true, "bindings": { "enter": "menu::Confirm", - "alt-enter": "console::WatchExpression" - } + "alt-enter": "console::WatchExpression", + }, }, { "context": "RunModal", "bindings": { "ctrl-tab": "pane::ActivateNextItem", - "ctrl-shift-tab": "pane::ActivatePreviousItem" - } + "ctrl-shift-tab": "pane::ActivatePreviousItem", + }, }, { "context": "MarkdownPreview", "bindings": { - "pageup": "markdown::MovePageUp", - "pagedown": "markdown::MovePageDown" - } + "pageup": "markdown::ScrollPageUp", + "pagedown": "markdown::ScrollPageDown", + "up": "markdown::ScrollUp", + "down": "markdown::ScrollDown", + "alt-up": "markdown::ScrollUpByItem", + "alt-down": "markdown::ScrollDownByItem", + }, }, { "context": "KeymapEditor", @@ -1210,8 +1227,8 @@ "alt-enter": "keymap_editor::CreateBinding", "ctrl-c": "keymap_editor::CopyAction", "ctrl-shift-c": "keymap_editor::CopyContext", - "ctrl-t": "keymap_editor::ShowMatchingKeybinds" - } + "ctrl-t": "keymap_editor::ShowMatchingKeybinds", + }, }, { "context": "KeystrokeInput", @@ -1219,24 +1236,24 @@ "bindings": { "enter": "keystroke_input::StartRecording", "escape escape escape": "keystroke_input::StopRecording", - "delete": "keystroke_input::ClearKeystrokes" - } + "delete": "keystroke_input::ClearKeystrokes", + }, }, { "context": "KeybindEditorModal", "use_key_equivalents": true, "bindings": { "ctrl-enter": "menu::Confirm", - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "KeybindEditorModal > Editor", "use_key_equivalents": true, "bindings": { "up": "menu::SelectPrevious", - "down": "menu::SelectNext" - } + "down": "menu::SelectNext", + }, }, { "context": "Onboarding", @@ -1248,8 +1265,8 @@ "ctrl-0": ["zed::ResetUiFontSize", { "persist": false }], "ctrl-enter": "onboarding::Finish", "alt-shift-l": "onboarding::SignIn", - "alt-shift-a": "onboarding::OpenAccount" - } + "alt-shift-a": "onboarding::OpenAccount", + }, }, { "context": "Welcome", @@ -1258,23 +1275,28 @@ "ctrl-=": ["zed::IncreaseUiFontSize", { "persist": false }], "ctrl-+": ["zed::IncreaseUiFontSize", { "persist": false }], "ctrl--": ["zed::DecreaseUiFontSize", { "persist": false }], - "ctrl-0": ["zed::ResetUiFontSize", { "persist": false }] - } + "ctrl-0": ["zed::ResetUiFontSize", { "persist": false }], + "ctrl-1": ["welcome::OpenRecentProject", 0], + "ctrl-2": ["welcome::OpenRecentProject", 1], + "ctrl-3": ["welcome::OpenRecentProject", 2], + "ctrl-4": ["welcome::OpenRecentProject", 3], + "ctrl-5": ["welcome::OpenRecentProject", 4], + }, }, { "context": "InvalidBuffer", "use_key_equivalents": true, "bindings": { - "ctrl-shift-enter": "workspace::OpenWithSystem" - } + "ctrl-shift-enter": "workspace::OpenWithSystem", + }, }, { "context": "GitWorktreeSelector || (GitWorktreeSelector > Picker > Editor)", "use_key_equivalents": true, "bindings": { "ctrl-shift-space": "git::WorktreeFromDefaultOnWindow", - "ctrl-space": "git::WorktreeFromDefault" - } + "ctrl-space": "git::WorktreeFromDefault", + }, }, { "context": "SettingsWindow", @@ -1299,16 +1321,16 @@ "ctrl-9": ["settings_editor::FocusFile", 8], "ctrl-0": ["settings_editor::FocusFile", 9], "ctrl-pageup": "settings_editor::FocusPreviousFile", - "ctrl-pagedown": "settings_editor::FocusNextFile" - } + "ctrl-pagedown": "settings_editor::FocusNextFile", + }, }, { "context": "StashDiff > Editor", "bindings": { "ctrl-space": "git::ApplyCurrentStash", "ctrl-shift-space": "git::PopCurrentStash", - "ctrl-shift-backspace": "git::DropCurrentStash" - } + "ctrl-shift-backspace": "git::DropCurrentStash", + }, }, { "context": "SettingsWindow > NavigationMenu", @@ -1323,22 +1345,22 @@ "pageup": "settings_editor::FocusPreviousRootNavEntry", "pagedown": "settings_editor::FocusNextRootNavEntry", "home": "settings_editor::FocusFirstNavEntry", - "end": "settings_editor::FocusLastNavEntry" - } + "end": "settings_editor::FocusLastNavEntry", + }, }, { "context": "EditPredictionContext > Editor", "bindings": { "alt-left": "dev::EditPredictionContextGoBack", - "alt-right": "dev::EditPredictionContextGoForward" - } + "alt-right": "dev::EditPredictionContextGoForward", + }, }, { "context": "GitBranchSelector || (GitBranchSelector > Picker > Editor)", "use_key_equivalents": true, "bindings": { "ctrl-shift-backspace": "branch_picker::DeleteBranch", - "ctrl-shift-i": "branch_picker::FilterRemotes" - } - } + "ctrl-shift-i": "branch_picker::FilterRemotes", + }, + }, ] diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 1194713e50e4ff3257060880d4e9cd6f5d83c675..5a893b26cfaad4bb34427e4b7b2867581157ec23 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -50,8 +50,9 @@ "ctrl-cmd-z": "edit_prediction::RatePredictions", "ctrl-cmd-i": "edit_prediction::ToggleMenu", "ctrl-cmd-l": "lsp_tool::ToggleMenu", - "ctrl-cmd-c": "editor::DisplayCursorNames" - } + "ctrl-cmd-c": "editor::DisplayCursorNames", + "ctrl-cmd-s": "workspace::ToggleWorktreeSecurity", + }, }, { "context": "Editor", @@ -148,8 +149,8 @@ "shift-f9": "editor::EditLogBreakpoint", "ctrl-f12": "editor::GoToDeclaration", "alt-ctrl-f12": "editor::GoToDeclarationSplit", - "ctrl-cmd-e": "editor::ToggleEditPrediction" - } + "ctrl-cmd-e": "editor::ToggleEditPrediction", + }, }, { "context": "Editor && mode == full", @@ -167,8 +168,8 @@ "cmd->": "agent::AddSelectionToThread", "cmd-<": "assistant::InsertIntoEditor", "cmd-alt-e": "editor::SelectEnclosingSymbol", - "alt-enter": "editor::OpenSelectionsInMultibuffer" - } + "alt-enter": "editor::OpenSelectionsInMultibuffer", + }, }, { "context": "Editor && multibuffer", @@ -177,23 +178,23 @@ "cmd-up": "editor::MoveToStartOfExcerpt", "cmd-down": "editor::MoveToStartOfNextExcerpt", "cmd-shift-up": "editor::SelectToStartOfExcerpt", - "cmd-shift-down": "editor::SelectToStartOfNextExcerpt" - } + "cmd-shift-down": "editor::SelectToStartOfNextExcerpt", + }, }, { "context": "Editor && mode == full && edit_prediction", "use_key_equivalents": true, "bindings": { "alt-tab": "editor::NextEditPrediction", - "alt-shift-tab": "editor::PreviousEditPrediction" - } + "alt-shift-tab": "editor::PreviousEditPrediction", + }, }, { "context": "Editor && !edit_prediction", "use_key_equivalents": true, "bindings": { - "alt-tab": "editor::ShowEditPrediction" - } + "alt-tab": "editor::ShowEditPrediction", + }, }, { "context": "Editor && mode == auto_height", @@ -201,23 +202,23 @@ "bindings": { "ctrl-enter": "editor::Newline", "shift-enter": "editor::Newline", - "ctrl-shift-enter": "editor::NewlineBelow" - } + "ctrl-shift-enter": "editor::NewlineBelow", + }, }, { "context": "Markdown", "use_key_equivalents": true, "bindings": { - "cmd-c": "markdown::Copy" - } + "cmd-c": "markdown::Copy", + }, }, { "context": "Editor && jupyter && !ContextEditor", "use_key_equivalents": true, "bindings": { "ctrl-shift-enter": "repl::Run", - "ctrl-alt-enter": "repl::RunInPlace" - } + "ctrl-alt-enter": "repl::RunInPlace", + }, }, { "context": "Editor && !agent_diff && !AgentPanel", @@ -226,8 +227,8 @@ "cmd-alt-z": "git::Restore", "cmd-alt-y": "git::ToggleStaged", "cmd-y": "git::StageAndNext", - "cmd-shift-y": "git::UnstageAndNext" - } + "cmd-shift-y": "git::UnstageAndNext", + }, }, { "context": "AgentDiff", @@ -236,8 +237,8 @@ "cmd-y": "agent::Keep", "cmd-n": "agent::Reject", "cmd-shift-y": "agent::KeepAll", - "cmd-shift-n": "agent::RejectAll" - } + "cmd-shift-n": "agent::RejectAll", + }, }, { "context": "Editor && editor_agent_diff", @@ -247,8 +248,8 @@ "cmd-n": "agent::Reject", "cmd-shift-y": "agent::KeepAll", "cmd-shift-n": "agent::RejectAll", - "shift-ctrl-r": "agent::OpenAgentDiff" - } + "shift-ctrl-r": "agent::OpenAgentDiff", + }, }, { "context": "ContextEditor > Editor", @@ -264,8 +265,10 @@ "cmd-k c": "assistant::CopyCode", "cmd-g": "search::SelectNextMatch", "cmd-shift-g": "search::SelectPreviousMatch", - "cmd-k l": "agent::OpenRulesLibrary" - } + "cmd-k l": "agent::OpenRulesLibrary", + "alt-tab": "agent::CycleFavoriteModels", + "cmd-shift-v": "agent::PasteRaw", + }, }, { "context": "AgentPanel", @@ -290,37 +293,38 @@ "alt-enter": "agent::ContinueWithBurnMode", "cmd-y": "agent::AllowOnce", "cmd-alt-y": "agent::AllowAlways", - "cmd-alt-z": "agent::RejectOnce" - } + "cmd-alt-z": "agent::RejectOnce", + "alt-tab": "agent::CycleFavoriteModels", + }, }, { "context": "AgentPanel > NavigationMenu", "bindings": { - "shift-backspace": "agent::DeleteRecentlyOpenThread" - } + "shift-backspace": "agent::DeleteRecentlyOpenThread", + }, }, { "context": "AgentPanel > Markdown", "use_key_equivalents": true, "bindings": { - "cmd-c": "markdown::CopyAsMarkdown" - } + "cmd-c": "markdown::CopyAsMarkdown", + }, }, { "context": "AgentPanel && text_thread", "use_key_equivalents": true, "bindings": { "cmd-n": "agent::NewTextThread", - "cmd-alt-n": "agent::NewExternalAgentThread" - } + "cmd-alt-n": "agent::NewExternalAgentThread", + }, }, { "context": "AgentPanel && acp_thread", "use_key_equivalents": true, "bindings": { "cmd-n": "agent::NewExternalAgentThread", - "cmd-alt-t": "agent::NewThread" - } + "cmd-alt-t": "agent::NewThread", + }, }, { "context": "MessageEditor && !Picker > Editor && !use_modifier_to_send", @@ -331,8 +335,9 @@ "cmd-i": "agent::ToggleProfileSelector", "shift-ctrl-r": "agent::OpenAgentDiff", "cmd-shift-y": "agent::KeepAll", - "cmd-shift-n": "agent::RejectAll" - } + "cmd-shift-n": "agent::RejectAll", + "cmd-shift-v": "agent::PasteRaw", + }, }, { "context": "MessageEditor && !Picker > Editor && use_modifier_to_send", @@ -343,8 +348,9 @@ "cmd-i": "agent::ToggleProfileSelector", "shift-ctrl-r": "agent::OpenAgentDiff", "cmd-shift-y": "agent::KeepAll", - "cmd-shift-n": "agent::RejectAll" - } + "cmd-shift-n": "agent::RejectAll", + "cmd-shift-v": "agent::PasteRaw", + }, }, { "context": "EditMessageEditor > Editor", @@ -352,8 +358,8 @@ "bindings": { "escape": "menu::Cancel", "enter": "menu::Confirm", - "alt-enter": "editor::Newline" - } + "alt-enter": "editor::Newline", + }, }, { "context": "AgentFeedbackMessageEditor > Editor", @@ -361,20 +367,20 @@ "bindings": { "escape": "menu::Cancel", "enter": "menu::Confirm", - "alt-enter": "editor::Newline" - } + "alt-enter": "editor::Newline", + }, }, { "context": "AgentConfiguration", "bindings": { - "ctrl--": "pane::GoBack" - } + "ctrl--": "pane::GoBack", + }, }, { "context": "AcpThread > ModeSelector", "bindings": { - "cmd-enter": "menu::Confirm" - } + "cmd-enter": "menu::Confirm", + }, }, { "context": "AcpThread > Editor && !use_modifier_to_send", @@ -384,8 +390,9 @@ "shift-ctrl-r": "agent::OpenAgentDiff", "cmd-shift-y": "agent::KeepAll", "cmd-shift-n": "agent::RejectAll", - "shift-tab": "agent::CycleModeSelector" - } + "shift-tab": "agent::CycleModeSelector", + "alt-tab": "agent::CycleFavoriteModels", + }, }, { "context": "AcpThread > Editor && use_modifier_to_send", @@ -395,20 +402,21 @@ "shift-ctrl-r": "agent::OpenAgentDiff", "cmd-shift-y": "agent::KeepAll", "cmd-shift-n": "agent::RejectAll", - "shift-tab": "agent::CycleModeSelector" - } + "shift-tab": "agent::CycleModeSelector", + "alt-tab": "agent::CycleFavoriteModels", + }, }, { "context": "ThreadHistory", "bindings": { - "ctrl--": "pane::GoBack" - } + "ctrl--": "pane::GoBack", + }, }, { "context": "ThreadHistory > Editor", "bindings": { - "shift-backspace": "agent::RemoveSelectedThread" - } + "shift-backspace": "agent::RemoveSelectedThread", + }, }, { "context": "RulesLibrary", @@ -416,8 +424,8 @@ "bindings": { "cmd-n": "rules_library::NewRule", "cmd-shift-s": "rules_library::ToggleDefaultRule", - "cmd-w": "workspace::CloseWindow" - } + "cmd-w": "workspace::CloseWindow", + }, }, { "context": "BufferSearchBar", @@ -431,24 +439,24 @@ "cmd-f": "search::FocusSearch", "cmd-alt-f": "search::ToggleReplace", "cmd-alt-l": "search::ToggleSelection", - "cmd-shift-o": "outline::Toggle" - } + "cmd-shift-o": "outline::Toggle", + }, }, { "context": "BufferSearchBar && in_replace > Editor", "use_key_equivalents": true, "bindings": { "enter": "search::ReplaceNext", - "cmd-enter": "search::ReplaceAll" - } + "cmd-enter": "search::ReplaceAll", + }, }, { "context": "BufferSearchBar && !in_replace > Editor", "use_key_equivalents": true, "bindings": { "up": "search::PreviousHistoryQuery", - "down": "search::NextHistoryQuery" - } + "down": "search::NextHistoryQuery", + }, }, { "context": "ProjectSearchBar", @@ -460,24 +468,24 @@ "cmd-shift-f": "search::FocusSearch", "cmd-shift-h": "search::ToggleReplace", "alt-cmd-g": "search::ToggleRegex", - "alt-cmd-x": "search::ToggleRegex" - } + "alt-cmd-x": "search::ToggleRegex", + }, }, { "context": "ProjectSearchBar > Editor", "use_key_equivalents": true, "bindings": { "up": "search::PreviousHistoryQuery", - "down": "search::NextHistoryQuery" - } + "down": "search::NextHistoryQuery", + }, }, { "context": "ProjectSearchBar && in_replace > Editor", "use_key_equivalents": true, "bindings": { "enter": "search::ReplaceNext", - "cmd-enter": "search::ReplaceAll" - } + "cmd-enter": "search::ReplaceAll", + }, }, { "context": "ProjectSearchView", @@ -488,8 +496,8 @@ "shift-enter": "project_search::ToggleAllSearchResults", "cmd-shift-h": "search::ToggleReplace", "alt-cmd-g": "search::ToggleRegex", - "alt-cmd-x": "search::ToggleRegex" - } + "alt-cmd-x": "search::ToggleRegex", + }, }, { "context": "Pane", @@ -519,8 +527,8 @@ "alt-cmd-w": "search::ToggleWholeWord", "alt-cmd-f": "project_search::ToggleFilters", "alt-cmd-x": "search::ToggleRegex", - "cmd-k shift-enter": "pane::TogglePinTab" - } + "cmd-k shift-enter": "pane::TogglePinTab", + }, }, // Bindings from VS Code { @@ -590,24 +598,24 @@ "cmd-.": "editor::ToggleCodeActions", "cmd-k r": "editor::RevealInFileManager", "cmd-k p": "editor::CopyPath", - "cmd-\\": "pane::SplitRight" - } + "cmd-\\": "pane::SplitRight", + }, }, { "context": "Editor && extension == md", "use_key_equivalents": true, "bindings": { "cmd-k v": "markdown::OpenPreviewToTheSide", - "cmd-shift-v": "markdown::OpenPreview" - } + "cmd-shift-v": "markdown::OpenPreview", + }, }, { "context": "Editor && extension == svg", "use_key_equivalents": true, "bindings": { "cmd-k v": "svg::OpenPreviewToTheSide", - "cmd-shift-v": "svg::OpenPreview" - } + "cmd-shift-v": "svg::OpenPreview", + }, }, { "context": "Editor && mode == full", @@ -616,8 +624,8 @@ "cmd-shift-o": "outline::Toggle", "ctrl-g": "go_to_line::Toggle", "cmd-shift-backspace": "editor::GoToPreviousChange", - "cmd-shift-alt-backspace": "editor::GoToNextChange" - } + "cmd-shift-alt-backspace": "editor::GoToNextChange", + }, }, { "context": "Pane", @@ -635,8 +643,8 @@ "ctrl-0": "pane::ActivateLastItem", "ctrl--": "pane::GoBack", "ctrl-_": "pane::GoForward", - "cmd-shift-f": "pane::DeploySearch" - } + "cmd-shift-f": "pane::DeploySearch", + }, }, { "context": "Workspace", @@ -707,8 +715,8 @@ "cmd-k shift-down": "workspace::SwapPaneDown", "cmd-shift-x": "zed::Extensions", "f5": "debugger::Rerun", - "cmd-w": "workspace::CloseActiveDock" - } + "cmd-w": "workspace::CloseActiveDock", + }, }, { "context": "Workspace && !Terminal", @@ -719,27 +727,27 @@ // All task parameters are captured and unchanged between reruns by default. // Use the `"reevaluate_context"` parameter to control this. "cmd-alt-r": ["task::Rerun", { "reevaluate_context": false }], - "ctrl-alt-shift-r": ["task::Spawn", { "reveal_target": "center" }] + "ctrl-alt-shift-r": ["task::Spawn", { "reveal_target": "center" }], // also possible to spawn tasks by name: // "foo-bar": ["task::Spawn", { "task_name": "MyTask", "reveal_target": "dock" }] // or by tag: // "foo-bar": ["task::Spawn", { "task_tag": "MyTag" }], - } + }, }, { "context": "Workspace && debugger_running", "use_key_equivalents": true, "bindings": { "f5": "zed::NoAction", - "f11": "debugger::StepInto" - } + "f11": "debugger::StepInto", + }, }, { "context": "Workspace && debugger_stopped", "use_key_equivalents": true, "bindings": { - "f5": "debugger::Continue" - } + "f5": "debugger::Continue", + }, }, // Bindings from Sublime Text { @@ -760,8 +768,8 @@ "ctrl-alt-shift-left": "editor::SelectToPreviousSubwordStart", "ctrl-alt-shift-b": "editor::SelectToPreviousSubwordStart", "ctrl-alt-shift-right": "editor::SelectToNextSubwordEnd", - "ctrl-alt-shift-f": "editor::SelectToNextSubwordEnd" - } + "ctrl-alt-shift-f": "editor::SelectToNextSubwordEnd", + }, }, // Bindings from Atom { @@ -771,16 +779,16 @@ "cmd-k up": "pane::SplitUp", "cmd-k down": "pane::SplitDown", "cmd-k left": "pane::SplitLeft", - "cmd-k right": "pane::SplitRight" - } + "cmd-k right": "pane::SplitRight", + }, }, // Bindings that should be unified with bindings for more general actions { "context": "Editor && renaming", "use_key_equivalents": true, "bindings": { - "enter": "editor::ConfirmRename" - } + "enter": "editor::ConfirmRename", + }, }, { "context": "Editor && showing_completions", @@ -788,45 +796,47 @@ "bindings": { "enter": "editor::ConfirmCompletion", "shift-enter": "editor::ConfirmCompletionReplace", - "tab": "editor::ComposeCompletion" - } + "tab": "editor::ComposeCompletion", + }, }, { "context": "Editor && in_snippet && has_next_tabstop && !showing_completions", "use_key_equivalents": true, "bindings": { - "tab": "editor::NextSnippetTabstop" - } + "tab": "editor::NextSnippetTabstop", + }, }, { "context": "Editor && in_snippet && has_previous_tabstop && !showing_completions", "use_key_equivalents": true, "bindings": { - "shift-tab": "editor::PreviousSnippetTabstop" - } + "shift-tab": "editor::PreviousSnippetTabstop", + }, }, { "context": "Editor && edit_prediction", "bindings": { "alt-tab": "editor::AcceptEditPrediction", "tab": "editor::AcceptEditPrediction", - "ctrl-cmd-right": "editor::AcceptPartialEditPrediction" - } + "ctrl-cmd-right": "editor::AcceptNextWordEditPrediction", + "ctrl-cmd-down": "editor::AcceptNextLineEditPrediction", + }, }, { "context": "Editor && edit_prediction_conflict", "use_key_equivalents": true, "bindings": { "alt-tab": "editor::AcceptEditPrediction", - "ctrl-cmd-right": "editor::AcceptPartialEditPrediction" - } + "ctrl-cmd-right": "editor::AcceptNextWordEditPrediction", + "ctrl-cmd-down": "editor::AcceptNextLineEditPrediction", + }, }, { "context": "Editor && showing_code_actions", "use_key_equivalents": true, "bindings": { - "enter": "editor::ConfirmCodeAction" - } + "enter": "editor::ConfirmCodeAction", + }, }, { "context": "Editor && (showing_code_actions || showing_completions)", @@ -837,15 +847,15 @@ "down": "editor::ContextMenuNext", "ctrl-n": "editor::ContextMenuNext", "pageup": "editor::ContextMenuFirst", - "pagedown": "editor::ContextMenuLast" - } + "pagedown": "editor::ContextMenuLast", + }, }, { "context": "Editor && showing_signature_help && !showing_completions", "bindings": { "up": "editor::SignatureHelpPrevious", - "down": "editor::SignatureHelpNext" - } + "down": "editor::SignatureHelpNext", + }, }, // Custom bindings { @@ -855,8 +865,8 @@ // TODO: Move this to a dock open action "cmd-shift-c": "collab_panel::ToggleFocus", // Only available in debug builds: opens an element inspector for development. - "cmd-alt-i": "dev::ToggleInspector" - } + "cmd-alt-i": "dev::ToggleInspector", + }, }, { "context": "!ContextEditor > Editor && mode == full", @@ -869,17 +879,20 @@ "cmd-f8": "editor::GoToHunk", "cmd-shift-f8": "editor::GoToPreviousHunk", "ctrl-enter": "assistant::InlineAssist", - "ctrl-:": "editor::ToggleInlayHints" - } + "ctrl-:": "editor::ToggleInlayHints", + }, }, { "context": "PromptEditor", "use_key_equivalents": true, "bindings": { "cmd-alt-/": "agent::ToggleModelSelector", + "alt-tab": "agent::CycleFavoriteModels", "ctrl-[": "agent::CyclePreviousInlineAssist", - "ctrl-]": "agent::CycleNextInlineAssist" - } + "ctrl-]": "agent::CycleNextInlineAssist", + "cmd-shift-enter": "inline_assistant::ThumbsUpResult", + "cmd-shift-backspace": "inline_assistant::ThumbsDownResult", + }, }, { "context": "Prompt", @@ -888,15 +901,15 @@ "left": "menu::SelectPrevious", "right": "menu::SelectNext", "h": "menu::SelectPrevious", - "l": "menu::SelectNext" - } + "l": "menu::SelectNext", + }, }, { "context": "ProjectSearchBar && !in_replace", "use_key_equivalents": true, "bindings": { - "cmd-enter": "project_search::SearchInNew" - } + "cmd-enter": "project_search::SearchInNew", + }, }, { "context": "OutlinePanel && not_editing", @@ -912,8 +925,8 @@ "shift-down": "menu::SelectNext", "shift-up": "menu::SelectPrevious", "alt-enter": "editor::OpenExcerpts", - "cmd-alt-enter": "editor::OpenExcerptsSplit" - } + "cmd-alt-enter": "editor::OpenExcerptsSplit", + }, }, { "context": "ProjectPanel", @@ -945,15 +958,15 @@ "cmd-alt-shift-f": "project_panel::NewSearchInDirectory", "shift-down": "menu::SelectNext", "shift-up": "menu::SelectPrevious", - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "ProjectPanel && not_editing", "use_key_equivalents": true, "bindings": { - "space": "project_panel::Open" - } + "space": "project_panel::Open", + }, }, { "context": "VariableList", @@ -966,17 +979,19 @@ "cmd-alt-c": "variable_list::CopyVariableName", "delete": "variable_list::RemoveWatch", "backspace": "variable_list::RemoveWatch", - "alt-enter": "variable_list::AddWatch" - } + "alt-enter": "variable_list::AddWatch", + }, }, { "context": "GitPanel && ChangesList", "use_key_equivalents": true, "bindings": { - "up": "menu::SelectPrevious", - "down": "menu::SelectNext", - "cmd-up": "menu::SelectFirst", - "cmd-down": "menu::SelectLast", + "up": "git_panel::PreviousEntry", + "down": "git_panel::NextEntry", + "cmd-up": "git_panel::FirstEntry", + "cmd-down": "git_panel::LastEntry", + "left": "git_panel::CollapseSelectedEntry", + "right": "git_panel::ExpandSelectedEntry", "enter": "menu::Confirm", "cmd-alt-y": "git::ToggleStaged", "space": "git::ToggleStaged", @@ -990,15 +1005,15 @@ "backspace": ["git::RestoreFile", { "skip_prompt": false }], "delete": ["git::RestoreFile", { "skip_prompt": false }], "cmd-backspace": ["git::RestoreFile", { "skip_prompt": true }], - "cmd-delete": ["git::RestoreFile", { "skip_prompt": true }] - } + "cmd-delete": ["git::RestoreFile", { "skip_prompt": true }], + }, }, { "context": "GitPanel && CommitEditor", "use_key_equivalents": true, "bindings": { - "escape": "git::Cancel" - } + "escape": "git::Cancel", + }, }, { "context": "GitDiff > Editor", @@ -1007,8 +1022,8 @@ "cmd-enter": "git::Commit", "cmd-shift-enter": "git::Amend", "cmd-ctrl-y": "git::StageAll", - "cmd-ctrl-shift-y": "git::UnstageAll" - } + "cmd-ctrl-shift-y": "git::UnstageAll", + }, }, { "context": "CommitEditor > Editor", @@ -1021,8 +1036,8 @@ "shift-tab": "git_panel::FocusChanges", "alt-up": "git_panel::FocusChanges", "shift-escape": "git::ExpandCommitEditor", - "alt-tab": "git::GenerateCommitMessage" - } + "alt-tab": "git::GenerateCommitMessage", + }, }, { "context": "GitPanel", @@ -1039,8 +1054,8 @@ "cmd-ctrl-y": "git::StageAll", "cmd-ctrl-shift-y": "git::UnstageAll", "cmd-enter": "git::Commit", - "cmd-shift-enter": "git::Amend" - } + "cmd-shift-enter": "git::Amend", + }, }, { "context": "GitCommit > Editor", @@ -1050,16 +1065,16 @@ "escape": "menu::Cancel", "cmd-enter": "git::Commit", "cmd-shift-enter": "git::Amend", - "alt-tab": "git::GenerateCommitMessage" - } + "alt-tab": "git::GenerateCommitMessage", + }, }, { "context": "DebugPanel", "bindings": { "cmd-t": "debugger::ToggleThreadPicker", "cmd-i": "debugger::ToggleSessionPicker", - "shift-alt-escape": "debugger::ToggleExpandItem" - } + "shift-alt-escape": "debugger::ToggleExpandItem", + }, }, { "context": "BreakpointList", @@ -1067,16 +1082,16 @@ "space": "debugger::ToggleEnableBreakpoint", "backspace": "debugger::UnsetBreakpoint", "left": "debugger::PreviousBreakpointProperty", - "right": "debugger::NextBreakpointProperty" - } + "right": "debugger::NextBreakpointProperty", + }, }, { "context": "CollabPanel && not_editing", "use_key_equivalents": true, "bindings": { "ctrl-backspace": "collab_panel::Remove", - "space": "menu::Confirm" - } + "space": "menu::Confirm", + }, }, { "context": "CollabPanel", @@ -1084,22 +1099,22 @@ "bindings": { "alt-up": "collab_panel::MoveChannelUp", "alt-down": "collab_panel::MoveChannelDown", - "alt-enter": "collab_panel::OpenSelectedChannelNotes" - } + "alt-enter": "collab_panel::OpenSelectedChannelNotes", + }, }, { "context": "(CollabPanel && editing) > Editor", "use_key_equivalents": true, "bindings": { - "space": "collab_panel::InsertSpace" - } + "space": "collab_panel::InsertSpace", + }, }, { "context": "ChannelModal", "use_key_equivalents": true, "bindings": { - "tab": "channel_modal::ToggleMode" - } + "tab": "channel_modal::ToggleMode", + }, }, { "context": "Picker > Editor", @@ -1110,30 +1125,30 @@ "down": "menu::SelectNext", "tab": "picker::ConfirmCompletion", "alt-enter": ["picker::ConfirmInput", { "secondary": false }], - "cmd-alt-enter": ["picker::ConfirmInput", { "secondary": true }] - } + "cmd-alt-enter": ["picker::ConfirmInput", { "secondary": true }], + }, }, { "context": "ChannelModal > Picker > Editor", "use_key_equivalents": true, "bindings": { - "tab": "channel_modal::ToggleMode" - } + "tab": "channel_modal::ToggleMode", + }, }, { "context": "ToolchainSelector", "use_key_equivalents": true, "bindings": { - "cmd-shift-a": "toolchain::AddToolchain" - } + "cmd-shift-a": "toolchain::AddToolchain", + }, }, { "context": "FileFinder || (FileFinder > Picker > Editor)", "use_key_equivalents": true, "bindings": { "cmd-shift-a": "file_finder::ToggleSplitMenu", - "cmd-shift-i": "file_finder::ToggleFilterMenu" - } + "cmd-shift-i": "file_finder::ToggleFilterMenu", + }, }, { "context": "FileFinder || (FileFinder > Picker > Editor) || (FileFinder > Picker > menu)", @@ -1143,8 +1158,8 @@ "cmd-j": "pane::SplitDown", "cmd-k": "pane::SplitUp", "cmd-h": "pane::SplitLeft", - "cmd-l": "pane::SplitRight" - } + "cmd-l": "pane::SplitRight", + }, }, { "context": "TabSwitcher", @@ -1153,16 +1168,16 @@ "ctrl-shift-tab": "menu::SelectPrevious", "ctrl-up": "menu::SelectPrevious", "ctrl-down": "menu::SelectNext", - "ctrl-backspace": "tab_switcher::CloseSelectedItem" - } + "ctrl-backspace": "tab_switcher::CloseSelectedItem", + }, }, { "context": "StashList || (StashList > Picker > Editor)", "use_key_equivalents": true, "bindings": { "ctrl-shift-backspace": "stash_picker::DropStashItem", - "ctrl-shift-v": "stash_picker::ShowStashItem" - } + "ctrl-shift-v": "stash_picker::ShowStashItem", + }, }, { "context": "Terminal", @@ -1217,8 +1232,8 @@ "ctrl-alt-left": "pane::SplitLeft", "ctrl-alt-right": "pane::SplitRight", "cmd-d": "pane::SplitRight", - "cmd-alt-r": "terminal::RerunTask" - } + "cmd-alt-r": "terminal::RerunTask", + }, }, { "context": "RatePredictionsModal", @@ -1228,8 +1243,8 @@ "cmd-shift-backspace": "zeta::ThumbsDownActivePrediction", "shift-down": "zeta::NextEdit", "shift-up": "zeta::PreviousEdit", - "right": "zeta::PreviewPrediction" - } + "right": "zeta::PreviewPrediction", + }, }, { "context": "RatePredictionsModal > Editor", @@ -1237,15 +1252,15 @@ "bindings": { "escape": "zeta::FocusPredictions", "cmd-shift-enter": "zeta::ThumbsUpActivePrediction", - "cmd-shift-backspace": "zeta::ThumbsDownActivePrediction" - } + "cmd-shift-backspace": "zeta::ThumbsDownActivePrediction", + }, }, { "context": "ZedPredictModal", "use_key_equivalents": true, "bindings": { - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "ConfigureContextServerModal > Editor", @@ -1253,52 +1268,56 @@ "bindings": { "escape": "menu::Cancel", "enter": "editor::Newline", - "cmd-enter": "menu::Confirm" - } + "cmd-enter": "menu::Confirm", + }, }, { "context": "ContextServerToolsModal", "use_key_equivalents": true, "bindings": { - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "OnboardingAiConfigurationModal", "use_key_equivalents": true, "bindings": { - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "Diagnostics", "use_key_equivalents": true, "bindings": { - "ctrl-r": "diagnostics::ToggleDiagnosticsRefresh" - } + "ctrl-r": "diagnostics::ToggleDiagnosticsRefresh", + }, }, { "context": "DebugConsole > Editor", "use_key_equivalents": true, "bindings": { "enter": "menu::Confirm", - "alt-enter": "console::WatchExpression" - } + "alt-enter": "console::WatchExpression", + }, }, { "context": "RunModal", "use_key_equivalents": true, "bindings": { "ctrl-tab": "pane::ActivateNextItem", - "ctrl-shift-tab": "pane::ActivatePreviousItem" - } + "ctrl-shift-tab": "pane::ActivatePreviousItem", + }, }, { "context": "MarkdownPreview", "bindings": { - "pageup": "markdown::MovePageUp", - "pagedown": "markdown::MovePageDown" - } + "pageup": "markdown::ScrollPageUp", + "pagedown": "markdown::ScrollPageDown", + "up": "markdown::ScrollUp", + "down": "markdown::ScrollDown", + "alt-up": "markdown::ScrollUpByItem", + "alt-down": "markdown::ScrollDownByItem", + }, }, { "context": "KeymapEditor", @@ -1311,8 +1330,8 @@ "alt-enter": "keymap_editor::CreateBinding", "cmd-c": "keymap_editor::CopyAction", "cmd-shift-c": "keymap_editor::CopyContext", - "cmd-t": "keymap_editor::ShowMatchingKeybinds" - } + "cmd-t": "keymap_editor::ShowMatchingKeybinds", + }, }, { "context": "KeystrokeInput", @@ -1320,24 +1339,24 @@ "bindings": { "enter": "keystroke_input::StartRecording", "escape escape escape": "keystroke_input::StopRecording", - "delete": "keystroke_input::ClearKeystrokes" - } + "delete": "keystroke_input::ClearKeystrokes", + }, }, { "context": "KeybindEditorModal", "use_key_equivalents": true, "bindings": { "cmd-enter": "menu::Confirm", - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "KeybindEditorModal > Editor", "use_key_equivalents": true, "bindings": { "up": "menu::SelectPrevious", - "down": "menu::SelectNext" - } + "down": "menu::SelectNext", + }, }, { "context": "Onboarding", @@ -1349,8 +1368,8 @@ "cmd-0": ["zed::ResetUiFontSize", { "persist": false }], "cmd-enter": "onboarding::Finish", "alt-tab": "onboarding::SignIn", - "alt-shift-a": "onboarding::OpenAccount" - } + "alt-shift-a": "onboarding::OpenAccount", + }, }, { "context": "Welcome", @@ -1359,23 +1378,28 @@ "cmd-=": ["zed::IncreaseUiFontSize", { "persist": false }], "cmd-+": ["zed::IncreaseUiFontSize", { "persist": false }], "cmd--": ["zed::DecreaseUiFontSize", { "persist": false }], - "cmd-0": ["zed::ResetUiFontSize", { "persist": false }] - } + "cmd-0": ["zed::ResetUiFontSize", { "persist": false }], + "cmd-1": ["welcome::OpenRecentProject", 0], + "cmd-2": ["welcome::OpenRecentProject", 1], + "cmd-3": ["welcome::OpenRecentProject", 2], + "cmd-4": ["welcome::OpenRecentProject", 3], + "cmd-5": ["welcome::OpenRecentProject", 4], + }, }, { "context": "InvalidBuffer", "use_key_equivalents": true, "bindings": { - "ctrl-shift-enter": "workspace::OpenWithSystem" - } + "ctrl-shift-enter": "workspace::OpenWithSystem", + }, }, { "context": "GitWorktreeSelector || (GitWorktreeSelector > Picker > Editor)", "use_key_equivalents": true, "bindings": { "ctrl-shift-space": "git::WorktreeFromDefaultOnWindow", - "ctrl-space": "git::WorktreeFromDefault" - } + "ctrl-space": "git::WorktreeFromDefault", + }, }, { "context": "SettingsWindow", @@ -1400,8 +1424,8 @@ "ctrl-9": ["settings_editor::FocusFile", 8], "ctrl-0": ["settings_editor::FocusFile", 9], "cmd-{": "settings_editor::FocusPreviousFile", - "cmd-}": "settings_editor::FocusNextFile" - } + "cmd-}": "settings_editor::FocusNextFile", + }, }, { "context": "StashDiff > Editor", @@ -1409,8 +1433,8 @@ "bindings": { "ctrl-space": "git::ApplyCurrentStash", "ctrl-shift-space": "git::PopCurrentStash", - "ctrl-shift-backspace": "git::DropCurrentStash" - } + "ctrl-shift-backspace": "git::DropCurrentStash", + }, }, { "context": "SettingsWindow > NavigationMenu", @@ -1425,22 +1449,22 @@ "pageup": "settings_editor::FocusPreviousRootNavEntry", "pagedown": "settings_editor::FocusNextRootNavEntry", "home": "settings_editor::FocusFirstNavEntry", - "end": "settings_editor::FocusLastNavEntry" - } + "end": "settings_editor::FocusLastNavEntry", + }, }, { "context": "EditPredictionContext > Editor", "bindings": { "alt-left": "dev::EditPredictionContextGoBack", - "alt-right": "dev::EditPredictionContextGoForward" - } + "alt-right": "dev::EditPredictionContextGoForward", + }, }, { "context": "GitBranchSelector || (GitBranchSelector > Picker > Editor)", "use_key_equivalents": true, "bindings": { "cmd-shift-backspace": "branch_picker::DeleteBranch", - "cmd-shift-i": "branch_picker::FilterRemotes" - } - } + "cmd-shift-i": "branch_picker::FilterRemotes", + }, + }, ] diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index ad418931dbbc412ea582b76d3f5b8f2fc66d878a..e4c86bb6f9a05a68ec196bf4186abf5d4186a97a 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -42,16 +42,17 @@ "f11": "zed::ToggleFullScreen", "ctrl-shift-i": "edit_prediction::ToggleMenu", "shift-alt-l": "lsp_tool::ToggleMenu", - "ctrl-shift-alt-c": "editor::DisplayCursorNames" - } + "ctrl-shift-alt-c": "editor::DisplayCursorNames", + "ctrl-shift-alt-s": "workspace::ToggleWorktreeSecurity", + }, }, { "context": "Picker || menu", "use_key_equivalents": true, "bindings": { "up": "menu::SelectPrevious", - "down": "menu::SelectNext" - } + "down": "menu::SelectNext", + }, }, { "context": "Editor", @@ -63,7 +64,6 @@ "delete": "editor::Delete", "tab": "editor::Tab", "shift-tab": "editor::Backtab", - "ctrl-k": "editor::CutToEndOfLine", "ctrl-k ctrl-q": "editor::Rewrap", "ctrl-k q": "editor::Rewrap", "ctrl-backspace": ["editor::DeleteToPreviousWordStart", { "ignore_newlines": false, "ignore_brackets": false }], @@ -120,8 +120,8 @@ "shift-f10": "editor::OpenContextMenu", "ctrl-alt-e": "editor::ToggleEditPrediction", "f9": "editor::ToggleBreakpoint", - "shift-f9": "editor::EditLogBreakpoint" - } + "shift-f9": "editor::EditLogBreakpoint", + }, }, { "context": "Editor && mode == full", @@ -140,23 +140,23 @@ "shift-alt-e": "editor::SelectEnclosingSymbol", "ctrl-shift-backspace": "editor::GoToPreviousChange", "ctrl-shift-alt-backspace": "editor::GoToNextChange", - "alt-enter": "editor::OpenSelectionsInMultibuffer" - } + "alt-enter": "editor::OpenSelectionsInMultibuffer", + }, }, { "context": "Editor && mode == full && edit_prediction", "use_key_equivalents": true, "bindings": { "alt-]": "editor::NextEditPrediction", - "alt-[": "editor::PreviousEditPrediction" - } + "alt-[": "editor::PreviousEditPrediction", + }, }, { "context": "Editor && !edit_prediction", "use_key_equivalents": true, "bindings": { - "alt-\\": "editor::ShowEditPrediction" - } + "alt-\\": "editor::ShowEditPrediction", + }, }, { "context": "Editor && mode == auto_height", @@ -164,23 +164,23 @@ "bindings": { "ctrl-enter": "editor::Newline", "shift-enter": "editor::Newline", - "ctrl-shift-enter": "editor::NewlineBelow" - } + "ctrl-shift-enter": "editor::NewlineBelow", + }, }, { "context": "Markdown", "use_key_equivalents": true, "bindings": { - "ctrl-c": "markdown::Copy" - } + "ctrl-c": "markdown::Copy", + }, }, { "context": "Editor && jupyter && !ContextEditor", "use_key_equivalents": true, "bindings": { "ctrl-shift-enter": "repl::Run", - "ctrl-alt-enter": "repl::RunInPlace" - } + "ctrl-alt-enter": "repl::RunInPlace", + }, }, { "context": "Editor && !agent_diff", @@ -188,8 +188,8 @@ "bindings": { "ctrl-k ctrl-r": "git::Restore", "alt-y": "git::StageAndNext", - "shift-alt-y": "git::UnstageAndNext" - } + "shift-alt-y": "git::UnstageAndNext", + }, }, { "context": "Editor && editor_agent_diff", @@ -199,8 +199,8 @@ "ctrl-n": "agent::Reject", "ctrl-shift-y": "agent::KeepAll", "ctrl-shift-n": "agent::RejectAll", - "ctrl-shift-r": "agent::OpenAgentDiff" - } + "ctrl-shift-r": "agent::OpenAgentDiff", + }, }, { "context": "AgentDiff", @@ -209,8 +209,8 @@ "ctrl-y": "agent::Keep", "ctrl-n": "agent::Reject", "ctrl-shift-y": "agent::KeepAll", - "ctrl-shift-n": "agent::RejectAll" - } + "ctrl-shift-n": "agent::RejectAll", + }, }, { "context": "ContextEditor > Editor", @@ -226,8 +226,9 @@ "ctrl-k c": "assistant::CopyCode", "ctrl-g": "search::SelectNextMatch", "ctrl-shift-g": "search::SelectPreviousMatch", - "ctrl-k l": "agent::OpenRulesLibrary" - } + "ctrl-k l": "agent::OpenRulesLibrary", + "ctrl-shift-v": "agent::PasteRaw", + }, }, { "context": "AgentPanel", @@ -252,38 +253,39 @@ "alt-enter": "agent::ContinueWithBurnMode", "shift-alt-a": "agent::AllowOnce", "ctrl-alt-y": "agent::AllowAlways", - "shift-alt-z": "agent::RejectOnce" - } + "shift-alt-z": "agent::RejectOnce", + "alt-tab": "agent::CycleFavoriteModels", + }, }, { "context": "AgentPanel > NavigationMenu", "use_key_equivalents": true, "bindings": { - "shift-backspace": "agent::DeleteRecentlyOpenThread" - } + "shift-backspace": "agent::DeleteRecentlyOpenThread", + }, }, { "context": "AgentPanel > Markdown", "use_key_equivalents": true, "bindings": { - "ctrl-c": "markdown::CopyAsMarkdown" - } + "ctrl-c": "markdown::CopyAsMarkdown", + }, }, { "context": "AgentPanel && text_thread", "use_key_equivalents": true, "bindings": { "ctrl-n": "agent::NewTextThread", - "ctrl-alt-t": "agent::NewThread" - } + "ctrl-alt-t": "agent::NewThread", + }, }, { "context": "AgentPanel && acp_thread", "use_key_equivalents": true, "bindings": { "ctrl-n": "agent::NewExternalAgentThread", - "ctrl-alt-t": "agent::NewThread" - } + "ctrl-alt-t": "agent::NewThread", + }, }, { "context": "MessageEditor && !Picker > Editor && !use_modifier_to_send", @@ -294,8 +296,9 @@ "ctrl-i": "agent::ToggleProfileSelector", "ctrl-shift-r": "agent::OpenAgentDiff", "ctrl-shift-y": "agent::KeepAll", - "ctrl-shift-n": "agent::RejectAll" - } + "ctrl-shift-n": "agent::RejectAll", + "ctrl-shift-v": "agent::PasteRaw", + }, }, { "context": "MessageEditor && !Picker > Editor && use_modifier_to_send", @@ -306,8 +309,9 @@ "ctrl-i": "agent::ToggleProfileSelector", "ctrl-shift-r": "agent::OpenAgentDiff", "ctrl-shift-y": "agent::KeepAll", - "ctrl-shift-n": "agent::RejectAll" - } + "ctrl-shift-n": "agent::RejectAll", + "ctrl-shift-v": "agent::PasteRaw", + }, }, { "context": "EditMessageEditor > Editor", @@ -315,8 +319,8 @@ "bindings": { "escape": "menu::Cancel", "enter": "menu::Confirm", - "alt-enter": "editor::Newline" - } + "alt-enter": "editor::Newline", + }, }, { "context": "AgentFeedbackMessageEditor > Editor", @@ -324,14 +328,14 @@ "bindings": { "escape": "menu::Cancel", "enter": "menu::Confirm", - "alt-enter": "editor::Newline" - } + "alt-enter": "editor::Newline", + }, }, { "context": "AcpThread > ModeSelector", "bindings": { - "ctrl-enter": "menu::Confirm" - } + "ctrl-enter": "menu::Confirm", + }, }, { "context": "AcpThread > Editor && !use_modifier_to_send", @@ -341,8 +345,9 @@ "ctrl-shift-r": "agent::OpenAgentDiff", "ctrl-shift-y": "agent::KeepAll", "ctrl-shift-n": "agent::RejectAll", - "shift-tab": "agent::CycleModeSelector" - } + "shift-tab": "agent::CycleModeSelector", + "alt-tab": "agent::CycleFavoriteModels", + }, }, { "context": "AcpThread > Editor && use_modifier_to_send", @@ -352,15 +357,16 @@ "ctrl-shift-r": "agent::OpenAgentDiff", "ctrl-shift-y": "agent::KeepAll", "ctrl-shift-n": "agent::RejectAll", - "shift-tab": "agent::CycleModeSelector" - } + "shift-tab": "agent::CycleModeSelector", + "alt-tab": "agent::CycleFavoriteModels", + }, }, { "context": "ThreadHistory", "use_key_equivalents": true, "bindings": { - "backspace": "agent::RemoveSelectedThread" - } + "backspace": "agent::RemoveSelectedThread", + }, }, { "context": "RulesLibrary", @@ -368,8 +374,8 @@ "bindings": { "ctrl-n": "rules_library::NewRule", "ctrl-shift-s": "rules_library::ToggleDefaultRule", - "ctrl-w": "workspace::CloseWindow" - } + "ctrl-w": "workspace::CloseWindow", + }, }, { "context": "BufferSearchBar", @@ -382,24 +388,24 @@ "alt-enter": "search::SelectAllMatches", "ctrl-f": "search::FocusSearch", "ctrl-h": "search::ToggleReplace", - "ctrl-l": "search::ToggleSelection" - } + "ctrl-l": "search::ToggleSelection", + }, }, { "context": "BufferSearchBar && in_replace > Editor", "use_key_equivalents": true, "bindings": { "enter": "search::ReplaceNext", - "ctrl-enter": "search::ReplaceAll" - } + "ctrl-enter": "search::ReplaceAll", + }, }, { "context": "BufferSearchBar && !in_replace > Editor", "use_key_equivalents": true, "bindings": { "up": "search::PreviousHistoryQuery", - "down": "search::NextHistoryQuery" - } + "down": "search::NextHistoryQuery", + }, }, { "context": "ProjectSearchBar", @@ -408,24 +414,24 @@ "escape": "project_search::ToggleFocus", "ctrl-shift-f": "search::FocusSearch", "ctrl-shift-h": "search::ToggleReplace", - "alt-r": "search::ToggleRegex" // vscode - } + "alt-r": "search::ToggleRegex", // vscode + }, }, { "context": "ProjectSearchBar > Editor", "use_key_equivalents": true, "bindings": { "up": "search::PreviousHistoryQuery", - "down": "search::NextHistoryQuery" - } + "down": "search::NextHistoryQuery", + }, }, { "context": "ProjectSearchBar && in_replace > Editor", "use_key_equivalents": true, "bindings": { "enter": "search::ReplaceNext", - "ctrl-alt-enter": "search::ReplaceAll" - } + "ctrl-alt-enter": "search::ReplaceAll", + }, }, { "context": "ProjectSearchView", @@ -433,8 +439,8 @@ "bindings": { "escape": "project_search::ToggleFocus", "ctrl-shift-h": "search::ToggleReplace", - "alt-r": "search::ToggleRegex" // vscode - } + "alt-r": "search::ToggleRegex", // vscode + }, }, { "context": "Pane", @@ -465,8 +471,10 @@ "ctrl-k ctrl-w": "workspace::CloseAllItemsAndPanes", "back": "pane::GoBack", "alt--": "pane::GoBack", + "alt-left": "pane::GoBack", "forward": "pane::GoForward", "alt-=": "pane::GoForward", + "alt-right": "pane::GoForward", "f3": "search::SelectNextMatch", "shift-f3": "search::SelectPreviousMatch", "ctrl-shift-f": "project_search::ToggleFocus", @@ -479,8 +487,8 @@ "shift-enter": "project_search::ToggleAllSearchResults", "alt-r": "search::ToggleRegex", // "ctrl-shift-alt-x": "search::ToggleRegex", - "ctrl-k shift-enter": "pane::TogglePinTab" - } + "ctrl-k shift-enter": "pane::TogglePinTab", + }, }, // Bindings from VS Code { @@ -489,8 +497,8 @@ "bindings": { "ctrl-[": "editor::Outdent", "ctrl-]": "editor::Indent", - "ctrl-shift-alt-up": ["editor::AddSelectionAbove", { "skip_soft_wrap": true }], // Insert Cursor Above - "ctrl-shift-alt-down": ["editor::AddSelectionBelow", { "skip_soft_wrap": true }], // Insert Cursor Below + "ctrl-alt-up": ["editor::AddSelectionAbove", { "skip_soft_wrap": true }], // Insert Cursor Above + "ctrl-alt-down": ["editor::AddSelectionBelow", { "skip_soft_wrap": true }], // Insert Cursor Below "ctrl-shift-k": "editor::DeleteLine", "alt-up": "editor::MoveLineUp", "alt-down": "editor::MoveLineDown", @@ -501,10 +509,14 @@ "ctrl-shift-l": "editor::SelectAllMatches", // Select all occurrences of current selection "ctrl-f2": "editor::SelectAllMatches", // Select all occurrences of current word "ctrl-d": ["editor::SelectNext", { "replace_newest": false }], // editor.action.addSelectionToNextFindMatch / find_under_expand + "ctrl-f3": ["editor::SelectNext", { "replace_newest": false }], // editor.action.addSelectionToNextFindMatch / find_under_expand "ctrl-k ctrl-d": ["editor::SelectNext", { "replace_newest": true }], // editor.action.moveSelectionToNextFindMatch / find_under_expand_skip + "ctrl-shift-f3": ["editor::SelectPrevious", { "replace_newest": false }], // editor.action.addSelectionToNextFindMatch / find_under_expand "ctrl-k ctrl-i": "editor::Hover", "ctrl-k ctrl-b": "editor::BlameHover", + "ctrl-k ctrl-f": "editor::FormatSelections", "ctrl-/": ["editor::ToggleComments", { "advance_downwards": false }], + "ctrl-k ctrl-c": ["editor::ToggleComments", { "advance_downwards": false }], "f8": ["editor::GoToDiagnostic", { "severity": { "min": "hint", "max": "error" } }], "shift-f8": ["editor::GoToPreviousDiagnostic", { "severity": { "min": "hint", "max": "error" } }], "f2": "editor::Rename", @@ -536,32 +548,32 @@ "ctrl-k p": "editor::CopyPath", "ctrl-\\": "pane::SplitRight", "alt-.": "editor::GoToHunk", - "alt-,": "editor::GoToPreviousHunk" - } + "alt-,": "editor::GoToPreviousHunk", + }, }, { "context": "Editor && extension == md", "use_key_equivalents": true, "bindings": { "ctrl-k v": "markdown::OpenPreviewToTheSide", - "ctrl-shift-v": "markdown::OpenPreview" - } + "ctrl-shift-v": "markdown::OpenPreview", + }, }, { "context": "Editor && extension == svg", "use_key_equivalents": true, "bindings": { "ctrl-k v": "svg::OpenPreviewToTheSide", - "ctrl-shift-v": "svg::OpenPreview" - } + "ctrl-shift-v": "svg::OpenPreview", + }, }, { "context": "Editor && mode == full", "use_key_equivalents": true, "bindings": { "ctrl-shift-o": "outline::Toggle", - "ctrl-g": "go_to_line::Toggle" - } + "ctrl-g": "go_to_line::Toggle", + }, }, { "context": "Workspace", @@ -645,22 +657,22 @@ // "foo-bar": ["task::Spawn", { "task_tag": "MyTag" }], "f5": "debugger::Rerun", "ctrl-f4": "workspace::CloseActiveDock", - "ctrl-w": "workspace::CloseActiveDock" - } + "ctrl-w": "workspace::CloseActiveDock", + }, }, { "context": "Workspace && debugger_running", "use_key_equivalents": true, "bindings": { - "f5": "zed::NoAction" - } + "f5": "zed::NoAction", + }, }, { "context": "Workspace && debugger_stopped", "use_key_equivalents": true, "bindings": { - "f5": "debugger::Continue" - } + "f5": "debugger::Continue", + }, }, { "context": "ApplicationMenu", @@ -668,8 +680,8 @@ "bindings": { "f10": "menu::Cancel", "left": "app_menu::ActivateMenuLeft", - "right": "app_menu::ActivateMenuRight" - } + "right": "app_menu::ActivateMenuRight", + }, }, // Bindings from Sublime Text { @@ -686,8 +698,8 @@ "ctrl-alt-left": "editor::MoveToPreviousSubwordStart", "ctrl-alt-right": "editor::MoveToNextSubwordEnd", "ctrl-shift-alt-left": "editor::SelectToPreviousSubwordStart", - "ctrl-shift-alt-right": "editor::SelectToNextSubwordEnd" - } + "ctrl-shift-alt-right": "editor::SelectToNextSubwordEnd", + }, }, // Bindings from Atom { @@ -697,16 +709,16 @@ "ctrl-k up": "pane::SplitUp", "ctrl-k down": "pane::SplitDown", "ctrl-k left": "pane::SplitLeft", - "ctrl-k right": "pane::SplitRight" - } + "ctrl-k right": "pane::SplitRight", + }, }, // Bindings that should be unified with bindings for more general actions { "context": "Editor && renaming", "use_key_equivalents": true, "bindings": { - "enter": "editor::ConfirmRename" - } + "enter": "editor::ConfirmRename", + }, }, { "context": "Editor && showing_completions", @@ -714,22 +726,22 @@ "bindings": { "enter": "editor::ConfirmCompletion", "shift-enter": "editor::ConfirmCompletionReplace", - "tab": "editor::ComposeCompletion" - } + "tab": "editor::ComposeCompletion", + }, }, { "context": "Editor && in_snippet && has_next_tabstop && !showing_completions", "use_key_equivalents": true, "bindings": { - "tab": "editor::NextSnippetTabstop" - } + "tab": "editor::NextSnippetTabstop", + }, }, { "context": "Editor && in_snippet && has_previous_tabstop && !showing_completions", "use_key_equivalents": true, "bindings": { - "shift-tab": "editor::PreviousSnippetTabstop" - } + "shift-tab": "editor::PreviousSnippetTabstop", + }, }, // Bindings for accepting edit predictions // @@ -742,8 +754,9 @@ "alt-tab": "editor::AcceptEditPrediction", "alt-l": "editor::AcceptEditPrediction", "tab": "editor::AcceptEditPrediction", - "alt-right": "editor::AcceptPartialEditPrediction" - } + "alt-right": "editor::AcceptNextWordEditPrediction", + "alt-down": "editor::AcceptNextLineEditPrediction", + }, }, { "context": "Editor && edit_prediction_conflict", @@ -751,15 +764,16 @@ "bindings": { "alt-tab": "editor::AcceptEditPrediction", "alt-l": "editor::AcceptEditPrediction", - "alt-right": "editor::AcceptPartialEditPrediction" - } + "alt-right": "editor::AcceptNextWordEditPrediction", + "alt-down": "editor::AcceptNextLineEditPrediction", + }, }, { "context": "Editor && showing_code_actions", "use_key_equivalents": true, "bindings": { - "enter": "editor::ConfirmCodeAction" - } + "enter": "editor::ConfirmCodeAction", + }, }, { "context": "Editor && (showing_code_actions || showing_completions)", @@ -770,16 +784,16 @@ "ctrl-n": "editor::ContextMenuNext", "down": "editor::ContextMenuNext", "pageup": "editor::ContextMenuFirst", - "pagedown": "editor::ContextMenuLast" - } + "pagedown": "editor::ContextMenuLast", + }, }, { "context": "Editor && showing_signature_help && !showing_completions", "use_key_equivalents": true, "bindings": { "up": "editor::SignatureHelpPrevious", - "down": "editor::SignatureHelpNext" - } + "down": "editor::SignatureHelpNext", + }, }, // Custom bindings { @@ -787,15 +801,15 @@ "bindings": { "ctrl-shift-alt-f": "workspace::FollowNextCollaborator", // Only available in debug builds: opens an element inspector for development. - "shift-alt-i": "dev::ToggleInspector" - } + "shift-alt-i": "dev::ToggleInspector", + }, }, { "context": "!Terminal", "use_key_equivalents": true, "bindings": { - "ctrl-shift-c": "collab_panel::ToggleFocus" - } + "ctrl-shift-c": "collab_panel::ToggleFocus", + }, }, { "context": "!ContextEditor > Editor && mode == full", @@ -808,16 +822,18 @@ "ctrl-f8": "editor::GoToHunk", "ctrl-shift-f8": "editor::GoToPreviousHunk", "ctrl-enter": "assistant::InlineAssist", - "ctrl-shift-;": "editor::ToggleInlayHints" - } + "ctrl-shift-;": "editor::ToggleInlayHints", + }, }, { "context": "PromptEditor", "use_key_equivalents": true, "bindings": { "ctrl-[": "agent::CyclePreviousInlineAssist", - "ctrl-]": "agent::CycleNextInlineAssist" - } + "ctrl-]": "agent::CycleNextInlineAssist", + "ctrl-shift-enter": "inline_assistant::ThumbsUpResult", + "ctrl-shift-delete": "inline_assistant::ThumbsDownResult", + }, }, { "context": "Prompt", @@ -826,15 +842,15 @@ "left": "menu::SelectPrevious", "right": "menu::SelectNext", "h": "menu::SelectPrevious", - "l": "menu::SelectNext" - } + "l": "menu::SelectNext", + }, }, { "context": "ProjectSearchBar && !in_replace", "use_key_equivalents": true, "bindings": { - "ctrl-enter": "project_search::SearchInNew" - } + "ctrl-enter": "project_search::SearchInNew", + }, }, { "context": "OutlinePanel && not_editing", @@ -849,8 +865,8 @@ "shift-down": "menu::SelectNext", "shift-up": "menu::SelectPrevious", "alt-enter": "editor::OpenExcerpts", - "ctrl-alt-enter": "editor::OpenExcerptsSplit" - } + "ctrl-alt-enter": "editor::OpenExcerptsSplit", + }, }, { "context": "ProjectPanel", @@ -884,22 +900,24 @@ "ctrl-k ctrl-shift-f": "project_panel::NewSearchInDirectory", "shift-down": "menu::SelectNext", "shift-up": "menu::SelectPrevious", - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "ProjectPanel && not_editing", "use_key_equivalents": true, "bindings": { - "space": "project_panel::Open" - } + "space": "project_panel::Open", + }, }, { "context": "GitPanel && ChangesList", "use_key_equivalents": true, "bindings": { - "up": "menu::SelectPrevious", - "down": "menu::SelectNext", + "up": "git_panel::PreviousEntry", + "down": "git_panel::NextEntry", + "left": "git_panel::CollapseSelectedEntry", + "right": "git_panel::ExpandSelectedEntry", "enter": "menu::Confirm", "alt-y": "git::StageFile", "shift-alt-y": "git::UnstageFile", @@ -913,15 +931,15 @@ "backspace": ["git::RestoreFile", { "skip_prompt": false }], "shift-delete": ["git::RestoreFile", { "skip_prompt": false }], "ctrl-backspace": ["git::RestoreFile", { "skip_prompt": false }], - "ctrl-delete": ["git::RestoreFile", { "skip_prompt": false }] - } + "ctrl-delete": ["git::RestoreFile", { "skip_prompt": false }], + }, }, { "context": "GitPanel && CommitEditor", "use_key_equivalents": true, "bindings": { - "escape": "git::Cancel" - } + "escape": "git::Cancel", + }, }, { "context": "GitCommit > Editor", @@ -931,8 +949,8 @@ "enter": "editor::Newline", "ctrl-enter": "git::Commit", "ctrl-shift-enter": "git::Amend", - "alt-l": "git::GenerateCommitMessage" - } + "alt-l": "git::GenerateCommitMessage", + }, }, { "context": "GitPanel", @@ -949,8 +967,8 @@ "ctrl-space": "git::StageAll", "ctrl-shift-space": "git::UnstageAll", "ctrl-enter": "git::Commit", - "ctrl-shift-enter": "git::Amend" - } + "ctrl-shift-enter": "git::Amend", + }, }, { "context": "GitDiff > Editor", @@ -959,15 +977,15 @@ "ctrl-enter": "git::Commit", "ctrl-shift-enter": "git::Amend", "ctrl-space": "git::StageAll", - "ctrl-shift-space": "git::UnstageAll" - } + "ctrl-shift-space": "git::UnstageAll", + }, }, { "context": "AskPass > Editor", "use_key_equivalents": true, "bindings": { - "enter": "menu::Confirm" - } + "enter": "menu::Confirm", + }, }, { "context": "CommitEditor > Editor", @@ -980,8 +998,8 @@ "ctrl-enter": "git::Commit", "ctrl-shift-enter": "git::Amend", "alt-up": "git_panel::FocusChanges", - "alt-l": "git::GenerateCommitMessage" - } + "alt-l": "git::GenerateCommitMessage", + }, }, { "context": "DebugPanel", @@ -989,8 +1007,8 @@ "bindings": { "ctrl-t": "debugger::ToggleThreadPicker", "ctrl-i": "debugger::ToggleSessionPicker", - "shift-alt-escape": "debugger::ToggleExpandItem" - } + "shift-alt-escape": "debugger::ToggleExpandItem", + }, }, { "context": "VariableList", @@ -1003,8 +1021,8 @@ "ctrl-alt-c": "variable_list::CopyVariableName", "delete": "variable_list::RemoveWatch", "backspace": "variable_list::RemoveWatch", - "alt-enter": "variable_list::AddWatch" - } + "alt-enter": "variable_list::AddWatch", + }, }, { "context": "BreakpointList", @@ -1013,16 +1031,16 @@ "space": "debugger::ToggleEnableBreakpoint", "backspace": "debugger::UnsetBreakpoint", "left": "debugger::PreviousBreakpointProperty", - "right": "debugger::NextBreakpointProperty" - } + "right": "debugger::NextBreakpointProperty", + }, }, { "context": "CollabPanel && not_editing", "use_key_equivalents": true, "bindings": { "ctrl-backspace": "collab_panel::Remove", - "space": "menu::Confirm" - } + "space": "menu::Confirm", + }, }, { "context": "CollabPanel", @@ -1030,22 +1048,22 @@ "bindings": { "alt-up": "collab_panel::MoveChannelUp", "alt-down": "collab_panel::MoveChannelDown", - "alt-enter": "collab_panel::OpenSelectedChannelNotes" - } + "alt-enter": "collab_panel::OpenSelectedChannelNotes", + }, }, { "context": "(CollabPanel && editing) > Editor", "use_key_equivalents": true, "bindings": { - "space": "collab_panel::InsertSpace" - } + "space": "collab_panel::InsertSpace", + }, }, { "context": "ChannelModal", "use_key_equivalents": true, "bindings": { - "tab": "channel_modal::ToggleMode" - } + "tab": "channel_modal::ToggleMode", + }, }, { "context": "Picker > Editor", @@ -1055,22 +1073,22 @@ "up": "menu::SelectPrevious", "down": "menu::SelectNext", "tab": "picker::ConfirmCompletion", - "alt-enter": ["picker::ConfirmInput", { "secondary": false }] - } + "alt-enter": ["picker::ConfirmInput", { "secondary": false }], + }, }, { "context": "ChannelModal > Picker > Editor", "use_key_equivalents": true, "bindings": { - "tab": "channel_modal::ToggleMode" - } + "tab": "channel_modal::ToggleMode", + }, }, { "context": "ToolchainSelector", "use_key_equivalents": true, "bindings": { - "ctrl-shift-a": "toolchain::AddToolchain" - } + "ctrl-shift-a": "toolchain::AddToolchain", + }, }, { "context": "FileFinder || (FileFinder > Picker > Editor)", @@ -1078,8 +1096,8 @@ "bindings": { "ctrl-p": "file_finder::Toggle", "ctrl-shift-a": "file_finder::ToggleSplitMenu", - "ctrl-shift-i": "file_finder::ToggleFilterMenu" - } + "ctrl-shift-i": "file_finder::ToggleFilterMenu", + }, }, { "context": "FileFinder || (FileFinder > Picker > Editor) || (FileFinder > Picker > menu)", @@ -1089,8 +1107,8 @@ "ctrl-j": "pane::SplitDown", "ctrl-k": "pane::SplitUp", "ctrl-h": "pane::SplitLeft", - "ctrl-l": "pane::SplitRight" - } + "ctrl-l": "pane::SplitRight", + }, }, { "context": "TabSwitcher", @@ -1099,16 +1117,16 @@ "ctrl-shift-tab": "menu::SelectPrevious", "ctrl-up": "menu::SelectPrevious", "ctrl-down": "menu::SelectNext", - "ctrl-backspace": "tab_switcher::CloseSelectedItem" - } + "ctrl-backspace": "tab_switcher::CloseSelectedItem", + }, }, { "context": "StashList || (StashList > Picker > Editor)", "use_key_equivalents": true, "bindings": { "ctrl-shift-backspace": "stash_picker::DropStashItem", - "ctrl-shift-v": "stash_picker::ShowStashItem" - } + "ctrl-shift-v": "stash_picker::ShowStashItem", + }, }, { "context": "Terminal", @@ -1155,21 +1173,21 @@ "ctrl-shift-r": "terminal::RerunTask", "ctrl-alt-r": "terminal::RerunTask", "alt-t": "terminal::RerunTask", - "ctrl-shift-5": "pane::SplitRight" - } + "ctrl-shift-5": "pane::SplitRight", + }, }, { "context": "Terminal && selection", "bindings": { - "ctrl-c": "terminal::Copy" - } + "ctrl-c": "terminal::Copy", + }, }, { "context": "ZedPredictModal", "use_key_equivalents": true, "bindings": { - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "ConfigureContextServerModal > Editor", @@ -1177,53 +1195,57 @@ "bindings": { "escape": "menu::Cancel", "enter": "editor::Newline", - "ctrl-enter": "menu::Confirm" - } + "ctrl-enter": "menu::Confirm", + }, }, { "context": "ContextServerToolsModal", "use_key_equivalents": true, "bindings": { - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "OnboardingAiConfigurationModal", "use_key_equivalents": true, "bindings": { - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "Diagnostics", "use_key_equivalents": true, "bindings": { - "ctrl-r": "diagnostics::ToggleDiagnosticsRefresh" - } + "ctrl-r": "diagnostics::ToggleDiagnosticsRefresh", + }, }, { "context": "DebugConsole > Editor", "use_key_equivalents": true, "bindings": { "enter": "menu::Confirm", - "alt-enter": "console::WatchExpression" - } + "alt-enter": "console::WatchExpression", + }, }, { "context": "RunModal", "use_key_equivalents": true, "bindings": { "ctrl-tab": "pane::ActivateNextItem", - "ctrl-shift-tab": "pane::ActivatePreviousItem" - } + "ctrl-shift-tab": "pane::ActivatePreviousItem", + }, }, { "context": "MarkdownPreview", "use_key_equivalents": true, "bindings": { - "pageup": "markdown::MovePageUp", - "pagedown": "markdown::MovePageDown" - } + "pageup": "markdown::ScrollPageUp", + "pagedown": "markdown::ScrollPageDown", + "up": "markdown::ScrollUp", + "down": "markdown::ScrollDown", + "alt-up": "markdown::ScrollUpByItem", + "alt-down": "markdown::ScrollDownByItem", + }, }, { "context": "KeymapEditor", @@ -1236,8 +1258,8 @@ "alt-enter": "keymap_editor::CreateBinding", "ctrl-c": "keymap_editor::CopyAction", "ctrl-shift-c": "keymap_editor::CopyContext", - "ctrl-t": "keymap_editor::ShowMatchingKeybinds" - } + "ctrl-t": "keymap_editor::ShowMatchingKeybinds", + }, }, { "context": "KeystrokeInput", @@ -1245,24 +1267,24 @@ "bindings": { "enter": "keystroke_input::StartRecording", "escape escape escape": "keystroke_input::StopRecording", - "delete": "keystroke_input::ClearKeystrokes" - } + "delete": "keystroke_input::ClearKeystrokes", + }, }, { "context": "KeybindEditorModal", "use_key_equivalents": true, "bindings": { "ctrl-enter": "menu::Confirm", - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "KeybindEditorModal > Editor", "use_key_equivalents": true, "bindings": { "up": "menu::SelectPrevious", - "down": "menu::SelectNext" - } + "down": "menu::SelectNext", + }, }, { "context": "Onboarding", @@ -1274,8 +1296,8 @@ "ctrl-0": ["zed::ResetUiFontSize", { "persist": false }], "ctrl-enter": "onboarding::Finish", "alt-shift-l": "onboarding::SignIn", - "shift-alt-a": "onboarding::OpenAccount" - } + "shift-alt-a": "onboarding::OpenAccount", + }, }, { "context": "Welcome", @@ -1284,16 +1306,21 @@ "ctrl-=": ["zed::IncreaseUiFontSize", { "persist": false }], "ctrl-+": ["zed::IncreaseUiFontSize", { "persist": false }], "ctrl--": ["zed::DecreaseUiFontSize", { "persist": false }], - "ctrl-0": ["zed::ResetUiFontSize", { "persist": false }] - } + "ctrl-0": ["zed::ResetUiFontSize", { "persist": false }], + "ctrl-1": ["welcome::OpenRecentProject", 0], + "ctrl-2": ["welcome::OpenRecentProject", 1], + "ctrl-3": ["welcome::OpenRecentProject", 2], + "ctrl-4": ["welcome::OpenRecentProject", 3], + "ctrl-5": ["welcome::OpenRecentProject", 4], + }, }, { "context": "GitWorktreeSelector || (GitWorktreeSelector > Picker > Editor)", "use_key_equivalents": true, "bindings": { "ctrl-shift-space": "git::WorktreeFromDefaultOnWindow", - "ctrl-space": "git::WorktreeFromDefault" - } + "ctrl-space": "git::WorktreeFromDefault", + }, }, { "context": "SettingsWindow", @@ -1318,8 +1345,8 @@ "ctrl-9": ["settings_editor::FocusFile", 8], "ctrl-0": ["settings_editor::FocusFile", 9], "ctrl-pageup": "settings_editor::FocusPreviousFile", - "ctrl-pagedown": "settings_editor::FocusNextFile" - } + "ctrl-pagedown": "settings_editor::FocusNextFile", + }, }, { "context": "StashDiff > Editor", @@ -1327,8 +1354,8 @@ "bindings": { "ctrl-space": "git::ApplyCurrentStash", "ctrl-shift-space": "git::PopCurrentStash", - "ctrl-shift-backspace": "git::DropCurrentStash" - } + "ctrl-shift-backspace": "git::DropCurrentStash", + }, }, { "context": "SettingsWindow > NavigationMenu", @@ -1343,22 +1370,22 @@ "pageup": "settings_editor::FocusPreviousRootNavEntry", "pagedown": "settings_editor::FocusNextRootNavEntry", "home": "settings_editor::FocusFirstNavEntry", - "end": "settings_editor::FocusLastNavEntry" - } + "end": "settings_editor::FocusLastNavEntry", + }, }, { "context": "EditPredictionContext > Editor", "bindings": { "alt-left": "dev::EditPredictionContextGoBack", - "alt-right": "dev::EditPredictionContextGoForward" - } + "alt-right": "dev::EditPredictionContextGoForward", + }, }, { "context": "GitBranchSelector || (GitBranchSelector > Picker > Editor)", "use_key_equivalents": true, "bindings": { "ctrl-shift-backspace": "branch_picker::DeleteBranch", - "ctrl-shift-i": "branch_picker::FilterRemotes" - } - } + "ctrl-shift-i": "branch_picker::FilterRemotes", + }, + }, ] diff --git a/assets/keymaps/initial.json b/assets/keymaps/initial.json index 8e4fe59f44ea7346a51e1c064ffa0553315da3b9..3a8d7f382aa57b39efc22845a17a4ef1bfd240ef 100644 --- a/assets/keymaps/initial.json +++ b/assets/keymaps/initial.json @@ -10,12 +10,12 @@ "context": "Workspace", "bindings": { // "shift shift": "file_finder::Toggle" - } + }, }, { "context": "Editor && vim_mode == insert", "bindings": { // "j k": "vim::NormalBefore" - } - } + }, + }, ] diff --git a/assets/keymaps/linux/atom.json b/assets/keymaps/linux/atom.json index 98992b19fac72055807063edae8b7b23652062d3..a15d4877aab79ac2e570697137ba89e3572d074e 100644 --- a/assets/keymaps/linux/atom.json +++ b/assets/keymaps/linux/atom.json @@ -4,15 +4,15 @@ "bindings": { "ctrl-shift-f5": "workspace::Reload", // window:reload "ctrl-k ctrl-n": "workspace::ActivatePreviousPane", // window:focus-next-pane - "ctrl-k ctrl-p": "workspace::ActivateNextPane" // window:focus-previous-pane - } + "ctrl-k ctrl-p": "workspace::ActivateNextPane", // window:focus-previous-pane + }, }, { "context": "Editor", "bindings": { "ctrl-k ctrl-u": "editor::ConvertToUpperCase", // editor:upper-case - "ctrl-k ctrl-l": "editor::ConvertToLowerCase" // editor:lower-case - } + "ctrl-k ctrl-l": "editor::ConvertToLowerCase", // editor:lower-case + }, }, { "context": "Editor && mode == full", @@ -32,8 +32,8 @@ "ctrl-down": "editor::MoveLineDown", // editor:move-line-down "ctrl-\\": "workspace::ToggleLeftDock", // tree-view:toggle "ctrl-shift-m": "markdown::OpenPreviewToTheSide", // markdown-preview:toggle - "ctrl-r": "outline::Toggle" // symbols-view:toggle-project-symbols - } + "ctrl-r": "outline::Toggle", // symbols-view:toggle-project-symbols + }, }, { "context": "BufferSearchBar", @@ -41,8 +41,8 @@ "f3": ["editor::SelectNext", { "replace_newest": true }], // find-and-replace:find-next "shift-f3": ["editor::SelectPrevious", { "replace_newest": true }], //find-and-replace:find-previous "ctrl-f3": "search::SelectNextMatch", // find-and-replace:find-next-selected - "ctrl-shift-f3": "search::SelectPreviousMatch" // find-and-replace:find-previous-selected - } + "ctrl-shift-f3": "search::SelectPreviousMatch", // find-and-replace:find-previous-selected + }, }, { "context": "Workspace", @@ -50,8 +50,8 @@ "ctrl-\\": "workspace::ToggleLeftDock", // tree-view:toggle "ctrl-k ctrl-b": "workspace::ToggleLeftDock", // tree-view:toggle "ctrl-t": "file_finder::Toggle", // fuzzy-finder:toggle-file-finder - "ctrl-r": "project_symbols::Toggle" // symbols-view:toggle-project-symbols - } + "ctrl-r": "project_symbols::Toggle", // symbols-view:toggle-project-symbols + }, }, { "context": "Pane", @@ -65,8 +65,8 @@ "ctrl-6": ["pane::ActivateItem", 5], // tree-view:open-selected-entry-in-pane-6 "ctrl-7": ["pane::ActivateItem", 6], // tree-view:open-selected-entry-in-pane-7 "ctrl-8": ["pane::ActivateItem", 7], // tree-view:open-selected-entry-in-pane-8 - "ctrl-9": ["pane::ActivateItem", 8] // tree-view:open-selected-entry-in-pane-9 - } + "ctrl-9": ["pane::ActivateItem", 8], // tree-view:open-selected-entry-in-pane-9 + }, }, { "context": "ProjectPanel", @@ -75,8 +75,8 @@ "backspace": ["project_panel::Trash", { "skip_prompt": false }], "ctrl-x": "project_panel::Cut", // tree-view:cut "ctrl-c": "project_panel::Copy", // tree-view:copy - "ctrl-v": "project_panel::Paste" // tree-view:paste - } + "ctrl-v": "project_panel::Paste", // tree-view:paste + }, }, { "context": "ProjectPanel && not_editing", @@ -90,7 +90,7 @@ "d": "project_panel::Duplicate", // tree-view:duplicate "home": "menu::SelectFirst", // core:move-to-top "end": "menu::SelectLast", // core:move-to-bottom - "shift-a": "project_panel::NewDirectory" // tree-view:add-folder - } - } + "shift-a": "project_panel::NewDirectory", // tree-view:add-folder + }, + }, ] diff --git a/assets/keymaps/linux/cursor.json b/assets/keymaps/linux/cursor.json index 4d2d13a90d96c31f72b1bb0ccc74608f81004eda..58a7309cf902a3f69f949830cace2200f41fb0fe 100644 --- a/assets/keymaps/linux/cursor.json +++ b/assets/keymaps/linux/cursor.json @@ -8,8 +8,8 @@ "ctrl-shift-i": "agent::ToggleFocus", "ctrl-l": "agent::ToggleFocus", "ctrl-shift-l": "agent::ToggleFocus", - "ctrl-shift-j": "agent::OpenSettings" - } + "ctrl-shift-j": "agent::OpenSettings", + }, }, { "context": "Editor && mode == full", @@ -20,18 +20,18 @@ "ctrl-shift-l": "agent::AddSelectionToThread", // In cursor uses "Ask" mode "ctrl-l": "agent::AddSelectionToThread", // In cursor uses "Agent" mode "ctrl-k": "assistant::InlineAssist", - "ctrl-shift-k": "assistant::InsertIntoEditor" - } + "ctrl-shift-k": "assistant::InsertIntoEditor", + }, }, { "context": "InlineAssistEditor", "use_key_equivalents": true, "bindings": { - "ctrl-shift-backspace": "editor::Cancel" + "ctrl-shift-backspace": "editor::Cancel", // "alt-enter": // Quick Question // "ctrl-shift-enter": // Full File Context // "ctrl-shift-k": // Toggle input focus (editor <> inline assist) - } + }, }, { "context": "AgentPanel || ContextEditor || (MessageEditor > Editor)", @@ -47,7 +47,7 @@ "ctrl-shift-backspace": "editor::Cancel", "ctrl-r": "agent::NewThread", "ctrl-shift-v": "editor::Paste", - "ctrl-shift-k": "assistant::InsertIntoEditor" + "ctrl-shift-k": "assistant::InsertIntoEditor", // "escape": "agent::ToggleFocus" ///// Enable when Zed supports multiple thread tabs // "ctrl-t": // new thread tab @@ -56,28 +56,29 @@ ///// Enable if Zed adds support for keyboard navigation of thread elements // "tab": // cycle to next message // "shift-tab": // cycle to previous message - } + }, }, { "context": "Editor && editor_agent_diff", "use_key_equivalents": true, "bindings": { "ctrl-enter": "agent::KeepAll", - "ctrl-backspace": "agent::RejectAll" - } + "ctrl-backspace": "agent::RejectAll", + }, }, { "context": "Editor && mode == full && edit_prediction", "use_key_equivalents": true, "bindings": { - "ctrl-right": "editor::AcceptPartialEditPrediction" - } + "ctrl-right": "editor::AcceptNextWordEditPrediction", + "ctrl-down": "editor::AcceptNextLineEditPrediction", + }, }, { "context": "Terminal", "use_key_equivalents": true, "bindings": { - "ctrl-k": "assistant::InlineAssist" - } - } + "ctrl-k": "assistant::InlineAssist", + }, + }, ] diff --git a/assets/keymaps/linux/emacs.json b/assets/keymaps/linux/emacs.json index c5cf22c81220bf286187252394f8fde26bdd6509..5b6f841de07ac2f9bd45c73e032dea0ede409007 100755 --- a/assets/keymaps/linux/emacs.json +++ b/assets/keymaps/linux/emacs.json @@ -5,8 +5,8 @@ [ { "bindings": { - "ctrl-g": "menu::Cancel" - } + "ctrl-g": "menu::Cancel", + }, }, { // Workaround to avoid falling back to default bindings. @@ -18,8 +18,8 @@ "ctrl-g": null, // currently activates `go_to_line::Toggle` when there is nothing to cancel "ctrl-x": null, // currently activates `editor::Cut` if no following key is pressed for 1 second "ctrl-p": null, // currently activates `file_finder::Toggle` when the cursor is on the first character of the buffer - "ctrl-n": null // currently activates `workspace::NewFile` when the cursor is on the last character of the buffer - } + "ctrl-n": null, // currently activates `workspace::NewFile` when the cursor is on the last character of the buffer + }, }, { "context": "Editor", @@ -82,8 +82,8 @@ "ctrl-s": "buffer_search::Deploy", // isearch-forward "ctrl-r": "buffer_search::Deploy", // isearch-backward "alt-^": "editor::JoinLines", // join-line - "alt-q": "editor::Rewrap" // fill-paragraph - } + "alt-q": "editor::Rewrap", // fill-paragraph + }, }, { "context": "Editor && selection_mode", // region selection @@ -119,22 +119,22 @@ "alt->": "editor::SelectToEnd", "ctrl-home": "editor::SelectToBeginning", "ctrl-end": "editor::SelectToEnd", - "ctrl-g": "editor::Cancel" - } + "ctrl-g": "editor::Cancel", + }, }, { "context": "Editor && (showing_code_actions || showing_completions)", "bindings": { "ctrl-p": "editor::ContextMenuPrevious", - "ctrl-n": "editor::ContextMenuNext" - } + "ctrl-n": "editor::ContextMenuNext", + }, }, { "context": "Editor && showing_signature_help && !showing_completions", "bindings": { "ctrl-p": "editor::SignatureHelpPrevious", - "ctrl-n": "editor::SignatureHelpNext" - } + "ctrl-n": "editor::SignatureHelpNext", + }, }, // Example setting for using emacs-style tab // (i.e. indent the current line / selection or perform symbol completion depending on context) @@ -164,8 +164,8 @@ "ctrl-x ctrl-f": "file_finder::Toggle", // find-file "ctrl-x ctrl-s": "workspace::Save", // save-buffer "ctrl-x ctrl-w": "workspace::SaveAs", // write-file - "ctrl-x s": "workspace::SaveAll" // save-some-buffers - } + "ctrl-x s": "workspace::SaveAll", // save-some-buffers + }, }, { // Workaround to enable using native emacs from the Zed terminal. @@ -185,22 +185,22 @@ "ctrl-x ctrl-f": null, // find-file "ctrl-x ctrl-s": null, // save-buffer "ctrl-x ctrl-w": null, // write-file - "ctrl-x s": null // save-some-buffers - } + "ctrl-x s": null, // save-some-buffers + }, }, { "context": "BufferSearchBar > Editor", "bindings": { "ctrl-s": "search::SelectNextMatch", "ctrl-r": "search::SelectPreviousMatch", - "ctrl-g": "buffer_search::Dismiss" - } + "ctrl-g": "buffer_search::Dismiss", + }, }, { "context": "Pane", "bindings": { "ctrl-alt-left": "pane::GoBack", - "ctrl-alt-right": "pane::GoForward" - } - } + "ctrl-alt-right": "pane::GoForward", + }, + }, ] diff --git a/assets/keymaps/linux/jetbrains.json b/assets/keymaps/linux/jetbrains.json index a0314c5bc1dd59c17f3f132db804891ef1df0d4e..d3bf53a0d3694943252e0fccb2ac821cc6c2a6d3 100644 --- a/assets/keymaps/linux/jetbrains.json +++ b/assets/keymaps/linux/jetbrains.json @@ -13,8 +13,8 @@ "shift-f8": "debugger::StepOut", "f9": "debugger::Continue", "shift-f9": "debugger::Start", - "alt-shift-f9": "debugger::Start" - } + "alt-shift-f9": "debugger::Start", + }, }, { "context": "Editor", @@ -62,28 +62,30 @@ "ctrl-shift-end": "editor::SelectToEnd", "ctrl-f8": "editor::ToggleBreakpoint", "ctrl-shift-f8": "editor::EditLogBreakpoint", - "ctrl-shift-u": "editor::ToggleCase" - } + "ctrl-shift-u": "editor::ToggleCase", + }, }, { "context": "Editor && mode == full", "bindings": { "ctrl-f12": "outline::Toggle", "ctrl-r": ["buffer_search::Deploy", { "replace_enabled": true }], + "ctrl-e": "file_finder::Toggle", "ctrl-shift-n": "file_finder::Toggle", + "ctrl-alt-n": "file_finder::Toggle", "ctrl-g": "go_to_line::Toggle", "alt-enter": "editor::ToggleCodeActions", "ctrl-space": "editor::ShowCompletions", "ctrl-q": "editor::Hover", "ctrl-p": "editor::ShowSignatureHelp", - "ctrl-\\": "assistant::InlineAssist" - } + "ctrl-\\": "assistant::InlineAssist", + }, }, { "context": "BufferSearchBar", "bindings": { - "shift-enter": "search::SelectPreviousMatch" - } + "shift-enter": "search::SelectPreviousMatch", + }, }, { "context": "BufferSearchBar || ProjectSearchBar", @@ -91,8 +93,8 @@ "alt-c": "search::ToggleCaseSensitive", "alt-e": "search::ToggleSelection", "alt-x": "search::ToggleRegex", - "alt-w": "search::ToggleWholeWord" - } + "alt-w": "search::ToggleWholeWord", + }, }, { "context": "Workspace", @@ -105,8 +107,8 @@ "ctrl-e": "file_finder::Toggle", "ctrl-k": "git_panel::ToggleFocus", // bug: This should also focus commit editor "ctrl-shift-n": "file_finder::Toggle", - "ctrl-n": "project_symbols::Toggle", "ctrl-alt-n": "file_finder::Toggle", + "ctrl-n": "project_symbols::Toggle", "ctrl-shift-a": "command_palette::Toggle", "shift shift": "command_palette::Toggle", "ctrl-alt-shift-n": "project_symbols::Toggle", @@ -114,8 +116,8 @@ "alt-1": "project_panel::ToggleFocus", "alt-5": "debug_panel::ToggleFocus", "alt-6": "diagnostics::Deploy", - "alt-7": "outline_panel::ToggleFocus" - } + "alt-7": "outline_panel::ToggleFocus", + }, }, { "context": "Pane", // this is to override the default Pane mappings to switch tabs @@ -129,15 +131,15 @@ "alt-7": "outline_panel::ToggleFocus", "alt-8": null, // Services (bottom dock) "alt-9": null, // Git History (bottom dock) - "alt-0": "git_panel::ToggleFocus" - } + "alt-0": "git_panel::ToggleFocus", + }, }, { "context": "Workspace || Editor", "bindings": { "alt-f12": "terminal_panel::Toggle", - "ctrl-shift-k": "git::Push" - } + "ctrl-shift-k": "git::Push", + }, }, { "context": "Pane", @@ -145,8 +147,8 @@ "ctrl-alt-left": "pane::GoBack", "ctrl-alt-right": "pane::GoForward", "alt-left": "pane::ActivatePreviousItem", - "alt-right": "pane::ActivateNextItem" - } + "alt-right": "pane::ActivateNextItem", + }, }, { "context": "ProjectPanel", @@ -156,8 +158,8 @@ "backspace": ["project_panel::Trash", { "skip_prompt": false }], "delete": ["project_panel::Trash", { "skip_prompt": false }], "shift-delete": ["project_panel::Delete", { "skip_prompt": false }], - "shift-f6": "project_panel::Rename" - } + "shift-f6": "project_panel::Rename", + }, }, { "context": "Terminal", @@ -167,8 +169,8 @@ "ctrl-up": "terminal::ScrollLineUp", "ctrl-down": "terminal::ScrollLineDown", "shift-pageup": "terminal::ScrollPageUp", - "shift-pagedown": "terminal::ScrollPageDown" - } + "shift-pagedown": "terminal::ScrollPageDown", + }, }, { "context": "GitPanel", "bindings": { "alt-0": "workspace::CloseActiveDock" } }, { "context": "ProjectPanel", "bindings": { "alt-1": "workspace::CloseActiveDock" } }, @@ -179,7 +181,7 @@ "context": "Dock || Workspace || OutlinePanel || ProjectPanel || CollabPanel || (Editor && mode == auto_height)", "bindings": { "escape": "editor::ToggleFocus", - "shift-escape": "workspace::CloseActiveDock" - } - } + "shift-escape": "workspace::CloseActiveDock", + }, + }, ] diff --git a/assets/keymaps/linux/sublime_text.json b/assets/keymaps/linux/sublime_text.json index eefd59e5bd1aa48125d0c6e3d662f3cb4e270be7..1d689a6f5841a011768113257afbed2c447669ed 100644 --- a/assets/keymaps/linux/sublime_text.json +++ b/assets/keymaps/linux/sublime_text.json @@ -22,8 +22,8 @@ "ctrl-^": ["workspace::MoveItemToPane", { "destination": 5 }], "ctrl-&": ["workspace::MoveItemToPane", { "destination": 6 }], "ctrl-*": ["workspace::MoveItemToPane", { "destination": 7 }], - "ctrl-(": ["workspace::MoveItemToPane", { "destination": 8 }] - } + "ctrl-(": ["workspace::MoveItemToPane", { "destination": 8 }], + }, }, { "context": "Editor", @@ -55,20 +55,20 @@ "alt-right": "editor::MoveToNextSubwordEnd", "alt-left": "editor::MoveToPreviousSubwordStart", "alt-shift-right": "editor::SelectToNextSubwordEnd", - "alt-shift-left": "editor::SelectToPreviousSubwordStart" - } + "alt-shift-left": "editor::SelectToPreviousSubwordStart", + }, }, { "context": "Editor && mode == full", "bindings": { - "ctrl-r": "outline::Toggle" - } + "ctrl-r": "outline::Toggle", + }, }, { "context": "Editor && !agent_diff", "bindings": { - "ctrl-k ctrl-z": "git::Restore" - } + "ctrl-k ctrl-z": "git::Restore", + }, }, { "context": "Pane", @@ -83,15 +83,15 @@ "alt-6": ["pane::ActivateItem", 5], "alt-7": ["pane::ActivateItem", 6], "alt-8": ["pane::ActivateItem", 7], - "alt-9": "pane::ActivateLastItem" - } + "alt-9": "pane::ActivateLastItem", + }, }, { "context": "Workspace", "bindings": { "ctrl-k ctrl-b": "workspace::ToggleLeftDock", // "ctrl-0": "project_panel::ToggleFocus", // normally resets zoom - "shift-ctrl-r": "project_symbols::Toggle" - } - } + "shift-ctrl-r": "project_symbols::Toggle", + }, + }, ] diff --git a/assets/keymaps/macos/atom.json b/assets/keymaps/macos/atom.json index ca015b667faa05db53d8fdc3bd82352d9bcc62aa..bf049fd3cb3eca8fe8049fa4e0810f82b10a5bbc 100644 --- a/assets/keymaps/macos/atom.json +++ b/assets/keymaps/macos/atom.json @@ -4,16 +4,16 @@ "bindings": { "ctrl-alt-cmd-l": "workspace::Reload", "cmd-k cmd-p": "workspace::ActivatePreviousPane", - "cmd-k cmd-n": "workspace::ActivateNextPane" - } + "cmd-k cmd-n": "workspace::ActivateNextPane", + }, }, { "context": "Editor", "bindings": { "cmd-shift-backspace": "editor::DeleteToBeginningOfLine", "cmd-k cmd-u": "editor::ConvertToUpperCase", - "cmd-k cmd-l": "editor::ConvertToLowerCase" - } + "cmd-k cmd-l": "editor::ConvertToLowerCase", + }, }, { "context": "Editor && mode == full", @@ -33,8 +33,8 @@ "ctrl-cmd-down": "editor::MoveLineDown", "cmd-\\": "workspace::ToggleLeftDock", "ctrl-shift-m": "markdown::OpenPreviewToTheSide", - "cmd-r": "outline::Toggle" - } + "cmd-r": "outline::Toggle", + }, }, { "context": "BufferSearchBar", @@ -42,8 +42,8 @@ "cmd-g": ["editor::SelectNext", { "replace_newest": true }], "cmd-shift-g": ["editor::SelectPrevious", { "replace_newest": true }], "cmd-f3": "search::SelectNextMatch", - "cmd-shift-f3": "search::SelectPreviousMatch" - } + "cmd-shift-f3": "search::SelectPreviousMatch", + }, }, { "context": "Workspace", @@ -51,8 +51,8 @@ "cmd-\\": "workspace::ToggleLeftDock", "cmd-k cmd-b": "workspace::ToggleLeftDock", "cmd-t": "file_finder::Toggle", - "cmd-shift-r": "project_symbols::Toggle" - } + "cmd-shift-r": "project_symbols::Toggle", + }, }, { "context": "Pane", @@ -67,8 +67,8 @@ "cmd-6": ["pane::ActivateItem", 5], "cmd-7": ["pane::ActivateItem", 6], "cmd-8": ["pane::ActivateItem", 7], - "cmd-9": "pane::ActivateLastItem" - } + "cmd-9": "pane::ActivateLastItem", + }, }, { "context": "ProjectPanel", @@ -77,8 +77,8 @@ "backspace": ["project_panel::Trash", { "skip_prompt": false }], "cmd-x": "project_panel::Cut", "cmd-c": "project_panel::Copy", - "cmd-v": "project_panel::Paste" - } + "cmd-v": "project_panel::Paste", + }, }, { "context": "ProjectPanel && not_editing", @@ -92,7 +92,7 @@ "d": "project_panel::Duplicate", "home": "menu::SelectFirst", "end": "menu::SelectLast", - "shift-a": "project_panel::NewDirectory" - } - } + "shift-a": "project_panel::NewDirectory", + }, + }, ] diff --git a/assets/keymaps/macos/cursor.json b/assets/keymaps/macos/cursor.json index 97abc7dd819485850107eca6762fc1ed60ec0515..93e259db37ac718d2e0258d83e4de436a0a378fd 100644 --- a/assets/keymaps/macos/cursor.json +++ b/assets/keymaps/macos/cursor.json @@ -8,8 +8,8 @@ "cmd-shift-i": "agent::ToggleFocus", "cmd-l": "agent::ToggleFocus", "cmd-shift-l": "agent::ToggleFocus", - "cmd-shift-j": "agent::OpenSettings" - } + "cmd-shift-j": "agent::OpenSettings", + }, }, { "context": "Editor && mode == full", @@ -20,19 +20,19 @@ "cmd-shift-l": "agent::AddSelectionToThread", // In cursor uses "Ask" mode "cmd-l": "agent::AddSelectionToThread", // In cursor uses "Agent" mode "cmd-k": "assistant::InlineAssist", - "cmd-shift-k": "assistant::InsertIntoEditor" - } + "cmd-shift-k": "assistant::InsertIntoEditor", + }, }, { "context": "InlineAssistEditor", "use_key_equivalents": true, "bindings": { "cmd-shift-backspace": "editor::Cancel", - "cmd-enter": "menu::Confirm" + "cmd-enter": "menu::Confirm", // "alt-enter": // Quick Question // "cmd-shift-enter": // Full File Context // "cmd-shift-k": // Toggle input focus (editor <> inline assist) - } + }, }, { "context": "AgentPanel || ContextEditor || (MessageEditor > Editor)", @@ -48,7 +48,7 @@ "cmd-shift-backspace": "editor::Cancel", "cmd-r": "agent::NewThread", "cmd-shift-v": "editor::Paste", - "cmd-shift-k": "assistant::InsertIntoEditor" + "cmd-shift-k": "assistant::InsertIntoEditor", // "escape": "agent::ToggleFocus" ///// Enable when Zed supports multiple thread tabs // "cmd-t": // new thread tab @@ -57,28 +57,29 @@ ///// Enable if Zed adds support for keyboard navigation of thread elements // "tab": // cycle to next message // "shift-tab": // cycle to previous message - } + }, }, { "context": "Editor && editor_agent_diff", "use_key_equivalents": true, "bindings": { "cmd-enter": "agent::KeepAll", - "cmd-backspace": "agent::RejectAll" - } + "cmd-backspace": "agent::RejectAll", + }, }, { "context": "Editor && mode == full && edit_prediction", "use_key_equivalents": true, "bindings": { - "cmd-right": "editor::AcceptPartialEditPrediction" - } + "cmd-right": "editor::AcceptNextWordEditPrediction", + "cmd-down": "editor::AcceptNextLineEditPrediction", + }, }, { "context": "Terminal", "use_key_equivalents": true, "bindings": { - "cmd-k": "assistant::InlineAssist" - } - } + "cmd-k": "assistant::InlineAssist", + }, + }, ] diff --git a/assets/keymaps/macos/emacs.json b/assets/keymaps/macos/emacs.json index ea831c0c059ea082d002f3af01b8d97be9e86616..2f11e2ce00e8b60a0f1c85b5aeb204e866491a45 100755 --- a/assets/keymaps/macos/emacs.json +++ b/assets/keymaps/macos/emacs.json @@ -6,8 +6,8 @@ { "context": "!GitPanel", "bindings": { - "ctrl-g": "menu::Cancel" - } + "ctrl-g": "menu::Cancel", + }, }, { // Workaround to avoid falling back to default bindings. @@ -15,8 +15,8 @@ // NOTE: must be declared before the `Editor` override. "context": "Editor", "bindings": { - "ctrl-g": null // currently activates `go_to_line::Toggle` when there is nothing to cancel - } + "ctrl-g": null, // currently activates `go_to_line::Toggle` when there is nothing to cancel + }, }, { "context": "Editor", @@ -79,8 +79,8 @@ "ctrl-s": "buffer_search::Deploy", // isearch-forward "ctrl-r": "buffer_search::Deploy", // isearch-backward "alt-^": "editor::JoinLines", // join-line - "alt-q": "editor::Rewrap" // fill-paragraph - } + "alt-q": "editor::Rewrap", // fill-paragraph + }, }, { "context": "Editor && selection_mode", // region selection @@ -116,22 +116,22 @@ "alt->": "editor::SelectToEnd", "ctrl-home": "editor::SelectToBeginning", "ctrl-end": "editor::SelectToEnd", - "ctrl-g": "editor::Cancel" - } + "ctrl-g": "editor::Cancel", + }, }, { "context": "Editor && (showing_code_actions || showing_completions)", "bindings": { "ctrl-p": "editor::ContextMenuPrevious", - "ctrl-n": "editor::ContextMenuNext" - } + "ctrl-n": "editor::ContextMenuNext", + }, }, { "context": "Editor && showing_signature_help && !showing_completions", "bindings": { "ctrl-p": "editor::SignatureHelpPrevious", - "ctrl-n": "editor::SignatureHelpNext" - } + "ctrl-n": "editor::SignatureHelpNext", + }, }, // Example setting for using emacs-style tab // (i.e. indent the current line / selection or perform symbol completion depending on context) @@ -161,8 +161,8 @@ "ctrl-x ctrl-f": "file_finder::Toggle", // find-file "ctrl-x ctrl-s": "workspace::Save", // save-buffer "ctrl-x ctrl-w": "workspace::SaveAs", // write-file - "ctrl-x s": "workspace::SaveAll" // save-some-buffers - } + "ctrl-x s": "workspace::SaveAll", // save-some-buffers + }, }, { // Workaround to enable using native emacs from the Zed terminal. @@ -182,22 +182,22 @@ "ctrl-x ctrl-f": null, // find-file "ctrl-x ctrl-s": null, // save-buffer "ctrl-x ctrl-w": null, // write-file - "ctrl-x s": null // save-some-buffers - } + "ctrl-x s": null, // save-some-buffers + }, }, { "context": "BufferSearchBar > Editor", "bindings": { "ctrl-s": "search::SelectNextMatch", "ctrl-r": "search::SelectPreviousMatch", - "ctrl-g": "buffer_search::Dismiss" - } + "ctrl-g": "buffer_search::Dismiss", + }, }, { "context": "Pane", "bindings": { "ctrl-alt-left": "pane::GoBack", - "ctrl-alt-right": "pane::GoForward" - } - } + "ctrl-alt-right": "pane::GoForward", + }, + }, ] diff --git a/assets/keymaps/macos/jetbrains.json b/assets/keymaps/macos/jetbrains.json index 364f489167f5abb9af21d9f005586bde08439850..9946d8b124957349181db659259174d906d08d3a 100644 --- a/assets/keymaps/macos/jetbrains.json +++ b/assets/keymaps/macos/jetbrains.json @@ -13,8 +13,8 @@ "shift-f8": "debugger::StepOut", "f9": "debugger::Continue", "shift-f9": "debugger::Start", - "alt-shift-f9": "debugger::Start" - } + "alt-shift-f9": "debugger::Start", + }, }, { "context": "Editor", @@ -60,28 +60,30 @@ "cmd-shift-end": "editor::SelectToEnd", "ctrl-f8": "editor::ToggleBreakpoint", "ctrl-shift-f8": "editor::EditLogBreakpoint", - "cmd-shift-u": "editor::ToggleCase" - } + "cmd-shift-u": "editor::ToggleCase", + }, }, { "context": "Editor && mode == full", "bindings": { "cmd-f12": "outline::Toggle", "cmd-r": ["buffer_search::Deploy", { "replace_enabled": true }], - "cmd-shift-o": "file_finder::Toggle", "cmd-l": "go_to_line::Toggle", + "cmd-e": "file_finder::Toggle", + "cmd-shift-o": "file_finder::Toggle", + "cmd-shift-n": "file_finder::Toggle", "alt-enter": "editor::ToggleCodeActions", "ctrl-space": "editor::ShowCompletions", "cmd-j": "editor::Hover", "cmd-p": "editor::ShowSignatureHelp", - "cmd-\\": "assistant::InlineAssist" - } + "cmd-\\": "assistant::InlineAssist", + }, }, { "context": "BufferSearchBar", "bindings": { - "shift-enter": "search::SelectPreviousMatch" - } + "shift-enter": "search::SelectPreviousMatch", + }, }, { "context": "BufferSearchBar || ProjectSearchBar", @@ -93,8 +95,8 @@ "ctrl-alt-c": "search::ToggleCaseSensitive", "ctrl-alt-e": "search::ToggleSelection", "ctrl-alt-w": "search::ToggleWholeWord", - "ctrl-alt-x": "search::ToggleRegex" - } + "ctrl-alt-x": "search::ToggleRegex", + }, }, { "context": "Workspace", @@ -116,8 +118,8 @@ "cmd-1": "project_panel::ToggleFocus", "cmd-5": "debug_panel::ToggleFocus", "cmd-6": "diagnostics::Deploy", - "cmd-7": "outline_panel::ToggleFocus" - } + "cmd-7": "outline_panel::ToggleFocus", + }, }, { "context": "Pane", // this is to override the default Pane mappings to switch tabs @@ -131,15 +133,15 @@ "cmd-7": "outline_panel::ToggleFocus", "cmd-8": null, // Services (bottom dock) "cmd-9": null, // Git History (bottom dock) - "cmd-0": "git_panel::ToggleFocus" - } + "cmd-0": "git_panel::ToggleFocus", + }, }, { "context": "Workspace || Editor", "bindings": { "alt-f12": "terminal_panel::Toggle", - "cmd-shift-k": "git::Push" - } + "cmd-shift-k": "git::Push", + }, }, { "context": "Pane", @@ -147,8 +149,8 @@ "cmd-alt-left": "pane::GoBack", "cmd-alt-right": "pane::GoForward", "alt-left": "pane::ActivatePreviousItem", - "alt-right": "pane::ActivateNextItem" - } + "alt-right": "pane::ActivateNextItem", + }, }, { "context": "ProjectPanel", @@ -159,8 +161,8 @@ "backspace": ["project_panel::Trash", { "skip_prompt": false }], "delete": ["project_panel::Trash", { "skip_prompt": false }], "shift-delete": ["project_panel::Delete", { "skip_prompt": false }], - "shift-f6": "project_panel::Rename" - } + "shift-f6": "project_panel::Rename", + }, }, { "context": "Terminal", @@ -170,8 +172,8 @@ "cmd-up": "terminal::ScrollLineUp", "cmd-down": "terminal::ScrollLineDown", "shift-pageup": "terminal::ScrollPageUp", - "shift-pagedown": "terminal::ScrollPageDown" - } + "shift-pagedown": "terminal::ScrollPageDown", + }, }, { "context": "GitPanel", "bindings": { "cmd-0": "workspace::CloseActiveDock" } }, { "context": "ProjectPanel", "bindings": { "cmd-1": "workspace::CloseActiveDock" } }, @@ -182,7 +184,7 @@ "context": "Dock || Workspace || OutlinePanel || ProjectPanel || CollabPanel || (Editor && mode == auto_height)", "bindings": { "escape": "editor::ToggleFocus", - "shift-escape": "workspace::CloseActiveDock" - } - } + "shift-escape": "workspace::CloseActiveDock", + }, + }, ] diff --git a/assets/keymaps/macos/sublime_text.json b/assets/keymaps/macos/sublime_text.json index d1bffca755b611d9046d4b7e794d2303835227a2..f4ae1ce5dda4e2c0dd21e97bd3a411dd4a4f3663 100644 --- a/assets/keymaps/macos/sublime_text.json +++ b/assets/keymaps/macos/sublime_text.json @@ -22,8 +22,8 @@ "ctrl-^": ["workspace::MoveItemToPane", { "destination": 5 }], "ctrl-&": ["workspace::MoveItemToPane", { "destination": 6 }], "ctrl-*": ["workspace::MoveItemToPane", { "destination": 7 }], - "ctrl-(": ["workspace::MoveItemToPane", { "destination": 8 }] - } + "ctrl-(": ["workspace::MoveItemToPane", { "destination": 8 }], + }, }, { "context": "Editor", @@ -57,20 +57,20 @@ "ctrl-right": "editor::MoveToNextSubwordEnd", "ctrl-left": "editor::MoveToPreviousSubwordStart", "ctrl-shift-right": "editor::SelectToNextSubwordEnd", - "ctrl-shift-left": "editor::SelectToPreviousSubwordStart" - } + "ctrl-shift-left": "editor::SelectToPreviousSubwordStart", + }, }, { "context": "Editor && mode == full", "bindings": { - "cmd-r": "outline::Toggle" - } + "cmd-r": "outline::Toggle", + }, }, { "context": "Editor && !agent_diff", "bindings": { - "cmd-k cmd-z": "git::Restore" - } + "cmd-k cmd-z": "git::Restore", + }, }, { "context": "Pane", @@ -85,8 +85,8 @@ "cmd-6": ["pane::ActivateItem", 5], "cmd-7": ["pane::ActivateItem", 6], "cmd-8": ["pane::ActivateItem", 7], - "cmd-9": "pane::ActivateLastItem" - } + "cmd-9": "pane::ActivateLastItem", + }, }, { "context": "Workspace", @@ -95,7 +95,7 @@ "cmd-t": "file_finder::Toggle", "shift-cmd-r": "project_symbols::Toggle", // Currently busted: https://github.com/zed-industries/feedback/issues/898 - "ctrl-0": "project_panel::ToggleFocus" - } - } + "ctrl-0": "project_panel::ToggleFocus", + }, + }, ] diff --git a/assets/keymaps/macos/textmate.json b/assets/keymaps/macos/textmate.json index f91f39b7f5c079f81b5fcf8e28e2092a33ff1aa4..90450e60af7147f1394eb6cb4c1efc389edad2d0 100644 --- a/assets/keymaps/macos/textmate.json +++ b/assets/keymaps/macos/textmate.json @@ -2,8 +2,8 @@ { "bindings": { "cmd-shift-o": "projects::OpenRecent", - "cmd-alt-tab": "project_panel::ToggleFocus" - } + "cmd-alt-tab": "project_panel::ToggleFocus", + }, }, { "context": "Editor && mode == full", @@ -15,8 +15,8 @@ "cmd-enter": "editor::NewlineBelow", "cmd-alt-enter": "editor::NewlineAbove", "cmd-shift-l": "editor::SelectLine", - "cmd-shift-t": "outline::Toggle" - } + "cmd-shift-t": "outline::Toggle", + }, }, { "context": "Editor", @@ -41,30 +41,30 @@ "ctrl-u": "editor::ConvertToUpperCase", "ctrl-shift-u": "editor::ConvertToLowerCase", "ctrl-alt-u": "editor::ConvertToUpperCamelCase", - "ctrl-_": "editor::ConvertToSnakeCase" - } + "ctrl-_": "editor::ConvertToSnakeCase", + }, }, { "context": "BufferSearchBar", "bindings": { "ctrl-s": "search::SelectNextMatch", - "ctrl-shift-s": "search::SelectPreviousMatch" - } + "ctrl-shift-s": "search::SelectPreviousMatch", + }, }, { "context": "Workspace", "bindings": { "cmd-alt-ctrl-d": "workspace::ToggleLeftDock", "cmd-t": "file_finder::Toggle", - "cmd-shift-t": "project_symbols::Toggle" - } + "cmd-shift-t": "project_symbols::Toggle", + }, }, { "context": "Pane", "bindings": { "alt-cmd-r": "search::ToggleRegex", - "ctrl-tab": "project_panel::ToggleFocus" - } + "ctrl-tab": "project_panel::ToggleFocus", + }, }, { "context": "ProjectPanel", @@ -75,11 +75,11 @@ "return": "project_panel::Rename", "cmd-c": "project_panel::Copy", "cmd-v": "project_panel::Paste", - "cmd-alt-c": "project_panel::CopyPath" - } + "cmd-alt-c": "project_panel::CopyPath", + }, }, { "context": "Dock", - "bindings": {} - } + "bindings": {}, + }, ] diff --git a/assets/keymaps/storybook.json b/assets/keymaps/storybook.json index 9b92fbe1a3844043e379647d1dd6c57e082fdf77..432bdc7004a4c66b52e20282aba924611b204aa1 100644 --- a/assets/keymaps/storybook.json +++ b/assets/keymaps/storybook.json @@ -27,7 +27,7 @@ "backspace": "editor::Backspace", "delete": "editor::Delete", "left": "editor::MoveLeft", - "right": "editor::MoveRight" - } - } + "right": "editor::MoveRight", + }, + }, ] diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 5ff1dc196a82d0c3226253c4b8d892058598b4e3..6e5d3423872a7dd83234b28e67c5082b36bd858f 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -180,10 +180,9 @@ "ctrl-w g shift-d": "editor::GoToTypeDefinitionSplit", "ctrl-w space": "editor::OpenExcerptsSplit", "ctrl-w g space": "editor::OpenExcerptsSplit", - "ctrl-6": "pane::AlternateFile", "ctrl-^": "pane::AlternateFile", - ".": "vim::Repeat" - } + ".": "vim::Repeat", + }, }, { "context": "vim_mode == normal || vim_mode == visual || vim_mode == operator", @@ -224,8 +223,8 @@ "] r": "vim::GoToNextReference", // tree-sitter related commands "[ x": "vim::SelectLargerSyntaxNode", - "] x": "vim::SelectSmallerSyntaxNode" - } + "] x": "vim::SelectSmallerSyntaxNode", + }, }, { "context": "vim_mode == normal", @@ -262,16 +261,16 @@ "[ d": "editor::GoToPreviousDiagnostic", "] c": "editor::GoToHunk", "[ c": "editor::GoToPreviousHunk", - "g c": "vim::PushToggleComments" - } + "g c": "vim::PushToggleComments", + }, }, { "context": "VimControl && VimCount", "bindings": { "0": ["vim::Number", 0], ":": "vim::CountCommand", - "%": "vim::GoToPercentage" - } + "%": "vim::GoToPercentage", + }, }, { "context": "vim_mode == visual", @@ -323,8 +322,8 @@ "g w": "vim::Rewrap", "g ?": "vim::ConvertToRot13", // "g ?": "vim::ConvertToRot47", - "\"": "vim::PushRegister" - } + "\"": "vim::PushRegister", + }, }, { "context": "vim_mode == helix_select", @@ -344,8 +343,8 @@ "ctrl-pageup": "pane::ActivatePreviousItem", "ctrl-pagedown": "pane::ActivateNextItem", ".": "vim::Repeat", - "alt-.": "vim::RepeatFind" - } + "alt-.": "vim::RepeatFind", + }, }, { "context": "vim_mode == insert", @@ -375,8 +374,8 @@ "ctrl-r": "vim::PushRegister", "insert": "vim::ToggleReplace", "ctrl-o": "vim::TemporaryNormal", - "ctrl-s": "editor::ShowSignatureHelp" - } + "ctrl-s": "editor::ShowSignatureHelp", + }, }, { "context": "showing_completions", @@ -384,8 +383,8 @@ "ctrl-d": "vim::ScrollDown", "ctrl-u": "vim::ScrollUp", "ctrl-e": "vim::LineDown", - "ctrl-y": "vim::LineUp" - } + "ctrl-y": "vim::LineUp", + }, }, { "context": "(vim_mode == normal || vim_mode == helix_normal) && !menu", @@ -410,23 +409,31 @@ "shift-s": "vim::SubstituteLine", "\"": "vim::PushRegister", "ctrl-pagedown": "pane::ActivateNextItem", - "ctrl-pageup": "pane::ActivatePreviousItem" - } + "ctrl-pageup": "pane::ActivatePreviousItem", + }, }, { "context": "VimControl && vim_mode == helix_normal && !menu", "bindings": { + "j": ["vim::Down", { "display_lines": true }], + "down": ["vim::Down", { "display_lines": true }], + "k": ["vim::Up", { "display_lines": true }], + "up": ["vim::Up", { "display_lines": true }], + "g j": "vim::Down", + "g down": "vim::Down", + "g k": "vim::Up", + "g up": "vim::Up", "escape": "vim::SwitchToHelixNormalMode", "i": "vim::HelixInsert", "a": "vim::HelixAppend", - "ctrl-[": "editor::Cancel" - } + "ctrl-[": "editor::Cancel", + }, }, { "context": "vim_mode == helix_select && !menu", "bindings": { - "escape": "vim::SwitchToHelixNormalMode" - } + "escape": "vim::SwitchToHelixNormalMode", + }, }, { "context": "(vim_mode == helix_normal || vim_mode == helix_select) && !menu", @@ -446,9 +453,9 @@ "shift-r": "editor::Paste", "`": "vim::ConvertToLowerCase", "alt-`": "vim::ConvertToUpperCase", - "insert": "vim::InsertBefore", + "insert": "vim::InsertBefore", // not a helix default "shift-u": "editor::Redo", - "ctrl-r": "vim::Redo", + "ctrl-r": "vim::Redo", // not a helix default "y": "vim::HelixYank", "p": "vim::HelixPaste", "shift-p": ["vim::HelixPaste", { "before": true }], @@ -477,6 +484,7 @@ "alt-p": "editor::SelectPreviousSyntaxNode", "alt-n": "editor::SelectNextSyntaxNode", + // Search "n": "vim::HelixSelectNext", "shift-n": "vim::HelixSelectPrevious", @@ -484,27 +492,32 @@ "g e": "vim::EndOfDocument", "g h": "vim::StartOfLine", "g l": "vim::EndOfLine", - "g s": "vim::FirstNonWhitespace", // "g s" default behavior is "space s" + "g s": "vim::FirstNonWhitespace", "g t": "vim::WindowTop", "g c": "vim::WindowMiddle", "g b": "vim::WindowBottom", - "g r": "editor::FindAllReferences", // zed specific + "g r": "editor::FindAllReferences", "g n": "pane::ActivateNextItem", - "shift-l": "pane::ActivateNextItem", + "shift-l": "pane::ActivateNextItem", // not a helix default "g p": "pane::ActivatePreviousItem", - "shift-h": "pane::ActivatePreviousItem", - "g .": "vim::HelixGotoLastModification", // go to last modification + "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", + "space w s": "pane::SplitRight", "space w h": "workspace::ActivatePaneLeft", - "space w l": "workspace::ActivatePaneRight", - "space w k": "workspace::ActivatePaneUp", "space w j": "workspace::ActivatePaneDown", + "space w k": "workspace::ActivatePaneUp", + "space w l": "workspace::ActivatePaneRight", "space w q": "pane::CloseActiveItem", - "space w s": "pane::SplitRight", - "space w r": "pane::SplitRight", - "space w v": "pane::SplitDown", - "space w d": "pane::SplitDown", + "space w r": "pane::SplitRight", // not a helix default + "space w d": "pane::SplitDown", // not a helix default // Space mode "space f": "file_finder::Toggle", @@ -518,6 +531,7 @@ "space c": "editor::ToggleComments", "space p": "editor::Paste", "space y": "editor::Copy", + "space /": "pane::DeploySearch", // Other ":": "command_palette::Toggle", @@ -525,24 +539,22 @@ "]": ["vim::PushHelixNext", { "around": true }], "[": ["vim::PushHelixPrevious", { "around": true }], "g q": "vim::PushRewrap", - "g w": "vim::PushRewrap" - // "tab": "pane::ActivateNextItem", - // "shift-tab": "pane::ActivatePrevItem", - } + "g w": "vim::PushRewrap", // not a helix default & clashes with helix `goto_word` + }, }, { "context": "vim_mode == insert && !(showing_code_actions || showing_completions)", "bindings": { "ctrl-p": "editor::ShowWordCompletions", - "ctrl-n": "editor::ShowWordCompletions" - } + "ctrl-n": "editor::ShowWordCompletions", + }, }, { "context": "(vim_mode == insert || vim_mode == normal) && showing_signature_help && !showing_completions", "bindings": { "ctrl-p": "editor::SignatureHelpPrevious", - "ctrl-n": "editor::SignatureHelpNext" - } + "ctrl-n": "editor::SignatureHelpNext", + }, }, { "context": "vim_mode == replace", @@ -558,8 +570,8 @@ "backspace": "vim::UndoReplace", "tab": "vim::Tab", "enter": "vim::Enter", - "insert": "vim::InsertBefore" - } + "insert": "vim::InsertBefore", + }, }, { "context": "vim_mode == waiting", @@ -571,14 +583,14 @@ "escape": "vim::ClearOperators", "ctrl-k": ["vim::PushDigraph", {}], "ctrl-v": ["vim::PushLiteral", {}], - "ctrl-q": ["vim::PushLiteral", {}] - } + "ctrl-q": ["vim::PushLiteral", {}], + }, }, { "context": "Editor && vim_mode == waiting && (vim_operator == ys || vim_operator == cs)", "bindings": { - "escape": "vim::SwitchToNormalMode" - } + "escape": "vim::SwitchToNormalMode", + }, }, { "context": "vim_mode == operator", @@ -586,8 +598,8 @@ "ctrl-c": "vim::ClearOperators", "ctrl-[": "vim::ClearOperators", "escape": "vim::ClearOperators", - "g c": "vim::Comment" - } + "g c": "vim::Comment", + }, }, { "context": "vim_operator == a || vim_operator == i || vim_operator == cs || vim_operator == helix_next || vim_operator == helix_previous", @@ -624,14 +636,14 @@ "shift-i": ["vim::IndentObj", { "include_below": true }], "f": "vim::Method", "c": "vim::Class", - "e": "vim::EntireFile" - } + "e": "vim::EntireFile", + }, }, { "context": "vim_operator == helix_m", "bindings": { - "m": "vim::Matching" - } + "m": "vim::Matching", + }, }, { "context": "vim_operator == helix_next", @@ -648,8 +660,8 @@ "x": "editor::SelectSmallerSyntaxNode", "d": "editor::GoToDiagnostic", "c": "editor::GoToHunk", - "space": "vim::InsertEmptyLineBelow" - } + "space": "vim::InsertEmptyLineBelow", + }, }, { "context": "vim_operator == helix_previous", @@ -666,8 +678,8 @@ "x": "editor::SelectLargerSyntaxNode", "d": "editor::GoToPreviousDiagnostic", "c": "editor::GoToPreviousHunk", - "space": "vim::InsertEmptyLineAbove" - } + "space": "vim::InsertEmptyLineAbove", + }, }, { "context": "vim_operator == c", @@ -675,8 +687,8 @@ "c": "vim::CurrentLine", "x": "vim::Exchange", "d": "editor::Rename", // zed specific - "s": ["vim::PushChangeSurrounds", {}] - } + "s": ["vim::PushChangeSurrounds", {}], + }, }, { "context": "vim_operator == d", @@ -688,36 +700,36 @@ "shift-o": "git::ToggleStaged", "p": "git::Restore", // "d p" "u": "git::StageAndNext", // "d u" - "shift-u": "git::UnstageAndNext" // "d shift-u" - } + "shift-u": "git::UnstageAndNext", // "d shift-u" + }, }, { "context": "vim_operator == gu", "bindings": { "g u": "vim::CurrentLine", - "u": "vim::CurrentLine" - } + "u": "vim::CurrentLine", + }, }, { "context": "vim_operator == gU", "bindings": { "g shift-u": "vim::CurrentLine", - "shift-u": "vim::CurrentLine" - } + "shift-u": "vim::CurrentLine", + }, }, { "context": "vim_operator == g~", "bindings": { "g ~": "vim::CurrentLine", - "~": "vim::CurrentLine" - } + "~": "vim::CurrentLine", + }, }, { "context": "vim_operator == g?", "bindings": { "g ?": "vim::CurrentLine", - "?": "vim::CurrentLine" - } + "?": "vim::CurrentLine", + }, }, { "context": "vim_operator == gq", @@ -725,66 +737,66 @@ "g q": "vim::CurrentLine", "q": "vim::CurrentLine", "g w": "vim::CurrentLine", - "w": "vim::CurrentLine" - } + "w": "vim::CurrentLine", + }, }, { "context": "vim_operator == y", "bindings": { "y": "vim::CurrentLine", "v": "vim::PushForcedMotion", - "s": ["vim::PushAddSurrounds", {}] - } + "s": ["vim::PushAddSurrounds", {}], + }, }, { "context": "vim_operator == ys", "bindings": { - "s": "vim::CurrentLine" - } + "s": "vim::CurrentLine", + }, }, { "context": "vim_operator == >", "bindings": { - ">": "vim::CurrentLine" - } + ">": "vim::CurrentLine", + }, }, { "context": "vim_operator == <", "bindings": { - "<": "vim::CurrentLine" - } + "<": "vim::CurrentLine", + }, }, { "context": "vim_operator == eq", "bindings": { - "=": "vim::CurrentLine" - } + "=": "vim::CurrentLine", + }, }, { "context": "vim_operator == sh", "bindings": { - "!": "vim::CurrentLine" - } + "!": "vim::CurrentLine", + }, }, { "context": "vim_operator == gc", "bindings": { - "c": "vim::CurrentLine" - } + "c": "vim::CurrentLine", + }, }, { "context": "vim_operator == gR", "bindings": { "r": "vim::CurrentLine", - "shift-r": "vim::CurrentLine" - } + "shift-r": "vim::CurrentLine", + }, }, { "context": "vim_operator == cx", "bindings": { "x": "vim::CurrentLine", - "c": "vim::ClearExchange" - } + "c": "vim::ClearExchange", + }, }, { "context": "vim_mode == literal", @@ -826,15 +838,15 @@ "tab": ["vim::Literal", ["tab", "\u0009"]], // zed extensions: "backspace": ["vim::Literal", ["backspace", "\u0008"]], - "delete": ["vim::Literal", ["delete", "\u007F"]] - } + "delete": ["vim::Literal", ["delete", "\u007F"]], + }, }, { "context": "BufferSearchBar && !in_replace", "bindings": { "enter": "vim::SearchSubmit", - "escape": "buffer_search::Dismiss" - } + "escape": "buffer_search::Dismiss", + }, }, { "context": "VimControl && !menu || !Editor && !Terminal", @@ -895,15 +907,19 @@ "ctrl-w ctrl-n": "workspace::NewFileSplitHorizontal", "ctrl-w n": "workspace::NewFileSplitHorizontal", "g t": "vim::GoToTab", - "g shift-t": "vim::GoToPreviousTab" - } + "g shift-t": "vim::GoToPreviousTab", + }, }, { "context": "!Editor && !Terminal", "bindings": { ":": "command_palette::Toggle", - "g /": "pane::DeploySearch" - } + "g /": "pane::DeploySearch", + "] b": "pane::ActivateNextItem", + "[ b": "pane::ActivatePreviousItem", + "] shift-b": "pane::ActivateLastItem", + "[ shift-b": ["pane::ActivateItem", 0], + }, }, { // netrw compatibility @@ -953,17 +969,45 @@ "6": ["vim::Number", 6], "7": ["vim::Number", 7], "8": ["vim::Number", 8], - "9": ["vim::Number", 9] - } + "9": ["vim::Number", 9], + }, }, { "context": "OutlinePanel && not_editing", "bindings": { - "j": "menu::SelectNext", - "k": "menu::SelectPrevious", + "h": "outline_panel::CollapseSelectedEntry", + "j": "vim::MenuSelectNext", + "k": "vim::MenuSelectPrevious", + "down": "vim::MenuSelectNext", + "up": "vim::MenuSelectPrevious", + "l": "outline_panel::ExpandSelectedEntry", "shift-g": "menu::SelectLast", - "g g": "menu::SelectFirst" - } + "g g": "menu::SelectFirst", + "-": "outline_panel::SelectParent", + "enter": "editor::ToggleFocus", + "/": "menu::Cancel", + "ctrl-u": "outline_panel::ScrollUp", + "ctrl-d": "outline_panel::ScrollDown", + "z t": "outline_panel::ScrollCursorTop", + "z z": "outline_panel::ScrollCursorCenter", + "z b": "outline_panel::ScrollCursorBottom", + "0": ["vim::Number", 0], + "1": ["vim::Number", 1], + "2": ["vim::Number", 2], + "3": ["vim::Number", 3], + "4": ["vim::Number", 4], + "5": ["vim::Number", 5], + "6": ["vim::Number", 6], + "7": ["vim::Number", 7], + "8": ["vim::Number", 8], + "9": ["vim::Number", 9], + }, + }, + { + "context": "OutlinePanel && editing", + "bindings": { + "enter": "menu::Cancel", + }, }, { "context": "GitPanel && ChangesList", @@ -978,8 +1022,8 @@ "x": "git::ToggleStaged", "shift-x": "git::StageAll", "g x": "git::StageRange", - "shift-u": "git::UnstageAll" - } + "shift-u": "git::UnstageAll", + }, }, { "context": "Editor && mode == auto_height && VimControl", @@ -990,8 +1034,8 @@ "#": null, "*": null, "n": null, - "shift-n": null - } + "shift-n": null, + }, }, { "context": "Picker > Editor", @@ -1000,29 +1044,29 @@ "ctrl-u": "editor::DeleteToBeginningOfLine", "ctrl-w": "editor::DeleteToPreviousWordStart", "ctrl-p": "menu::SelectPrevious", - "ctrl-n": "menu::SelectNext" - } + "ctrl-n": "menu::SelectNext", + }, }, { "context": "GitCommit > Editor && VimControl && vim_mode == normal", "bindings": { "ctrl-c": "menu::Cancel", - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "Editor && edit_prediction", "bindings": { // This is identical to the binding in the base keymap, but the vim bindings above to // "vim::Tab" shadow it, so it needs to be bound again. - "tab": "editor::AcceptEditPrediction" - } + "tab": "editor::AcceptEditPrediction", + }, }, { "context": "MessageEditor > Editor && VimControl", "bindings": { - "enter": "agent::Chat" - } + "enter": "agent::Chat", + }, }, { "context": "os != macos && Editor && edit_prediction_conflict", @@ -1030,8 +1074,8 @@ // alt-l is provided as an alternative to tab/alt-tab. and will be displayed in the UI. This // is because alt-tab may not be available, as it is often used for window switching on Linux // and Windows. - "alt-l": "editor::AcceptEditPrediction" - } + "alt-l": "editor::AcceptEditPrediction", + }, }, { "context": "SettingsWindow > NavigationMenu && !search", @@ -1041,7 +1085,16 @@ "k": "settings_editor::FocusPreviousNavEntry", "j": "settings_editor::FocusNextNavEntry", "g g": "settings_editor::FocusFirstNavEntry", - "shift-g": "settings_editor::FocusLastNavEntry" - } - } + "shift-g": "settings_editor::FocusLastNavEntry", + }, + }, + { + "context": "MarkdownPreview", + "bindings": { + "ctrl-u": "markdown::ScrollPageUp", + "ctrl-d": "markdown::ScrollPageDown", + "ctrl-y": "markdown::ScrollUp", + "ctrl-e": "markdown::ScrollDown", + }, + }, ] diff --git a/assets/prompts/content_prompt_v2.hbs b/assets/prompts/content_prompt_v2.hbs index e1b6ddc6f023e9e97c9bb851473ac02e989c8feb..826aada8c04863c21d756cf99beb64e582ed4906 100644 --- a/assets/prompts/content_prompt_v2.hbs +++ b/assets/prompts/content_prompt_v2.hbs @@ -14,7 +14,6 @@ The section you'll need to rewrite is marked with The context around the relevant section has been truncated (possibly in the middle of a line) for brevity. {{/if}} -{{#if rewrite_section}} And here's the section to rewrite based on that prompt again for reference: @@ -33,12 +32,9 @@ Below are the diagnostic errors visible to the user. If the user requests probl {{/each}} {{/if}} -{{/if}} - Only make changes that are necessary to fulfill the prompt, leave everything else as-is. All surrounding {{content_type}} will be preserved. Start at the indentation level in the original file in the rewritten {{content_type}}. -You must use one of the provided tools to make the rewrite or to provide an explanation as to why the user's request cannot be fulfilled. It is an error if -you simply send back unstructured text. If you need to make a statement or ask a question you must use one of the tools to do so. +IMPORTANT: You MUST use one of the provided tools to make the rewrite or to provide an explanation as to why the user's request cannot be fulfilled. You MUST NOT send back unstructured text. If you need to make a statement or ask a question you MUST use one of the tools to do so. It is an error if you try to make a change that cannot be made simply by editing the rewrite_section. diff --git a/assets/settings/default.json b/assets/settings/default.json index f687778d7bd7fc0f6d66404199c34fac8d77e7a8..154fe2d6e34e6573e95e7ffedbb46df8bbf10634 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -12,7 +12,7 @@ "theme": { "mode": "system", "light": "One Light", - "dark": "One Dark" + "dark": "One Dark", }, "icon_theme": "Zed (Default)", // The name of a base set of key bindings to use. @@ -29,7 +29,7 @@ // Features that can be globally enabled or disabled "features": { // Which edit prediction provider to use. - "edit_prediction_provider": "zed" + "edit_prediction_provider": "zed", }, // The name of a font to use for rendering text in the editor // ".ZedMono" currently aliases to Lilex @@ -69,7 +69,7 @@ // The OpenType features to enable for text in the UI "ui_font_features": { // Disable ligatures: - "calt": false + "calt": false, }, // The weight of the UI font in standard CSS units from 100 to 900. "ui_font_weight": 400, @@ -87,7 +87,7 @@ "border_size": 0.0, // Opacity of the inactive panes. 0 means transparent, 1 means opaque. // Values are clamped to the [0.0, 1.0] range. - "inactive_opacity": 1.0 + "inactive_opacity": 1.0, }, // Layout mode of the bottom dock. Defaults to "contained" // choices: contained, full, left_aligned, right_aligned @@ -103,12 +103,12 @@ "left_padding": 0.2, // The relative width of the right padding of the central pane from the // workspace when the centered layout is used. - "right_padding": 0.2 + "right_padding": 0.2, }, // Image viewer settings "image_viewer": { // The unit for image file sizes: "binary" (KiB, MiB) or decimal (KB, MB) - "unit": "binary" + "unit": "binary", }, // Determines the modifier to be used to add multiple cursors with the mouse. The open hover link mouse gestures will adapt such that it do not conflict with the multicursor modifier. // @@ -296,7 +296,7 @@ // When true, enables drag and drop text selection in buffer. "enabled": true, // The delay in milliseconds that must elapse before drag and drop is allowed. Otherwise, a new text selection is created. - "delay": 300 + "delay": 300, }, // What to do when go to definition yields no results. // @@ -400,14 +400,14 @@ // Visible characters used to render whitespace when show_whitespaces is enabled. "whitespace_map": { "space": "•", - "tab": "→" + "tab": "→", }, // Settings related to calls in Zed "calls": { // Join calls with the microphone live by default "mute_on_join": false, // Share your project when you are the first to join a channel - "share_on_join": false + "share_on_join": false, }, // Toolbar related settings "toolbar": { @@ -420,7 +420,7 @@ // Whether to show agent review buttons in the editor toolbar. "agent_review": true, // Whether to show code action buttons in the editor toolbar. - "code_actions": false + "code_actions": false, }, // Whether to allow windows to tab together based on the user’s tabbing preference (macOS only). "use_system_window_tabs": false, @@ -436,10 +436,12 @@ "show_onboarding_banner": true, // Whether to show user picture in the titlebar. "show_user_picture": true, + // Whether to show the user menu in the titlebar. + "show_user_menu": true, // Whether to show the sign in button in the titlebar. "show_sign_in": true, // Whether to show the menus in the titlebar. - "show_menus": false + "show_menus": false, }, "audio": { // Opt into the new audio system. @@ -472,7 +474,7 @@ // the future we will migrate by setting this to false // // You need to rejoin a call for this setting to apply - "experimental.legacy_audio_compatible": true + "experimental.legacy_audio_compatible": true, }, // Scrollbar related settings "scrollbar": { @@ -511,8 +513,8 @@ // When false, forcefully disables the horizontal scrollbar. Otherwise, obey other settings. "horizontal": true, // When false, forcefully disables the vertical scrollbar. Otherwise, obey other settings. - "vertical": true - } + "vertical": true, + }, }, // Minimap related settings "minimap": { @@ -560,7 +562,7 @@ // 3. "gutter" or "none" to not highlight the current line in the minimap. "current_line_highlight": null, // Maximum number of columns to display in the minimap. - "max_width_columns": 80 + "max_width_columns": 80, }, // Enable middle-click paste on Linux. "middle_click_paste": true, @@ -583,7 +585,7 @@ // Whether to show fold buttons in the gutter. "folds": true, // Minimum number of characters to reserve space for in the gutter. - "min_line_number_digits": 4 + "min_line_number_digits": 4, }, "indent_guides": { // Whether to show indent guides in the editor. @@ -604,7 +606,7 @@ // // 1. "disabled" // 2. "indent_aware" - "background_coloring": "disabled" + "background_coloring": "disabled", }, // Whether the editor will scroll beyond the last line. "scroll_beyond_last_line": "one_page", @@ -623,7 +625,7 @@ "fast_scroll_sensitivity": 4.0, "sticky_scroll": { // Whether to stick scopes to the top of the editor. - "enabled": false + "enabled": false, }, "relative_line_numbers": "disabled", // If 'search_wrap' is disabled, search result do not wrap around the end of the file. @@ -641,7 +643,7 @@ // Whether to interpret the search query as a regular expression. "regex": false, // Whether to center the cursor on each search match when navigating. - "center_on_match": false + "center_on_match": false, }, // When to populate a new search's query based on the text under the cursor. // This setting can take the following three values: @@ -684,8 +686,8 @@ "shift": false, "alt": false, "platform": false, - "function": false - } + "function": false, + }, }, // Whether to resize all the panels in a dock when resizing the dock. // Can be a combination of "left", "right" and "bottom". @@ -733,7 +735,7 @@ // "always" // 5. Never show the scrollbar: // "never" - "show": null + "show": null, }, // Which files containing diagnostic errors/warnings to mark in the project panel. // This setting can take the following three values: @@ -756,7 +758,7 @@ // "always" // 2. Never show indent guides: // "never" - "show": "always" + "show": "always", }, // Sort order for entries in the project panel. // This setting can take three values: @@ -781,8 +783,8 @@ // Whether to automatically open files after pasting or duplicating them. "on_paste": true, // Whether to automatically open files dropped from external sources. - "on_drop": true - } + "on_drop": true, + }, }, "outline_panel": { // Whether to show the outline panel button in the status bar @@ -815,7 +817,7 @@ // "always" // 2. Never show indent guides: // "never" - "show": "always" + "show": "always", }, // Scrollbar-related settings "scrollbar": { @@ -832,11 +834,11 @@ // "always" // 5. Never show the scrollbar: // "never" - "show": null + "show": null, }, // Default depth to expand outline items in the current file. // Set to 0 to collapse all items that have children, 1 or higher to collapse items at that depth or deeper. - "expand_outlines_with_depth": 100 + "expand_outlines_with_depth": 100, }, "collaboration_panel": { // Whether to show the collaboration panel button in the status bar. @@ -844,7 +846,7 @@ // Where to dock the collaboration panel. Can be 'left' or 'right'. "dock": "left", // Default width of the collaboration panel. - "default_width": 240 + "default_width": 240, }, "git_panel": { // Whether to show the git panel button in the status bar. @@ -870,18 +872,22 @@ // // Default: false "collapse_untracked_diff": false, + /// Whether to show entries with tree or flat view in the panel + /// + /// Default: false + "tree_view": false, "scrollbar": { // When to show the scrollbar in the git panel. // // Choices: always, auto, never, system // Default: inherits editor scrollbar settings // "show": null - } + }, }, "message_editor": { // Whether to automatically replace emoji shortcodes with emoji characters. // For example: typing `:wave:` gets replaced with `👋`. - "auto_replace_emoji_shortcode": true + "auto_replace_emoji_shortcode": true, }, "notification_panel": { // Whether to show the notification panel button in the status bar. @@ -889,9 +895,11 @@ // Where to dock the notification panel. Can be 'left' or 'right'. "dock": "right", // Default width of the notification panel. - "default_width": 380 + "default_width": 380, }, "agent": { + // Whether the inline assistant should use streaming tools, when available + "inline_assistant_use_streaming_tools": true, // Whether the agent is enabled. "enabled": true, // What completion mode to start new threads in, if available. Can be 'normal' or 'burn'. @@ -900,6 +908,8 @@ "button": true, // Where to dock the agent panel. Can be 'left', 'right' or 'bottom'. "dock": "right", + // Where to dock the agents panel. Can be 'left' or 'right'. + "agents_panel_dock": "left", // Default width when the agent panel is docked to the left or right. "default_width": 640, // Default height when the agent panel is docked to the bottom. @@ -911,7 +921,7 @@ // The provider to use. "provider": "zed.dev", // The model to use. - "model": "claude-sonnet-4" + "model": "claude-sonnet-4", }, // Additional parameters for language model requests. When making a request to a model, parameters will be taken // from the last entry in this list that matches the model's provider and name. In each entry, both provider @@ -962,12 +972,14 @@ "now": true, "find_path": true, "read_file": true, + "restore_file_from_disk": true, + "save_file": true, "open": true, "grep": true, "terminal": true, "thinking": true, - "web_search": true - } + "web_search": true, + }, }, "ask": { "name": "Ask", @@ -984,14 +996,14 @@ "open": true, "grep": true, "thinking": true, - "web_search": true - } + "web_search": true, + }, }, "minimal": { "name": "Minimal", "enable_all_context_servers": false, - "tools": {} - } + "tools": {}, + }, }, // Where to show notifications when the agent has either completed // its response, or else needs confirmation before it can run a @@ -1020,7 +1032,7 @@ // Minimum number of lines to display in the agent message editor. // // Default: 4 - "message_editor_min_lines": 4 + "message_editor_min_lines": 4, }, // Whether the screen sharing icon is shown in the os status bar. "show_call_status_icon": true, @@ -1055,7 +1067,7 @@ // Whether or not to show the navigation history buttons. "show_nav_history_buttons": true, // Whether or not to show the tab bar buttons. - "show_tab_bar_buttons": true + "show_tab_bar_buttons": true, }, // Settings related to the editor's tabs "tabs": { @@ -1094,7 +1106,7 @@ // "errors" // 3. Mark files with errors and warnings: // "all" - "show_diagnostics": "off" + "show_diagnostics": "off", }, // Settings related to preview tabs. "preview_tabs": { @@ -1115,7 +1127,7 @@ "enable_preview_file_from_code_navigation": true, // Whether to keep tabs in preview mode when code navigation is used to navigate away from them. // If `enable_preview_file_from_code_navigation` or `enable_preview_multibuffer_from_code_navigation` is also true, the new tab may replace the existing one. - "enable_keep_preview_on_code_navigation": false + "enable_keep_preview_on_code_navigation": false, }, // Settings related to the file finder. "file_finder": { @@ -1159,7 +1171,7 @@ // * "all": Use all gitignored files // * "indexed": Use only the files Zed had indexed // * "smart": Be smart and search for ignored when called from a gitignored worktree - "include_ignored": "smart" + "include_ignored": "smart", }, // Whether or not to remove any trailing whitespace from lines of a buffer // before saving it. @@ -1230,7 +1242,7 @@ // Send debug info like crash reports. "diagnostics": true, // Send anonymized usage data like what languages you're using Zed with. - "metrics": true + "metrics": true, }, // Whether to disable all AI features in Zed. // @@ -1264,7 +1276,7 @@ "enabled": true, // Minimum time to wait before pulling diagnostics from the language server(s). // 0 turns the debounce off. - "debounce_ms": 50 + "debounce_ms": 50, }, // Settings for inline diagnostics "inline": { @@ -1282,8 +1294,8 @@ "min_column": 0, // The minimum severity of the diagnostics to show inline. // Inherits editor's diagnostics' max severity settings when `null`. - "max_severity": null - } + "max_severity": null, + }, }, // Files or globs of files that will be excluded by Zed entirely. They will be skipped during file // scans, file searches, and not be displayed in the project file tree. Takes precedence over `file_scan_inclusions`. @@ -1297,7 +1309,7 @@ "**/.DS_Store", "**/Thumbs.db", "**/.classpath", - "**/.settings" + "**/.settings", ], // Files or globs of files that will be included by Zed, even when ignored by git. This is useful // for files that are not tracked by git, but are still important to your project. Note that globs @@ -1309,6 +1321,14 @@ "hidden_files": ["**/.*"], // Git gutter behavior configuration. "git": { + // Global switch to enable or disable all git integration features. + // If set to true, disables all git integration features. + // If set to false, individual git integration features below will be independently enabled or disabled. + "disable_git": false, + // Whether to enable git status tracking. + "enable_status": true, + // Whether to enable git diff display. + "enable_diff": true, // Control whether the git gutter is shown. May take 2 values: // 1. Show the gutter // "git_gutter": "tracked_files" @@ -1332,14 +1352,14 @@ // Whether or not to display the git commit summary on the same line. "show_commit_summary": false, // The minimum column number to show the inline blame information at - "min_column": 0 + "min_column": 0, }, "blame": { - "show_avatar": true + "show_avatar": true, }, // Control which information is shown in the branch picker. "branch_picker": { - "show_author_name": true + "show_author_name": true, }, // How git hunks are displayed visually in the editor. // This setting can take two values: @@ -1351,7 +1371,7 @@ "hunk_style": "staged_hollow", // Should the name or path be displayed first in the git view. // "path_style": "file_name_first" or "file_path_first" - "path_style": "file_name_first" + "path_style": "file_name_first", }, // The list of custom Git hosting providers. "git_hosting_providers": [ @@ -1385,7 +1405,7 @@ "**/secrets.yml", "**/.zed/settings.json", // zed project settings "/**/zed/settings.json", // zed user settings - "/**/zed/keymap.json" + "/**/zed/keymap.json", ], // When to show edit predictions previews in buffer. // This setting takes two possible values: @@ -1403,15 +1423,16 @@ "copilot": { "enterprise_uri": null, "proxy": null, - "proxy_no_verify": null + "proxy_no_verify": null, }, "codestral": { - "model": null, - "max_tokens": null + "api_url": "https://codestral.mistral.ai", + "model": "codestral-latest", + "max_tokens": 150, }, // Whether edit predictions are enabled when editing text threads in the agent panel. // This setting has no effect if globally disabled. - "enabled_in_text_threads": true + "enabled_in_text_threads": true, }, // Settings specific to journaling "journal": { @@ -1421,7 +1442,7 @@ // May take 2 values: // 1. hour12 // 2. hour24 - "hour_format": "hour12" + "hour_format": "hour12", }, // Status bar-related settings. "status_bar": { @@ -1432,7 +1453,7 @@ // Whether to show the cursor position button in the status bar. "cursor_position_button": true, // Whether to show active line endings button in the status bar. - "line_endings_button": false + "line_endings_button": false, }, // Settings specific to the terminal "terminal": { @@ -1553,8 +1574,8 @@ // Preferred Conda manager to use when activating Conda environments. // Values: "auto", "conda", "mamba", "micromamba" // Default: "auto" - "conda_manager": "auto" - } + "conda_manager": "auto", + }, }, "toolbar": { // Whether to display the terminal title in its toolbar's breadcrumbs. @@ -1562,7 +1583,7 @@ // // The shell running in the terminal needs to be configured to emit the title. // Example: `echo -e "\e]2;New Title\007";` - "breadcrumbs": false + "breadcrumbs": false, }, // Scrollbar-related settings "scrollbar": { @@ -1579,7 +1600,7 @@ // "always" // 5. Never show the scrollbar: // "never" - "show": null + "show": null, }, // Set the terminal's font size. If this option is not included, // the terminal will default to matching the buffer's font size. @@ -1642,30 +1663,26 @@ // surrounding symbols or quotes [ "(?x)", - "# optionally starts with 0-2 opening prefix symbols", - "[({\\[<]{0,2}", - "# which may be followed by an opening quote", - "(?[\"'`])?", - "# `path` is the shortest sequence of any non-space character", - "(?(?[^ ]+?", - " # which may end with a line and optionally a column,", - " (?:+[0-9]+(:[0-9]+)?|:?\\([0-9]+([,:][0-9]+)?\\))?", - "))", - "# which must be followed by a matching quote", - "(?()\\k)", - "# and optionally a single closing symbol", - "[)}\\]>]?", - "# if line/column matched, may be followed by a description", - "(?():[^ 0-9][^ ]*)?", - "# which may be followed by trailing punctuation", - "[.,:)}\\]>]*", - "# and always includes trailing whitespace or end of line", - "([ ]+|$)" - ] + "(?", + " (", + " # multi-char path: first char (not opening delimiter or space)", + " [^({\\[<\"'`\\ ]", + " # middle chars: non-space, and colon/paren only if not followed by digit/paren", + " ([^\\ :(]|[:(][^0-9()])*", + " # last char: not closing delimiter or colon", + " [^()}\\]>\"'`.,;:\\ ]", + " |", + " # single-char path: not delimiter, punctuation, or space", + " [^(){}\\[\\]<>\"'`.,;:\\ ]", + " )", + " # optional line/column suffix (included in path for PathWithPosition::parse_str)", + " (:+[0-9]+(:[0-9]+)?|:?\\([0-9]+([,:]?[0-9]+)?\\))?", + ")", + ], ], // Timeout for hover and Cmd-click path hyperlink discovery in milliseconds. Specifying a // timeout of `0` will disable path hyperlinking in terminal. - "path_hyperlink_timeout_ms": 1 + "path_hyperlink_timeout_ms": 1, }, "code_actions_on_format": {}, // Settings related to running tasks. @@ -1681,7 +1698,7 @@ // * Zed task from history (e.g. one-off task was spawned before) // // Default: true - "prefer_lsp": true + "prefer_lsp": true, }, // An object whose keys are language names, and whose values // are arrays of filenames or extensions of files that should @@ -1696,9 +1713,14 @@ // } // "file_types": { - "JSONC": ["**/.zed/**/*.json", "**/zed/**/*.json", "**/Zed/**/*.json", "**/.vscode/**/*.json", "tsconfig*.json"], + "JSONC": [ + "**/.zed/*.json", + "**/.vscode/**/*.json", + "**/{zed,Zed}/{settings,keymap,tasks,debug}.json", + "tsconfig*.json", + ], "Markdown": [".rules", ".cursorrules", ".windsurfrules", ".clinerules"], - "Shell Script": [".env.*"] + "Shell Script": [".env.*"], }, // Settings for which version of Node.js and NPM to use when installing // language servers and Copilot. @@ -1714,14 +1736,14 @@ // `path`, but not `npm_path`, Zed will assume that `npm` is located at // `${path}/../npm`. "path": null, - "npm_path": null + "npm_path": null, }, // The extensions that Zed should automatically install on startup. // // If you don't want any of these extensions, add this field to your settings // and change the value to `false`. "auto_install_extensions": { - "html": true + "html": true, }, // The capabilities granted to extensions. // @@ -1729,7 +1751,7 @@ "granted_extension_capabilities": [ { "kind": "process:exec", "command": "*", "args": ["**"] }, { "kind": "download_file", "host": "*", "path": ["**"] }, - { "kind": "npm:install", "package": "*" } + { "kind": "npm:install", "package": "*" }, ], // Controls how completions are processed for this language. "completions": { @@ -1780,7 +1802,7 @@ // 4. "replace_suffix" // Behaves like `"replace"` if the text after the cursor is a suffix of the completion, and like // `"insert"` otherwise. - "lsp_insert_mode": "replace_suffix" + "lsp_insert_mode": "replace_suffix", }, // Different settings for specific languages. "languages": { @@ -1788,113 +1810,116 @@ "language_servers": ["astro-language-server", "..."], "prettier": { "allowed": true, - "plugins": ["prettier-plugin-astro"] - } + "plugins": ["prettier-plugin-astro"], + }, }, "Blade": { "prettier": { - "allowed": true - } + "allowed": true, + }, }, "C": { "format_on_save": "off", "use_on_type_format": false, "prettier": { - "allowed": false - } + "allowed": false, + }, }, "C++": { "format_on_save": "off", "use_on_type_format": false, "prettier": { - "allowed": false - } + "allowed": false, + }, + }, + "CSharp": { + "language_servers": ["roslyn", "!omnisharp", "..."], }, "CSS": { "prettier": { - "allowed": true - } + "allowed": true, + }, }, "Dart": { - "tab_size": 2 + "tab_size": 2, }, "Diff": { "show_edit_predictions": false, "remove_trailing_whitespace_on_save": false, - "ensure_final_newline_on_save": false + "ensure_final_newline_on_save": false, }, "Elixir": { - "language_servers": ["elixir-ls", "!expert", "!next-ls", "!lexical", "..."] + "language_servers": ["elixir-ls", "!expert", "!next-ls", "!lexical", "..."], }, "Elm": { - "tab_size": 4 + "tab_size": 4, }, "Erlang": { - "language_servers": ["erlang-ls", "!elp", "..."] + "language_servers": ["erlang-ls", "!elp", "..."], }, "Git Commit": { "allow_rewrap": "anywhere", "soft_wrap": "editor_width", - "preferred_line_length": 72 + "preferred_line_length": 72, }, "Go": { "hard_tabs": true, "code_actions_on_format": { - "source.organizeImports": true + "source.organizeImports": true, }, - "debuggers": ["Delve"] + "debuggers": ["Delve"], }, "GraphQL": { "prettier": { - "allowed": true - } + "allowed": true, + }, }, "HEEX": { - "language_servers": ["elixir-ls", "!expert", "!next-ls", "!lexical", "..."] + "language_servers": ["elixir-ls", "!expert", "!next-ls", "!lexical", "..."], }, "HTML": { "prettier": { - "allowed": true - } + "allowed": true, + }, }, "HTML+ERB": { - "language_servers": ["herb", "!ruby-lsp", "..."] + "language_servers": ["herb", "!ruby-lsp", "..."], }, "Java": { "prettier": { "allowed": true, - "plugins": ["prettier-plugin-java"] - } + "plugins": ["prettier-plugin-java"], + }, }, "JavaScript": { "language_servers": ["!typescript-language-server", "vtsls", "..."], "prettier": { - "allowed": true - } + "allowed": true, + }, }, "JSON": { "prettier": { - "allowed": true - } + "allowed": true, + }, }, "JSONC": { "prettier": { - "allowed": true - } + "allowed": true, + }, }, "JS+ERB": { - "language_servers": ["!ruby-lsp", "..."] + "language_servers": ["!ruby-lsp", "..."], }, "Kotlin": { - "language_servers": ["!kotlin-language-server", "kotlin-lsp", "..."] + "language_servers": ["!kotlin-language-server", "kotlin-lsp", "..."], }, "LaTeX": { "formatter": "language_server", "language_servers": ["texlab", "..."], "prettier": { "allowed": true, - "plugins": ["prettier-plugin-latex"] - } + "plugins": ["prettier-plugin-latex"], + }, }, "Markdown": { "format_on_save": "off", @@ -1902,136 +1927,145 @@ "remove_trailing_whitespace_on_save": false, "allow_rewrap": "anywhere", "soft_wrap": "editor_width", + "completions": { + "words": "disabled", + }, "prettier": { - "allowed": true - } + "allowed": true, + }, }, "PHP": { "language_servers": ["phpactor", "!intelephense", "!phptools", "..."], "prettier": { "allowed": true, "plugins": ["@prettier/plugin-php"], - "parser": "php" - } + "parser": "php", + }, }, "Plain Text": { "allow_rewrap": "anywhere", - "soft_wrap": "editor_width" + "soft_wrap": "editor_width", + "completions": { + "words": "disabled", + }, + }, + "Proto": { + "language_servers": ["buf", "!protols", "!protobuf-language-server", "..."], }, "Python": { "code_actions_on_format": { - "source.organizeImports.ruff": true + "source.organizeImports.ruff": true, }, "formatter": { "language_server": { - "name": "ruff" - } + "name": "ruff", + }, }, "debuggers": ["Debugpy"], - "language_servers": ["basedpyright", "ruff", "!ty", "!pyrefly", "!pyright", "!pylsp", "..."] + "language_servers": ["basedpyright", "ruff", "!ty", "!pyrefly", "!pyright", "!pylsp", "..."], }, "Ruby": { - "language_servers": ["solargraph", "!ruby-lsp", "!rubocop", "!sorbet", "!steep", "..."] + "language_servers": ["solargraph", "!ruby-lsp", "!rubocop", "!sorbet", "!steep", "..."], }, "Rust": { - "debuggers": ["CodeLLDB"] + "debuggers": ["CodeLLDB"], }, "SCSS": { "prettier": { - "allowed": true - } + "allowed": true, + }, }, "Starlark": { - "language_servers": ["starpls", "!buck2-lsp", "..."] + "language_servers": ["starpls", "!buck2-lsp", "..."], }, "Svelte": { "language_servers": ["svelte-language-server", "..."], "prettier": { "allowed": true, - "plugins": ["prettier-plugin-svelte"] - } + "plugins": ["prettier-plugin-svelte"], + }, }, "TSX": { "language_servers": ["!typescript-language-server", "vtsls", "..."], "prettier": { - "allowed": true - } + "allowed": true, + }, }, "Twig": { "prettier": { - "allowed": true - } + "allowed": true, + }, }, "TypeScript": { "language_servers": ["!typescript-language-server", "vtsls", "..."], "prettier": { - "allowed": true - } + "allowed": true, + }, }, "SystemVerilog": { "format_on_save": "off", "language_servers": ["!slang", "..."], - "use_on_type_format": false + "use_on_type_format": false, }, "Vue.js": { "language_servers": ["vue-language-server", "vtsls", "..."], "prettier": { - "allowed": true - } + "allowed": true, + }, }, "XML": { "prettier": { "allowed": true, - "plugins": ["@prettier/plugin-xml"] - } + "plugins": ["@prettier/plugin-xml"], + }, }, "YAML": { "prettier": { - "allowed": true - } + "allowed": true, + }, }, "YAML+ERB": { - "language_servers": ["!ruby-lsp", "..."] + "language_servers": ["!ruby-lsp", "..."], }, "Zig": { - "language_servers": ["zls", "..."] - } + "language_servers": ["zls", "..."], + }, }, // Different settings for specific language models. "language_models": { "anthropic": { - "api_url": "https://api.anthropic.com" + "api_url": "https://api.anthropic.com", }, "bedrock": {}, "google": { - "api_url": "https://generativelanguage.googleapis.com" + "api_url": "https://generativelanguage.googleapis.com", }, "ollama": { - "api_url": "http://localhost:11434" + "api_url": "http://localhost:11434", }, "openai": { - "api_url": "https://api.openai.com/v1" + "api_url": "https://api.openai.com/v1", }, "openai_compatible": {}, "open_router": { - "api_url": "https://openrouter.ai/api/v1" + "api_url": "https://openrouter.ai/api/v1", }, "lmstudio": { - "api_url": "http://localhost:1234/api/v0" + "api_url": "http://localhost:1234/api/v0", }, "deepseek": { - "api_url": "https://api.deepseek.com/v1" + "api_url": "https://api.deepseek.com/v1", }, "mistral": { - "api_url": "https://api.mistral.ai/v1" + "api_url": "https://api.mistral.ai/v1", }, "vercel": { - "api_url": "https://api.v0.dev/v1" + "api_url": "https://api.v0.dev/v1", }, "x_ai": { - "api_url": "https://api.x.ai/v1" + "api_url": "https://api.x.ai/v1", }, - "zed.dev": {} + "zed.dev": {}, }, "session": { // Whether or not to restore unsaved buffers on restart. @@ -2040,7 +2074,13 @@ // dirty files when closing the application. // // Default: true - "restore_unsaved_buffers": 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 @@ -2058,11 +2098,11 @@ // "singleQuote": true // Forces Prettier integration to use a specific parser name when formatting files with the language // when set to a non-empty string. - "parser": "" + "parser": "", }, // Settings for auto-closing of JSX tags. "jsx_tag_auto_close": { - "enabled": true + "enabled": true, }, // LSP Specific settings. "lsp": { @@ -2083,19 +2123,19 @@ // Specify the DAP name as a key here. "CodeLLDB": { "env": { - "RUST_LOG": "info" - } - } + "RUST_LOG": "info", + }, + }, }, // Common language server settings. "global_lsp_settings": { // Whether to show the LSP servers button in the status bar. - "button": true + "button": true, }, // Jupyter settings "jupyter": { "enabled": true, - "kernel_selections": {} + "kernel_selections": {}, // Specify the language name as the key and the kernel name as the value. // "kernel_selections": { // "python": "conda-base" @@ -2109,7 +2149,7 @@ "max_columns": 128, // Maximum number of lines to keep in REPL's scrollback buffer. // Clamped with [4, 256] range. - "max_lines": 32 + "max_lines": 32, }, // Vim settings "vim": { @@ -2123,7 +2163,14 @@ // Specify the mode as the key and the shape as the value. // The mode can be one of the following: "normal", "replace", "insert", "visual". // The shape can be one of the following: "block", "bar", "underline", "hollow". - "cursor_shape": {} + "cursor_shape": {}, + }, + // Which-key popup settings + "which_key": { + // Whether to show the which-key popup when holding down key combinations. + "enabled": false, + // Delay in milliseconds before showing the which-key popup. + "delay_ms": 1000, }, // The server to connect to. If the environment variable // ZED_SERVER_URL is set, it will override this setting. @@ -2156,9 +2203,9 @@ "windows": { "languages": { "PHP": { - "language_servers": ["intelephense", "!phpactor", "!phptools", "..."] - } - } + "language_servers": ["intelephense", "!phpactor", "!phptools", "..."], + }, + }, }, // Whether to show full labels in line indicator or short ones // @@ -2217,7 +2264,7 @@ "dock": "bottom", "log_dap_communications": true, "format_dap_log_messages": true, - "button": true + "button": true, }, // Configures any number of settings profiles that are temporarily applied on // top of your existing user settings when selected from @@ -2244,5 +2291,5 @@ // Useful for filtering out noisy logs or enabling more verbose logging. // // Example: {"log": {"client": "warn"}} - "log": {} + "log": {}, } diff --git a/assets/settings/initial_debug_tasks.json b/assets/settings/initial_debug_tasks.json index af4512bd51aa82d57ce62e605b45ee61e8f98030..851289392a65aecfca17e00d4c123823ac9e21cb 100644 --- a/assets/settings/initial_debug_tasks.json +++ b/assets/settings/initial_debug_tasks.json @@ -8,7 +8,7 @@ "adapter": "Debugpy", "program": "$ZED_FILE", "request": "launch", - "cwd": "$ZED_WORKTREE_ROOT" + "cwd": "$ZED_WORKTREE_ROOT", }, { "label": "Debug active JavaScript file", @@ -16,7 +16,7 @@ "program": "$ZED_FILE", "request": "launch", "cwd": "$ZED_WORKTREE_ROOT", - "type": "pwa-node" + "type": "pwa-node", }, { "label": "JavaScript debug terminal", @@ -24,6 +24,6 @@ "request": "launch", "cwd": "$ZED_WORKTREE_ROOT", "console": "integratedTerminal", - "type": "pwa-node" - } + "type": "pwa-node", + }, ] diff --git a/assets/settings/initial_server_settings.json b/assets/settings/initial_server_settings.json index d6ec33e60128380378610a273a1bbdff1ecdbaa8..29aa569b105157df7ec48164e2066fdac72c7b41 100644 --- a/assets/settings/initial_server_settings.json +++ b/assets/settings/initial_server_settings.json @@ -3,5 +3,5 @@ // For a full list of overridable settings, and general information on settings, // see the documentation: https://zed.dev/docs/configuring-zed#settings-files { - "lsp": {} + "lsp": {}, } diff --git a/assets/settings/initial_tasks.json b/assets/settings/initial_tasks.json index a79e98063237ca297a89b0d151bd48149061b7bb..5bedafbd3a1e75a755598e37cd673742e146fdcc 100644 --- a/assets/settings/initial_tasks.json +++ b/assets/settings/initial_tasks.json @@ -47,8 +47,8 @@ // Whether to show the task line in the output of the spawned task, defaults to `true`. "show_summary": true, // Whether to show the command line in the output of the spawned task, defaults to `true`. - "show_command": true + "show_command": true, // Represents the tags for inline runnable indicators, or spawning multiple tasks at once. // "tags": [] - } + }, ] diff --git a/assets/settings/initial_user_settings.json b/assets/settings/initial_user_settings.json index 5ac2063bdb481e057a2d124c1e72f998390b066b..8b573854895a03243803a71a91a35af647f45ca2 100644 --- a/assets/settings/initial_user_settings.json +++ b/assets/settings/initial_user_settings.json @@ -12,6 +12,6 @@ "theme": { "mode": "system", "light": "One Light", - "dark": "One Dark" - } + "dark": "One Dark", + }, } diff --git a/assets/themes/gruvbox/gruvbox.json b/assets/themes/gruvbox/gruvbox.json index 90973fd6c3469a1ef0e698d629376dfaaf3b5a76..16ae188712f7a800ab4fb8a81a2d24cac99da56b 100644 --- a/assets/themes/gruvbox/gruvbox.json +++ b/assets/themes/gruvbox/gruvbox.json @@ -71,33 +71,33 @@ "editor.document_highlight.read_background": "#83a5981a", "editor.document_highlight.write_background": "#92847466", "terminal.background": "#282828ff", - "terminal.foreground": "#fbf1c7ff", + "terminal.foreground": "#ebdbb2ff", "terminal.bright_foreground": "#fbf1c7ff", - "terminal.dim_foreground": "#282828ff", + "terminal.dim_foreground": "#766b5dff", "terminal.ansi.black": "#282828ff", - "terminal.ansi.bright_black": "#73675eff", + "terminal.ansi.bright_black": "#928374ff", "terminal.ansi.dim_black": "#fbf1c7ff", - "terminal.ansi.red": "#fb4a35ff", - "terminal.ansi.bright_red": "#93201dff", - "terminal.ansi.dim_red": "#ffaa95ff", - "terminal.ansi.green": "#b7bb26ff", - "terminal.ansi.bright_green": "#605c1bff", - "terminal.ansi.dim_green": "#e0dc98ff", - "terminal.ansi.yellow": "#f9bd2fff", - "terminal.ansi.bright_yellow": "#91611bff", - "terminal.ansi.dim_yellow": "#fedc9bff", - "terminal.ansi.blue": "#83a598ff", - "terminal.ansi.bright_blue": "#414f4aff", - "terminal.ansi.dim_blue": "#c0d2cbff", - "terminal.ansi.magenta": "#d3869bff", - "terminal.ansi.bright_magenta": "#8e5868ff", - "terminal.ansi.dim_magenta": "#ff9ebbff", - "terminal.ansi.cyan": "#8ec07cff", - "terminal.ansi.bright_cyan": "#45603eff", - "terminal.ansi.dim_cyan": "#c7dfbdff", - "terminal.ansi.white": "#fbf1c7ff", - "terminal.ansi.bright_white": "#ffffffff", - "terminal.ansi.dim_white": "#b0a189ff", + "terminal.ansi.red": "#cc241dff", + "terminal.ansi.bright_red": "#fb4934ff", + "terminal.ansi.dim_red": "#8e1814ff", + "terminal.ansi.green": "#98971aff", + "terminal.ansi.bright_green": "#b8bb26ff", + "terminal.ansi.dim_green": "#6a6912ff", + "terminal.ansi.yellow": "#d79921ff", + "terminal.ansi.bright_yellow": "#fabd2fff", + "terminal.ansi.dim_yellow": "#966a17ff", + "terminal.ansi.blue": "#458588ff", + "terminal.ansi.bright_blue": "#83a598ff", + "terminal.ansi.dim_blue": "#305d5fff", + "terminal.ansi.magenta": "#b16286ff", + "terminal.ansi.bright_magenta": "#d3869bff", + "terminal.ansi.dim_magenta": "#7c455eff", + "terminal.ansi.cyan": "#689d6aff", + "terminal.ansi.bright_cyan": "#8ec07cff", + "terminal.ansi.dim_cyan": "#496e4aff", + "terminal.ansi.white": "#a89984ff", + "terminal.ansi.bright_white": "#fbf1c7ff", + "terminal.ansi.dim_white": "#766b5dff", "link_text.hover": "#83a598ff", "version_control.added": "#b7bb26ff", "version_control.modified": "#f9bd2fff", @@ -478,33 +478,33 @@ "editor.document_highlight.read_background": "#83a5981a", "editor.document_highlight.write_background": "#92847466", "terminal.background": "#1d2021ff", - "terminal.foreground": "#fbf1c7ff", + "terminal.foreground": "#ebdbb2ff", "terminal.bright_foreground": "#fbf1c7ff", - "terminal.dim_foreground": "#1d2021ff", - "terminal.ansi.black": "#1d2021ff", - "terminal.ansi.bright_black": "#73675eff", + "terminal.dim_foreground": "#766b5dff", + "terminal.ansi.black": "#282828ff", + "terminal.ansi.bright_black": "#928374ff", "terminal.ansi.dim_black": "#fbf1c7ff", - "terminal.ansi.red": "#fb4a35ff", - "terminal.ansi.bright_red": "#93201dff", - "terminal.ansi.dim_red": "#ffaa95ff", - "terminal.ansi.green": "#b7bb26ff", - "terminal.ansi.bright_green": "#605c1bff", - "terminal.ansi.dim_green": "#e0dc98ff", - "terminal.ansi.yellow": "#f9bd2fff", - "terminal.ansi.bright_yellow": "#91611bff", - "terminal.ansi.dim_yellow": "#fedc9bff", - "terminal.ansi.blue": "#83a598ff", - "terminal.ansi.bright_blue": "#414f4aff", - "terminal.ansi.dim_blue": "#c0d2cbff", - "terminal.ansi.magenta": "#d3869bff", - "terminal.ansi.bright_magenta": "#8e5868ff", - "terminal.ansi.dim_magenta": "#ff9ebbff", - "terminal.ansi.cyan": "#8ec07cff", - "terminal.ansi.bright_cyan": "#45603eff", - "terminal.ansi.dim_cyan": "#c7dfbdff", - "terminal.ansi.white": "#fbf1c7ff", - "terminal.ansi.bright_white": "#ffffffff", - "terminal.ansi.dim_white": "#b0a189ff", + "terminal.ansi.red": "#cc241dff", + "terminal.ansi.bright_red": "#fb4934ff", + "terminal.ansi.dim_red": "#8e1814ff", + "terminal.ansi.green": "#98971aff", + "terminal.ansi.bright_green": "#b8bb26ff", + "terminal.ansi.dim_green": "#6a6912ff", + "terminal.ansi.yellow": "#d79921ff", + "terminal.ansi.bright_yellow": "#fabd2fff", + "terminal.ansi.dim_yellow": "#966a17ff", + "terminal.ansi.blue": "#458588ff", + "terminal.ansi.bright_blue": "#83a598ff", + "terminal.ansi.dim_blue": "#305d5fff", + "terminal.ansi.magenta": "#b16286ff", + "terminal.ansi.bright_magenta": "#d3869bff", + "terminal.ansi.dim_magenta": "#7c455eff", + "terminal.ansi.cyan": "#689d6aff", + "terminal.ansi.bright_cyan": "#8ec07cff", + "terminal.ansi.dim_cyan": "#496e4aff", + "terminal.ansi.white": "#a89984ff", + "terminal.ansi.bright_white": "#fbf1c7ff", + "terminal.ansi.dim_white": "#766b5dff", "link_text.hover": "#83a598ff", "version_control.added": "#b7bb26ff", "version_control.modified": "#f9bd2fff", @@ -885,33 +885,33 @@ "editor.document_highlight.read_background": "#83a5981a", "editor.document_highlight.write_background": "#92847466", "terminal.background": "#32302fff", - "terminal.foreground": "#fbf1c7ff", + "terminal.foreground": "#ebdbb2ff", "terminal.bright_foreground": "#fbf1c7ff", - "terminal.dim_foreground": "#32302fff", - "terminal.ansi.black": "#32302fff", - "terminal.ansi.bright_black": "#73675eff", + "terminal.dim_foreground": "#766b5dff", + "terminal.ansi.black": "#282828ff", + "terminal.ansi.bright_black": "#928374ff", "terminal.ansi.dim_black": "#fbf1c7ff", - "terminal.ansi.red": "#fb4a35ff", - "terminal.ansi.bright_red": "#93201dff", - "terminal.ansi.dim_red": "#ffaa95ff", - "terminal.ansi.green": "#b7bb26ff", - "terminal.ansi.bright_green": "#605c1bff", - "terminal.ansi.dim_green": "#e0dc98ff", - "terminal.ansi.yellow": "#f9bd2fff", - "terminal.ansi.bright_yellow": "#91611bff", - "terminal.ansi.dim_yellow": "#fedc9bff", - "terminal.ansi.blue": "#83a598ff", - "terminal.ansi.bright_blue": "#414f4aff", - "terminal.ansi.dim_blue": "#c0d2cbff", - "terminal.ansi.magenta": "#d3869bff", - "terminal.ansi.bright_magenta": "#8e5868ff", - "terminal.ansi.dim_magenta": "#ff9ebbff", - "terminal.ansi.cyan": "#8ec07cff", - "terminal.ansi.bright_cyan": "#45603eff", - "terminal.ansi.dim_cyan": "#c7dfbdff", - "terminal.ansi.white": "#fbf1c7ff", - "terminal.ansi.bright_white": "#ffffffff", - "terminal.ansi.dim_white": "#b0a189ff", + "terminal.ansi.red": "#cc241dff", + "terminal.ansi.bright_red": "#fb4934ff", + "terminal.ansi.dim_red": "#8e1814ff", + "terminal.ansi.green": "#98971aff", + "terminal.ansi.bright_green": "#b8bb26ff", + "terminal.ansi.dim_green": "#6a6912ff", + "terminal.ansi.yellow": "#d79921ff", + "terminal.ansi.bright_yellow": "#fabd2fff", + "terminal.ansi.dim_yellow": "#966a17ff", + "terminal.ansi.blue": "#458588ff", + "terminal.ansi.bright_blue": "#83a598ff", + "terminal.ansi.dim_blue": "#305d5fff", + "terminal.ansi.magenta": "#b16286ff", + "terminal.ansi.bright_magenta": "#d3869bff", + "terminal.ansi.dim_magenta": "#7c455eff", + "terminal.ansi.cyan": "#689d6aff", + "terminal.ansi.bright_cyan": "#8ec07cff", + "terminal.ansi.dim_cyan": "#496e4aff", + "terminal.ansi.white": "#a89984ff", + "terminal.ansi.bright_white": "#fbf1c7ff", + "terminal.ansi.dim_white": "#766b5dff", "link_text.hover": "#83a598ff", "version_control.added": "#b7bb26ff", "version_control.modified": "#f9bd2fff", @@ -1295,30 +1295,30 @@ "terminal.foreground": "#282828ff", "terminal.bright_foreground": "#282828ff", "terminal.dim_foreground": "#fbf1c7ff", - "terminal.ansi.black": "#282828ff", - "terminal.ansi.bright_black": "#0b6678ff", - "terminal.ansi.dim_black": "#5f5650ff", - "terminal.ansi.red": "#9d0308ff", - "terminal.ansi.bright_red": "#db8b7aff", - "terminal.ansi.dim_red": "#4e1207ff", - "terminal.ansi.green": "#797410ff", - "terminal.ansi.bright_green": "#bfb787ff", - "terminal.ansi.dim_green": "#3e3a11ff", - "terminal.ansi.yellow": "#b57615ff", - "terminal.ansi.bright_yellow": "#e2b88bff", - "terminal.ansi.dim_yellow": "#5c3a12ff", - "terminal.ansi.blue": "#0b6678ff", - "terminal.ansi.bright_blue": "#8fb0baff", - "terminal.ansi.dim_blue": "#14333bff", - "terminal.ansi.magenta": "#8f3e71ff", - "terminal.ansi.bright_magenta": "#c76da0ff", - "terminal.ansi.dim_magenta": "#5c2848ff", - "terminal.ansi.cyan": "#437b59ff", - "terminal.ansi.bright_cyan": "#9fbca8ff", - "terminal.ansi.dim_cyan": "#253e2eff", - "terminal.ansi.white": "#fbf1c7ff", - "terminal.ansi.bright_white": "#ffffffff", - "terminal.ansi.dim_white": "#b0a189ff", + "terminal.ansi.black": "#fbf1c7ff", + "terminal.ansi.bright_black": "#928374ff", + "terminal.ansi.dim_black": "#7c6f64ff", + "terminal.ansi.red": "#cc241dff", + "terminal.ansi.bright_red": "#9d0006ff", + "terminal.ansi.dim_red": "#c31c16ff", + "terminal.ansi.green": "#98971aff", + "terminal.ansi.bright_green": "#79740eff", + "terminal.ansi.dim_green": "#929015ff", + "terminal.ansi.yellow": "#d79921ff", + "terminal.ansi.bright_yellow": "#b57614ff", + "terminal.ansi.dim_yellow": "#cf8e1aff", + "terminal.ansi.blue": "#458588ff", + "terminal.ansi.bright_blue": "#076678ff", + "terminal.ansi.dim_blue": "#356f77ff", + "terminal.ansi.magenta": "#b16286ff", + "terminal.ansi.bright_magenta": "#8f3f71ff", + "terminal.ansi.dim_magenta": "#a85580ff", + "terminal.ansi.cyan": "#689d6aff", + "terminal.ansi.bright_cyan": "#427b58ff", + "terminal.ansi.dim_cyan": "#5f9166ff", + "terminal.ansi.white": "#7c6f64ff", + "terminal.ansi.bright_white": "#282828ff", + "terminal.ansi.dim_white": "#282828ff", "link_text.hover": "#0b6678ff", "version_control.added": "#797410ff", "version_control.modified": "#b57615ff", @@ -1702,30 +1702,30 @@ "terminal.foreground": "#282828ff", "terminal.bright_foreground": "#282828ff", "terminal.dim_foreground": "#f9f5d7ff", - "terminal.ansi.black": "#282828ff", - "terminal.ansi.bright_black": "#73675eff", - "terminal.ansi.dim_black": "#f9f5d7ff", - "terminal.ansi.red": "#9d0308ff", - "terminal.ansi.bright_red": "#db8b7aff", - "terminal.ansi.dim_red": "#4e1207ff", - "terminal.ansi.green": "#797410ff", - "terminal.ansi.bright_green": "#bfb787ff", - "terminal.ansi.dim_green": "#3e3a11ff", - "terminal.ansi.yellow": "#b57615ff", - "terminal.ansi.bright_yellow": "#e2b88bff", - "terminal.ansi.dim_yellow": "#5c3a12ff", - "terminal.ansi.blue": "#0b6678ff", - "terminal.ansi.bright_blue": "#8fb0baff", - "terminal.ansi.dim_blue": "#14333bff", - "terminal.ansi.magenta": "#8f3e71ff", - "terminal.ansi.bright_magenta": "#c76da0ff", - "terminal.ansi.dim_magenta": "#5c2848ff", - "terminal.ansi.cyan": "#437b59ff", - "terminal.ansi.bright_cyan": "#9fbca8ff", - "terminal.ansi.dim_cyan": "#253e2eff", - "terminal.ansi.white": "#f9f5d7ff", - "terminal.ansi.bright_white": "#ffffffff", - "terminal.ansi.dim_white": "#b0a189ff", + "terminal.ansi.black": "#fbf1c7ff", + "terminal.ansi.bright_black": "#928374ff", + "terminal.ansi.dim_black": "#7c6f64ff", + "terminal.ansi.red": "#cc241dff", + "terminal.ansi.bright_red": "#9d0006ff", + "terminal.ansi.dim_red": "#c31c16ff", + "terminal.ansi.green": "#98971aff", + "terminal.ansi.bright_green": "#79740eff", + "terminal.ansi.dim_green": "#929015ff", + "terminal.ansi.yellow": "#d79921ff", + "terminal.ansi.bright_yellow": "#b57614ff", + "terminal.ansi.dim_yellow": "#cf8e1aff", + "terminal.ansi.blue": "#458588ff", + "terminal.ansi.bright_blue": "#076678ff", + "terminal.ansi.dim_blue": "#356f77ff", + "terminal.ansi.magenta": "#b16286ff", + "terminal.ansi.bright_magenta": "#8f3f71ff", + "terminal.ansi.dim_magenta": "#a85580ff", + "terminal.ansi.cyan": "#689d6aff", + "terminal.ansi.bright_cyan": "#427b58ff", + "terminal.ansi.dim_cyan": "#5f9166ff", + "terminal.ansi.white": "#7c6f64ff", + "terminal.ansi.bright_white": "#282828ff", + "terminal.ansi.dim_white": "#282828ff", "link_text.hover": "#0b6678ff", "version_control.added": "#797410ff", "version_control.modified": "#b57615ff", @@ -2109,30 +2109,30 @@ "terminal.foreground": "#282828ff", "terminal.bright_foreground": "#282828ff", "terminal.dim_foreground": "#f2e5bcff", - "terminal.ansi.black": "#282828ff", - "terminal.ansi.bright_black": "#73675eff", - "terminal.ansi.dim_black": "#f2e5bcff", - "terminal.ansi.red": "#9d0308ff", - "terminal.ansi.bright_red": "#db8b7aff", - "terminal.ansi.dim_red": "#4e1207ff", - "terminal.ansi.green": "#797410ff", - "terminal.ansi.bright_green": "#bfb787ff", - "terminal.ansi.dim_green": "#3e3a11ff", - "terminal.ansi.yellow": "#b57615ff", - "terminal.ansi.bright_yellow": "#e2b88bff", - "terminal.ansi.dim_yellow": "#5c3a12ff", - "terminal.ansi.blue": "#0b6678ff", - "terminal.ansi.bright_blue": "#8fb0baff", - "terminal.ansi.dim_blue": "#14333bff", - "terminal.ansi.magenta": "#8f3e71ff", - "terminal.ansi.bright_magenta": "#c76da0ff", - "terminal.ansi.dim_magenta": "#5c2848ff", - "terminal.ansi.cyan": "#437b59ff", - "terminal.ansi.bright_cyan": "#9fbca8ff", - "terminal.ansi.dim_cyan": "#253e2eff", - "terminal.ansi.white": "#f2e5bcff", - "terminal.ansi.bright_white": "#ffffffff", - "terminal.ansi.dim_white": "#b0a189ff", + "terminal.ansi.black": "#fbf1c7ff", + "terminal.ansi.bright_black": "#928374ff", + "terminal.ansi.dim_black": "#7c6f64ff", + "terminal.ansi.red": "#cc241dff", + "terminal.ansi.bright_red": "#9d0006ff", + "terminal.ansi.dim_red": "#c31c16ff", + "terminal.ansi.green": "#98971aff", + "terminal.ansi.bright_green": "#79740eff", + "terminal.ansi.dim_green": "#929015ff", + "terminal.ansi.yellow": "#d79921ff", + "terminal.ansi.bright_yellow": "#b57614ff", + "terminal.ansi.dim_yellow": "#cf8e1aff", + "terminal.ansi.blue": "#458588ff", + "terminal.ansi.bright_blue": "#076678ff", + "terminal.ansi.dim_blue": "#356f77ff", + "terminal.ansi.magenta": "#b16286ff", + "terminal.ansi.bright_magenta": "#8f3f71ff", + "terminal.ansi.dim_magenta": "#a85580ff", + "terminal.ansi.cyan": "#689d6aff", + "terminal.ansi.bright_cyan": "#427b58ff", + "terminal.ansi.dim_cyan": "#5f9166ff", + "terminal.ansi.white": "#7c6f64ff", + "terminal.ansi.bright_white": "#282828ff", + "terminal.ansi.dim_white": "#282828ff", "link_text.hover": "#0b6678ff", "version_control.added": "#797410ff", "version_control.modified": "#b57615ff", diff --git a/assets/themes/one/one.json b/assets/themes/one/one.json index c72c92471761c473bea05edc37b1f96f18b2f683..13f94991ad44fc997144a3d44527dcbce5231504 100644 --- a/assets/themes/one/one.json +++ b/assets/themes/one/one.json @@ -68,34 +68,34 @@ "editor.active_wrap_guide": "#c8ccd41a", "editor.document_highlight.read_background": "#74ade81a", "editor.document_highlight.write_background": "#555a6366", - "terminal.background": "#282c33ff", - "terminal.foreground": "#dce0e5ff", + "terminal.background": "#282c34ff", + "terminal.foreground": "#abb2bfff", "terminal.bright_foreground": "#dce0e5ff", - "terminal.dim_foreground": "#282c33ff", - "terminal.ansi.black": "#282c33ff", - "terminal.ansi.bright_black": "#525561ff", - "terminal.ansi.dim_black": "#dce0e5ff", - "terminal.ansi.red": "#d07277ff", - "terminal.ansi.bright_red": "#673a3cff", - "terminal.ansi.dim_red": "#eab7b9ff", - "terminal.ansi.green": "#a1c181ff", - "terminal.ansi.bright_green": "#4d6140ff", - "terminal.ansi.dim_green": "#d1e0bfff", - "terminal.ansi.yellow": "#dec184ff", - "terminal.ansi.bright_yellow": "#e5c07bff", - "terminal.ansi.dim_yellow": "#f1dfc1ff", - "terminal.ansi.blue": "#74ade8ff", - "terminal.ansi.bright_blue": "#385378ff", - "terminal.ansi.dim_blue": "#bed5f4ff", - "terminal.ansi.magenta": "#b477cfff", - "terminal.ansi.bright_magenta": "#d6b4e4ff", - "terminal.ansi.dim_magenta": "#612a79ff", - "terminal.ansi.cyan": "#6eb4bfff", - "terminal.ansi.bright_cyan": "#3a565bff", - "terminal.ansi.dim_cyan": "#b9d9dfff", - "terminal.ansi.white": "#dce0e5ff", + "terminal.dim_foreground": "#636d83ff", + "terminal.ansi.black": "#282c34ff", + "terminal.ansi.bright_black": "#636d83ff", + "terminal.ansi.dim_black": "#3b3f4aff", + "terminal.ansi.red": "#e06c75ff", + "terminal.ansi.bright_red": "#EA858Bff", + "terminal.ansi.dim_red": "#a7545aff", + "terminal.ansi.green": "#98c379ff", + "terminal.ansi.bright_green": "#AAD581ff", + "terminal.ansi.dim_green": "#6d8f59ff", + "terminal.ansi.yellow": "#e5c07bff", + "terminal.ansi.bright_yellow": "#FFD885ff", + "terminal.ansi.dim_yellow": "#b8985bff", + "terminal.ansi.blue": "#61afefff", + "terminal.ansi.bright_blue": "#85C1FFff", + "terminal.ansi.dim_blue": "#457cadff", + "terminal.ansi.magenta": "#c678ddff", + "terminal.ansi.bright_magenta": "#D398EBff", + "terminal.ansi.dim_magenta": "#8d54a0ff", + "terminal.ansi.cyan": "#56b6c2ff", + "terminal.ansi.bright_cyan": "#6ED5DEff", + "terminal.ansi.dim_cyan": "#3c818aff", + "terminal.ansi.white": "#abb2bfff", "terminal.ansi.bright_white": "#fafafaff", - "terminal.ansi.dim_white": "#575d65ff", + "terminal.ansi.dim_white": "#8f969bff", "link_text.hover": "#74ade8ff", "version_control.added": "#27a657ff", "version_control.modified": "#d3b020ff", @@ -473,33 +473,33 @@ "editor.document_highlight.read_background": "#5c78e225", "editor.document_highlight.write_background": "#a3a3a466", "terminal.background": "#fafafaff", - "terminal.foreground": "#242529ff", - "terminal.bright_foreground": "#242529ff", - "terminal.dim_foreground": "#fafafaff", - "terminal.ansi.black": "#242529ff", - "terminal.ansi.bright_black": "#747579ff", - "terminal.ansi.dim_black": "#97979aff", - "terminal.ansi.red": "#d36151ff", - "terminal.ansi.bright_red": "#f0b0a4ff", - "terminal.ansi.dim_red": "#6f312aff", - "terminal.ansi.green": "#669f59ff", - "terminal.ansi.bright_green": "#b2cfa9ff", - "terminal.ansi.dim_green": "#354d2eff", - "terminal.ansi.yellow": "#dec184ff", - "terminal.ansi.bright_yellow": "#826221ff", - "terminal.ansi.dim_yellow": "#786441ff", - "terminal.ansi.blue": "#5c78e2ff", - "terminal.ansi.bright_blue": "#b5baf2ff", - "terminal.ansi.dim_blue": "#2d3d75ff", - "terminal.ansi.magenta": "#984ea5ff", - "terminal.ansi.bright_magenta": "#cea6d3ff", - "terminal.ansi.dim_magenta": "#4b2a50ff", - "terminal.ansi.cyan": "#3a82b7ff", - "terminal.ansi.bright_cyan": "#a3bedaff", - "terminal.ansi.dim_cyan": "#254058ff", - "terminal.ansi.white": "#fafafaff", + "terminal.foreground": "#2a2c33ff", + "terminal.bright_foreground": "#2a2c33ff", + "terminal.dim_foreground": "#bbbbbbff", + "terminal.ansi.black": "#000000ff", + "terminal.ansi.bright_black": "#000000ff", + "terminal.ansi.dim_black": "#555555ff", + "terminal.ansi.red": "#de3e35ff", + "terminal.ansi.bright_red": "#de3e35ff", + "terminal.ansi.dim_red": "#9c2b26ff", + "terminal.ansi.green": "#3f953aff", + "terminal.ansi.bright_green": "#3f953aff", + "terminal.ansi.dim_green": "#2b6927ff", + "terminal.ansi.yellow": "#d2b67cff", + "terminal.ansi.bright_yellow": "#d2b67cff", + "terminal.ansi.dim_yellow": "#a48c5aff", + "terminal.ansi.blue": "#2f5af3ff", + "terminal.ansi.bright_blue": "#2f5af3ff", + "terminal.ansi.dim_blue": "#2140abff", + "terminal.ansi.magenta": "#950095ff", + "terminal.ansi.bright_magenta": "#a00095ff", + "terminal.ansi.dim_magenta": "#6a006aff", + "terminal.ansi.cyan": "#3f953aff", + "terminal.ansi.bright_cyan": "#3f953aff", + "terminal.ansi.dim_cyan": "#2b6927ff", + "terminal.ansi.white": "#bbbbbbff", "terminal.ansi.bright_white": "#ffffffff", - "terminal.ansi.dim_white": "#aaaaaaff", + "terminal.ansi.dim_white": "#888888ff", "link_text.hover": "#5c78e2ff", "version_control.added": "#27a657ff", "version_control.modified": "#d3b020ff", diff --git a/clippy.toml b/clippy.toml index 0ce7a6cd68d4e8210788eb7a67aa06c742cc8274..9dd246074a06c4db7b66eff7a83ef68e3612c378 100644 --- a/clippy.toml +++ b/clippy.toml @@ -14,6 +14,7 @@ disallowed-methods = [ { path = "std::process::Command::stderr", reason = "`smol::process::Command::from()` does not preserve stdio configuration", replacement = "smol::process::Command::stderr" }, { path = "serde_json::from_reader", reason = "Parsing from a buffer is much slower than first reading the buffer into a Vec/String, see https://github.com/serde-rs/json/issues/160#issuecomment-253446892. Use `serde_json::from_slice` instead." }, { path = "serde_json_lenient::from_reader", reason = "Parsing from a buffer is much slower than first reading the buffer into a Vec/String, see https://github.com/serde-rs/json/issues/160#issuecomment-253446892, Use `serde_json_lenient::from_slice` instead." }, + { path = "cocoa::foundation::NSString::alloc", reason = "NSString must be autoreleased to avoid memory leaks. Use `ns_string()` helper instead." }, ] disallowed-types = [ # { path = "std::collections::HashMap", replacement = "collections::HashMap" }, diff --git a/crates/acp_thread/Cargo.toml b/crates/acp_thread/Cargo.toml index 8ef6f1a52c8b207658d59a1e6b877964df9e42ce..70f2e4d259f1611fb42ebc0b064d278c8b3b9c4d 100644 --- a/crates/acp_thread/Cargo.toml +++ b/crates/acp_thread/Cargo.toml @@ -46,6 +46,7 @@ url.workspace = true util.workspace = true uuid.workspace = true watch.workspace = true +urlencoding.workspace = true [dev-dependencies] env_logger.workspace = true diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index b96ef1d898086a0b4b9336a21d1d8369fea4ad6c..a994cc8e57e4456ec57092b2257269b104af74c7 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), @@ -182,6 +192,7 @@ pub struct ToolCall { pub locations: Vec, pub resolved_locations: Vec>, pub raw_input: Option, + pub raw_input_markdown: Option>, pub raw_output: Option, } @@ -212,6 +223,11 @@ impl ToolCall { } } + let raw_input_markdown = tool_call + .raw_input + .as_ref() + .and_then(|input| markdown_for_raw_output(input, &language_registry, cx)); + let result = Self { id: tool_call.tool_call_id, label: cx @@ -222,6 +238,7 @@ impl ToolCall { resolved_locations: Vec::default(), status, raw_input: tool_call.raw_input, + raw_input_markdown, raw_output: tool_call.raw_output, }; Ok(result) @@ -297,6 +314,7 @@ impl ToolCall { } if let Some(raw_input) = raw_input { + self.raw_input_markdown = markdown_for_raw_output(&raw_input, &language_registry, cx); self.raw_input = Some(raw_input); } @@ -1184,6 +1202,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 +1222,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 +1240,7 @@ impl AcpThread { content, chunks: vec![chunk], checkpoint: None, + indented, }), cx, ); @@ -1221,12 +1252,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 +1300,7 @@ impl AcpThread { self.push_entry( AgentThreadEntry::AssistantMessage(AssistantMessage { chunks: vec![chunk], + indented, }), cx, ); @@ -1317,6 +1363,7 @@ impl AcpThread { locations: Vec::new(), resolved_locations: Vec::new(), raw_input: None, + raw_input_markdown: None, raw_output: None, }; self.push_entry(AgentThreadEntry::ToolCall(failed_tool_call), cx); @@ -1372,7 +1419,7 @@ impl AcpThread { let path_style = self.project.read(cx).path_style(cx); let id = update.tool_call_id.clone(); - let agent = self.connection().telemetry_id(); + let agent_telemetry_id = self.connection().telemetry_id(); let session = self.session_id(); if let ToolCallStatus::Completed | ToolCallStatus::Failed = status { let status = if matches!(status, ToolCallStatus::Completed) { @@ -1380,7 +1427,12 @@ impl AcpThread { } else { "failed" }; - telemetry::event!("Agent Tool Call Completed", agent, session, status); + telemetry::event!( + "Agent Tool Call Completed", + agent_telemetry_id, + session, + status + ); } if let Some(ix) = self.index_for_tool_call(&id) { @@ -1699,6 +1751,7 @@ impl AcpThread { content: block, chunks: message, checkpoint: None, + indented: false, }), cx, ); @@ -3556,8 +3609,8 @@ mod tests { } impl AgentConnection for FakeAgentConnection { - fn telemetry_id(&self) -> &'static str { - "fake" + fn telemetry_id(&self) -> SharedString { + "fake".into() } fn auth_methods(&self) -> &[acp::AuthMethod] { diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index 8213786a182e1d93d1bfc1a8918a8830ecaa754b..a670ba601159ec323ad2c88695c30bf4aeae4118 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -20,7 +20,7 @@ impl UserMessageId { } pub trait AgentConnection { - fn telemetry_id(&self) -> &'static str; + fn telemetry_id(&self) -> SharedString; fn new_thread( self: Rc, @@ -202,6 +202,12 @@ pub trait AgentModelSelector: 'static { fn should_render_footer(&self) -> bool { false } + + /// Whether this selector supports the favorites feature. + /// Only the native agent uses the model ID format that maps to settings. + fn supports_favorites(&self) -> bool { + false + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -239,6 +245,10 @@ impl AgentModelList { AgentModelList::Grouped(groups) => groups.is_empty(), } } + + pub fn is_flat(&self) -> bool { + matches!(self, AgentModelList::Flat(_)) + } } #[cfg(feature = "test-support")] @@ -322,8 +332,8 @@ mod test_support { } impl AgentConnection for StubAgentConnection { - fn telemetry_id(&self) -> &'static str { - "stub" + fn telemetry_id(&self) -> SharedString { + "stub".into() } fn auth_methods(&self) -> &[acp::AuthMethod] { diff --git a/crates/acp_thread/src/diff.rs b/crates/acp_thread/src/diff.rs index f17e9d0fce404483ae99efc95bf666586c1f644b..cae1aad90810c217324659d29c065af443494933 100644 --- a/crates/acp_thread/src/diff.rs +++ b/crates/acp_thread/src/diff.rs @@ -166,7 +166,7 @@ impl Diff { } pub fn has_revealed_range(&self, cx: &App) -> bool { - self.multibuffer().read(cx).excerpt_paths().next().is_some() + self.multibuffer().read(cx).paths().next().is_some() } pub fn needs_update(&self, old_text: &str, new_text: &str, cx: &App) -> bool { diff --git a/crates/acp_thread/src/mention.rs b/crates/acp_thread/src/mention.rs index c1b7032cfaa904764055bb79a3cac7e7ac74b0c1..3e2e53fb7fbdf581b45566bd747cfcbfc1c0a004 100644 --- a/crates/acp_thread/src/mention.rs +++ b/crates/acp_thread/src/mention.rs @@ -4,12 +4,14 @@ use file_icons::FileIcons; use prompt_store::{PromptId, UserPromptId}; use serde::{Deserialize, Serialize}; use std::{ + borrow::Cow, fmt, ops::RangeInclusive, path::{Path, PathBuf}, }; use ui::{App, IconName, SharedString}; use url::Url; +use urlencoding::decode; use util::paths::PathStyle; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Hash)] @@ -74,11 +76,13 @@ impl MentionUri { let path = url.path(); match url.scheme() { "file" => { - let path = if path_style.is_windows() { + let normalized = if path_style.is_windows() { path.trim_start_matches("/") } else { path }; + let decoded = decode(normalized).unwrap_or(Cow::Borrowed(normalized)); + let path = decoded.as_ref(); if let Some(fragment) = url.fragment() { let line_range = parse_line_range(fragment)?; @@ -406,6 +410,19 @@ mod tests { assert_eq!(parsed.to_uri().to_string(), selection_uri); } + #[test] + fn test_parse_file_uri_with_non_ascii() { + let file_uri = uri!("file:///path/to/%E6%97%A5%E6%9C%AC%E8%AA%9E.txt"); + let parsed = MentionUri::parse(file_uri, PathStyle::local()).unwrap(); + match &parsed { + MentionUri::File { abs_path } => { + assert_eq!(abs_path, Path::new(path!("/path/to/日本語.txt"))); + } + _ => panic!("Expected File variant"), + } + assert_eq!(parsed.to_uri().to_string(), file_uri); + } + #[test] fn test_parse_untitled_selection_uri() { let selection_uri = uri!("zed:///agent/untitled-buffer#L1:10"); diff --git a/crates/acp_thread/src/terminal.rs b/crates/acp_thread/src/terminal.rs index 2da4125209d3bcf902d23380c5273d9b31902905..f70e044fbc1b380768dbcd807f1833f6fb5cd48b 100644 --- a/crates/acp_thread/src/terminal.rs +++ b/crates/acp_thread/src/terminal.rs @@ -187,8 +187,10 @@ pub async fn create_terminal_entity( Default::default() }; - // Disables paging for `git` and hopefully other commands + // Disable pagers so agent/terminal commands don't hang behind interactive UIs env.insert("PAGER".into(), "".into()); + // Override user core.pager (e.g. delta) which Git prefers over PAGER + env.insert("GIT_PAGER".into(), "cat".into()); env.extend(env_vars); // Use remote shell or default system shell, as appropriate diff --git a/crates/acp_tools/src/acp_tools.rs b/crates/acp_tools/src/acp_tools.rs index 0905effce38d1bfd4fa18e1d00169d6c7ef6c2d7..b0d30367da0634dc82f8db96fc099e268aa4790e 100644 --- a/crates/acp_tools/src/acp_tools.rs +++ b/crates/acp_tools/src/acp_tools.rs @@ -371,13 +371,13 @@ impl AcpTools { syntax: cx.theme().syntax().clone(), code_block_overflow_x_scroll: true, code_block: StyleRefinement { - text: Some(TextStyleRefinement { + text: TextStyleRefinement { font_family: Some( theme_settings.buffer_font.family.clone(), ), font_size: Some((base_size * 0.8).into()), ..Default::default() - }), + }, ..Default::default() }, ..Default::default() diff --git a/crates/action_log/src/action_log.rs b/crates/action_log/src/action_log.rs index 80c9438bc9f8051cb58357e56a82b5307fd20b75..6eb18a4f12325f0c181928f99b4eb921265dbf9c 100644 --- a/crates/action_log/src/action_log.rs +++ b/crates/action_log/src/action_log.rs @@ -777,7 +777,7 @@ impl ActionLog { #[derive(Clone)] pub struct ActionLogTelemetry { - pub agent_telemetry_id: &'static str, + pub agent_telemetry_id: SharedString, pub session_id: Arc, } diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index aec0767c25422dbfeae6fdddcf33e54f8045995c..43ed3b90f3556eb24e45440a7fe0038e7a1b9535 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, AgentModelSelector}; +use acp_thread::{AcpThread, AgentModelSelector, UserMessageId}; use agent_client_protocol as acp; use anyhow::{Context as _, Result, anyhow}; use chrono::{DateTime, Utc}; -use collections::{HashSet, IndexMap}; +use collections::{HashMap, HashSet, IndexMap}; use fs::Fs; use futures::channel::{mpsc, oneshot}; use futures::future::Shared; @@ -33,12 +33,12 @@ use gpui::{ use language_model::{LanguageModel, LanguageModelProvider, LanguageModelRegistry}; use project::{Project, ProjectItem, ProjectPath, Worktree}; use prompt_store::{ - ProjectContext, PromptStore, RulesFileContext, UserRulesContext, WorktreeContext, + ProjectContext, PromptStore, RULES_FILE_NAMES, RulesFileContext, UserRulesContext, + WorktreeContext, }; 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; @@ -51,18 +51,6 @@ pub struct ProjectSnapshot { pub timestamp: DateTime, } -const RULES_FILE_NAMES: [&str; 9] = [ - ".rules", - ".cursorrules", - ".windsurfrules", - ".clinerules", - ".github/copilot-instructions.md", - "CLAUDE.md", - "AGENT.md", - "AGENTS.md", - "GEMINI.md", -]; - pub struct RulesLoadingError { pub message: SharedString, } @@ -263,12 +251,24 @@ impl NativeAgent { .await; cx.new(|cx| { + let context_server_store = project.read(cx).context_server_store(); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx)); + let mut subscriptions = vec![ cx.subscribe(&project, Self::handle_project_event), cx.subscribe( &LanguageModelRegistry::global(cx), Self::handle_models_updated_event, ), + cx.subscribe( + &context_server_store, + Self::handle_context_server_store_updated, + ), + cx.subscribe( + &context_server_registry, + Self::handle_context_server_registry_event, + ), ]; if let Some(prompt_store) = prompt_store.as_ref() { subscriptions.push(cx.subscribe(prompt_store, Self::handle_prompts_updated_event)) @@ -277,16 +277,14 @@ impl NativeAgent { let (project_context_needs_refresh_tx, project_context_needs_refresh_rx) = watch::channel(()); Self { - sessions: HashMap::new(), + sessions: HashMap::default(), history, project_context: cx.new(|_| project_context), project_context_needs_refresh: project_context_needs_refresh_tx, _maintain_project_context: cx.spawn(async move |this, cx| { Self::maintain_project_context(this, project_context_needs_refresh_rx, cx).await }), - context_server_registry: cx.new(|cx| { - ContextServerRegistry::new(project.read(cx).context_server_store(), cx) - }), + context_server_registry, templates, models: LanguageModels::new(cx), project, @@ -355,6 +353,9 @@ impl NativeAgent { pending_save: Task::ready(()), }, ); + + self.update_available_commands(cx); + acp_thread } @@ -425,10 +426,7 @@ impl NativeAgent { .into_iter() .flat_map(|(contents, prompt_metadata)| match contents { Ok(contents) => Some(UserRulesContext { - uuid: match prompt_metadata.id { - prompt_store::PromptId::User { uuid } => uuid, - prompt_store::PromptId::EditWorkflow => return None, - }, + uuid: prompt_metadata.id.as_user()?, title: prompt_metadata.title.map(|title| title.to_string()), contents, }), @@ -622,6 +620,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, @@ -720,6 +811,102 @@ impl NativeAgent { history.update(cx, |history, cx| history.reload(cx)).ok(); }); } + + fn send_mcp_prompt( + &self, + message_id: UserMessageId, + session_id: agent_client_protocol::SessionId, + prompt_name: String, + server_id: ContextServerId, + arguments: HashMap, + 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 @@ -854,6 +1041,39 @@ impl NativeAgentConnection { } } +struct Command<'a> { + prompt_name: &'a str, + arg_value: &'a str, + explicit_server_id: Option<&'a str>, +} + +impl<'a> Command<'a> { + fn parse(prompt: &'a [acp::ContentBlock]) -> Option { + 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, @@ -944,11 +1164,15 @@ 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 { - fn telemetry_id(&self) -> &'static str { - "zed" + fn telemetry_id(&self) -> SharedString { + "zed".into() } fn new_thread( @@ -1019,6 +1243,47 @@ impl acp_thread::AgentConnection for NativeAgentConnection { let session_id = params.session_id.clone(); log::info!("Received prompt request for session: {}", session_id); log::debug!("Prompt blocks count: {}", params.prompt.len()); + + if let Some(parsed_command) = Command::parse(¶ms.prompt) { + let registry = self.0.read(cx).context_server_registry.read(cx); + + let explicit_server_id = parsed_command + .explicit_server_id + .map(|server_id| ContextServerId(server_id.into())); + + if let Some(prompt) = + registry.find_prompt(explicit_server_id.as_ref(), parsed_command.prompt_name) + { + let arguments = if !parsed_command.arg_value.is_empty() + && let Some(arg_name) = prompt + .prompt + .arguments + .as_ref() + .and_then(|args| args.first()) + .map(|arg| arg.name.clone()) + { + HashMap::from_iter([(arg_name, parsed_command.arg_value.to_string())]) + } else { + Default::default() + }; + + let prompt_name = prompt.prompt.name.clone(); + let server_id = prompt.server_id.clone(); + + return self.0.update(cx, |agent, cx| { + agent.send_mcp_prompt( + id, + session_id.clone(), + prompt_name, + server_id, + arguments, + params.prompt, + cx, + ) + }); + }; + }; + let path_style = self.0.read(cx).project.read(cx).path_style(cx); self.run_turn(session_id, cx, move |thread, cx| { @@ -1219,6 +1484,15 @@ impl TerminalHandle for AcpTerminalHandle { self.terminal .read_with(cx, |term, cx| term.current_output(cx)) } + + fn kill(&self, cx: &AsyncApp) -> Result<()> { + cx.update(|cx| { + self.terminal.update(cx, |terminal, cx| { + terminal.kill(cx); + }); + })?; + Ok(()) + } } #[cfg(test)] @@ -1606,3 +1880,35 @@ mod internal_tests { }); } } + +fn mcp_message_content_to_acp_content_block( + content: context_server::types::MessageContent, +) -> acp::ContentBlock { + match content { + context_server::types::MessageContent::Text { + text, + annotations: _, + } => text.into(), + context_server::types::MessageContent::Image { + data, + mime_type, + annotations: _, + } => acp::ContentBlock::Image(acp::ImageContent::new(data, mime_type)), + context_server::types::MessageContent::Audio { + data, + mime_type, + annotations: _, + } => acp::ContentBlock::Audio(acp::AudioContent::new(data, mime_type)), + context_server::types::MessageContent::Resource { + resource, + annotations: _, + } => { + let mut link = + acp::ResourceLink::new(resource.uri.to_string(), resource.uri.to_string()); + if let Some(mime_type) = resource.mime_type { + link = link.mime_type(mime_type); + } + acp::ContentBlock::ResourceLink(link) + } + } +} diff --git a/crates/agent/src/edit_agent/evals.rs b/crates/agent/src/edit_agent/evals.rs index edf8a0f671d231b3bfbd29526c256388fd41f85a..01c81e0103a2d3624c7e8eb9b9c587726fcc4876 100644 --- a/crates/agent/src/edit_agent/evals.rs +++ b/crates/agent/src/edit_agent/evals.rs @@ -1343,6 +1343,7 @@ fn run_eval(eval: EvalInput) -> eval_utils::EvalOutput { let test = EditAgentTest::new(&mut cx).await; test.eval(eval, &mut cx).await }); + cx.quit(); match result { Ok(output) => eval_utils::EvalOutput { data: output.to_string(), diff --git a/crates/agent/src/history_store.rs b/crates/agent/src/history_store.rs index 5a1b923d139060ed7df679a69d96928d03559c9d..c455f73316e3fc7a641fa8a31ac0ad766a2ae584 100644 --- a/crates/agent/src/history_store.rs +++ b/crates/agent/src/history_store.rs @@ -216,14 +216,10 @@ impl HistoryStore { } pub fn reload(&self, cx: &mut Context) { - let database_future = ThreadsDatabase::connect(cx); + let database_connection = ThreadsDatabase::connect(cx); cx.spawn(async move |this, cx| { - let threads = database_future - .await - .map_err(|err| anyhow!(err))? - .list_threads() - .await?; - + let database = database_connection.await; + let threads = database.map_err(|err| anyhow!(err))?.list_threads().await?; this.update(cx, |this, cx| { if this.recently_opened_entries.len() < MAX_RECENTLY_OPENED_ENTRIES { for thread in threads @@ -344,7 +340,8 @@ impl HistoryStore { fn load_recently_opened_entries(cx: &AsyncApp) -> Task>> { cx.background_spawn(async move { if cfg!(any(feature = "test-support", test)) { - anyhow::bail!("history store does not persist in tests"); + log::warn!("history store does not persist in tests"); + return Ok(VecDeque::new()); } let json = KEY_VALUE_STORE .read_kvp(RECENTLY_OPENED_THREADS_KEY)? diff --git a/crates/agent/src/native_agent_server.rs b/crates/agent/src/native_agent_server.rs index 4c78c5a3f85b6628f9784fe7ecbadc8531b017d0..a9ade8141a678329e0dd8dad9808e55eee3c382b 100644 --- a/crates/agent/src/native_agent_server.rs +++ b/crates/agent/src/native_agent_server.rs @@ -21,10 +21,6 @@ impl NativeAgentServer { } impl AgentServer for NativeAgentServer { - fn telemetry_id(&self) -> &'static str { - "zed" - } - fn name(&self) -> SharedString { "Zed Agent".into() } diff --git a/crates/agent/src/templates/system_prompt.hbs b/crates/agent/src/templates/system_prompt.hbs index 4620647135631fdb367b0dc2604e89770a938c07..2477e46a85183813f61bb60d7e3de7f119a4f00c 100644 --- a/crates/agent/src/templates/system_prompt.hbs +++ b/crates/agent/src/templates/system_prompt.hbs @@ -16,7 +16,7 @@ You are a highly skilled software engineer with extensive knowledge in many prog 3. DO NOT use tools to access items that are already available in the context section. 4. Use only the tools that are currently available. 5. DO NOT use a tool that is not available just because it appears in the conversation. This means the user turned it off. -6. NEVER run commands that don't terminate on their own such as web servers (like `npm run start`, `npm run dev`, `python -m http.server`, etc) or file watchers. +6. When running commands that may run indefinitely or for a long time (such as build scripts, tests, servers, or file watchers), specify `timeout_ms` to bound runtime. If the command times out, the user can always ask you to run it again with a longer timeout or no timeout if they're willing to wait or cancel manually. 7. Avoid HTML entity escaping - use plain characters instead. ## Searching and Reading diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index 9ff870353279635957cd2b84f418f881c3444aa2..45028902e467fe67945ddf444c9ae417dcaed654 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -9,14 +9,16 @@ use collections::IndexMap; use context_server::{ContextServer, ContextServerCommand, ContextServerId}; use fs::{FakeFs, Fs}; use futures::{ - StreamExt, + FutureExt as _, StreamExt, channel::{ mpsc::{self, UnboundedReceiver}, oneshot, }, + future::{Fuse, Shared}, }; use gpui::{ - App, AppContext, Entity, Task, TestAppContext, UpdateGlobal, http_client::FakeHttpClient, + App, AppContext, AsyncApp, Entity, Task, TestAppContext, UpdateGlobal, + http_client::FakeHttpClient, }; use indoc::indoc; use language_model::{ @@ -35,12 +37,109 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::json; use settings::{Settings, SettingsStore}; -use std::{path::Path, rc::Rc, sync::Arc, time::Duration}; +use std::{ + path::Path, + pin::Pin, + rc::Rc, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; use util::path; mod test_tools; use test_tools::*; +fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + }); +} + +struct FakeTerminalHandle { + killed: Arc, + wait_for_exit: Shared>, + output: acp::TerminalOutputResponse, + id: acp::TerminalId, +} + +impl FakeTerminalHandle { + fn new_never_exits(cx: &mut App) -> Self { + let killed = Arc::new(AtomicBool::new(false)); + + let killed_for_task = killed.clone(); + let wait_for_exit = cx + .spawn(async move |cx| { + loop { + if killed_for_task.load(Ordering::SeqCst) { + return acp::TerminalExitStatus::new(); + } + cx.background_executor() + .timer(Duration::from_millis(1)) + .await; + } + }) + .shared(); + + Self { + killed, + wait_for_exit, + output: acp::TerminalOutputResponse::new("partial output".to_string(), false), + id: acp::TerminalId::new("fake_terminal".to_string()), + } + } + + fn was_killed(&self) -> bool { + self.killed.load(Ordering::SeqCst) + } +} + +impl crate::TerminalHandle for FakeTerminalHandle { + fn id(&self, _cx: &AsyncApp) -> Result { + Ok(self.id.clone()) + } + + fn current_output(&self, _cx: &AsyncApp) -> Result { + Ok(self.output.clone()) + } + + fn wait_for_exit(&self, _cx: &AsyncApp) -> Result>> { + Ok(self.wait_for_exit.clone()) + } + + fn kill(&self, _cx: &AsyncApp) -> Result<()> { + self.killed.store(true, Ordering::SeqCst); + Ok(()) + } +} + +struct FakeThreadEnvironment { + handle: Rc, +} + +impl crate::ThreadEnvironment for FakeThreadEnvironment { + fn create_terminal( + &self, + _command: String, + _cwd: Option, + _output_byte_limit: Option, + _cx: &mut AsyncApp, + ) -> Task>> { + Task::ready(Ok(self.handle.clone() as Rc)) + } +} + +fn always_allow_tools(cx: &mut TestAppContext) { + cx.update(|cx| { + let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + settings.always_allow_tool_actions = true; + agent_settings::AgentSettings::override_global(settings, cx); + }); +} + #[gpui::test] async fn test_echo(cx: &mut TestAppContext) { let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; @@ -71,6 +170,120 @@ async fn test_echo(cx: &mut TestAppContext) { assert_eq!(stop_events(events), vec![acp::StopReason::EndTurn]); } +#[gpui::test] +async fn test_terminal_tool_timeout_kills_handle(cx: &mut TestAppContext) { + init_test(cx); + always_allow_tools(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + + let handle = Rc::new(cx.update(|cx| FakeTerminalHandle::new_never_exits(cx))); + let environment = Rc::new(FakeThreadEnvironment { + handle: handle.clone(), + }); + + #[allow(clippy::arc_with_non_send_sync)] + let tool = Arc::new(crate::TerminalTool::new(project, environment)); + let (event_stream, mut rx) = crate::ToolCallEventStream::test(); + + let task = cx.update(|cx| { + tool.run( + crate::TerminalToolInput { + command: "sleep 1000".to_string(), + cd: ".".to_string(), + timeout_ms: Some(5), + }, + event_stream, + cx, + ) + }); + + let update = rx.expect_update_fields().await; + assert!( + update.content.iter().any(|blocks| { + blocks + .iter() + .any(|c| matches!(c, acp::ToolCallContent::Terminal(_))) + }), + "expected tool call update to include terminal content" + ); + + let mut task_future: Pin>>>> = Box::pin(task.fuse()); + + let deadline = std::time::Instant::now() + Duration::from_millis(500); + loop { + if let Some(result) = task_future.as_mut().now_or_never() { + let result = result.expect("terminal tool task should complete"); + + assert!( + handle.was_killed(), + "expected terminal handle to be killed on timeout" + ); + assert!( + result.contains("partial output"), + "expected result to include terminal output, got: {result}" + ); + return; + } + + if std::time::Instant::now() >= deadline { + panic!("timed out waiting for terminal tool task to complete"); + } + + cx.run_until_parked(); + cx.background_executor.timer(Duration::from_millis(1)).await; + } +} + +#[gpui::test] +#[ignore] +async fn test_terminal_tool_without_timeout_does_not_kill_handle(cx: &mut TestAppContext) { + init_test(cx); + always_allow_tools(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + + let handle = Rc::new(cx.update(|cx| FakeTerminalHandle::new_never_exits(cx))); + let environment = Rc::new(FakeThreadEnvironment { + handle: handle.clone(), + }); + + #[allow(clippy::arc_with_non_send_sync)] + let tool = Arc::new(crate::TerminalTool::new(project, environment)); + let (event_stream, mut rx) = crate::ToolCallEventStream::test(); + + let _task = cx.update(|cx| { + tool.run( + crate::TerminalToolInput { + command: "sleep 1000".to_string(), + cd: ".".to_string(), + timeout_ms: None, + }, + event_stream, + cx, + ) + }); + + let update = rx.expect_update_fields().await; + assert!( + update.content.iter().any(|blocks| { + blocks + .iter() + .any(|c| matches!(c, acp::ToolCallContent::Terminal(_))) + }), + "expected tool call update to include terminal content" + ); + + smol::Timer::after(Duration::from_millis(25)).await; + + assert!( + !handle.was_killed(), + "did not expect terminal handle to be killed without a timeout" + ); +} + #[gpui::test] async fn test_thinking(cx: &mut TestAppContext) { let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; @@ -2596,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 4aabf8069bc3380b6908187b28517f99a9548f26..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, @@ -530,6 +537,7 @@ pub trait TerminalHandle { fn id(&self, cx: &AsyncApp) -> Result; fn current_output(&self, cx: &AsyncApp) -> Result; fn wait_for_exit(&self, cx: &AsyncApp) -> Result>>; + fn kill(&self, cx: &AsyncApp) -> Result<()>; } pub trait ThreadEnvironment { @@ -1001,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); @@ -1085,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, @@ -1137,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")] @@ -1649,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(); @@ -1716,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; }; @@ -1965,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, @@ -2658,7 +2744,6 @@ impl From for acp::ContentBlock { fn convert_image(image_content: acp::ImageContent) -> LanguageModelImage { LanguageModelImage { source: image_content.data.into(), - // TODO: make this optional? - size: gpui::Size::new(0.into(), 0.into()), + size: None, } } 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/src/tools/terminal_tool.rs b/crates/agent/src/tools/terminal_tool.rs index 2db4a2d86038579fca62224f3a7c567f93fc6922..f3302fb1894612287bf04acfbfa301188bf853fb 100644 --- a/crates/agent/src/tools/terminal_tool.rs +++ b/crates/agent/src/tools/terminal_tool.rs @@ -1,6 +1,7 @@ use agent_client_protocol as acp; use anyhow::Result; -use gpui::{App, Entity, SharedString, Task}; +use futures::FutureExt as _; +use gpui::{App, AppContext, Entity, SharedString, Task}; use project::Project; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -8,6 +9,7 @@ use std::{ path::{Path, PathBuf}, rc::Rc, sync::Arc, + time::Duration, }; use util::markdown::MarkdownInlineCode; @@ -25,13 +27,17 @@ const COMMAND_OUTPUT_LIMIT: u64 = 16 * 1024; /// /// Do not use this tool for commands that run indefinitely, such as servers (like `npm run start`, `npm run dev`, `python -m http.server`, etc) or file watchers that don't terminate on their own. /// +/// For potentially long-running commands, prefer specifying `timeout_ms` to bound runtime and prevent indefinite hangs. +/// /// Remember that each invocation of this tool will spawn a new shell process, so you can't rely on any state from previous invocations. #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] pub struct TerminalToolInput { /// The one-liner command to execute. - command: String, + pub command: String, /// Working directory for the command. This must be one of the root directories of the project. - cd: String, + pub cd: String, + /// Optional maximum runtime (in milliseconds). If exceeded, the running terminal task is killed. + pub timeout_ms: Option, } pub struct TerminalTool { @@ -116,7 +122,26 @@ impl AgentTool for TerminalTool { acp::ToolCallContent::Terminal(acp::Terminal::new(terminal_id)), ])); - let exit_status = terminal.wait_for_exit(cx)?.await; + let timeout = input.timeout_ms.map(Duration::from_millis); + + let exit_status = match timeout { + Some(timeout) => { + let wait_for_exit = terminal.wait_for_exit(cx)?; + let timeout_task = cx.background_spawn(async move { + smol::Timer::after(timeout).await; + }); + + futures::select! { + status = wait_for_exit.clone().fuse() => status, + _ = timeout_task.fuse() => { + terminal.kill(cx)?; + wait_for_exit.await + } + } + } + None => terminal.wait_for_exit(cx)?.await, + }; + let output = terminal.current_output(cx)?; Ok(process_content(output, &input.command, exit_status)) diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index 153357a79afdaeeb4bf4c9e2b48bee32245ba2ef..e99855fe8a7241468e93f01fe6c7b6fee161f600 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -9,6 +9,8 @@ use futures::io::BufReader; use project::Project; use project::agent_server_store::AgentServerCommand; use serde::Deserialize; +use settings::Settings as _; +use task::ShellBuilder; use util::ResultExt as _; use std::path::PathBuf; @@ -21,7 +23,7 @@ use gpui::{App, AppContext as _, AsyncApp, Entity, SharedString, Task, WeakEntit use acp_thread::{AcpThread, AuthRequired, LoadError, TerminalProviderEvent}; use terminal::TerminalBuilder; -use terminal::terminal_settings::{AlternateScroll, CursorShape}; +use terminal::terminal_settings::{AlternateScroll, CursorShape, TerminalSettings}; #[derive(Debug, Error)] #[error("Unsupported version")] @@ -29,7 +31,7 @@ pub struct UnsupportedVersion; pub struct AcpConnection { server_name: SharedString, - telemetry_id: &'static str, + telemetry_id: SharedString, connection: Rc, sessions: Rc>>, auth_methods: Vec, @@ -54,7 +56,6 @@ pub struct AcpSession { pub async fn connect( server_name: SharedString, - telemetry_id: &'static str, command: AgentServerCommand, root_dir: &Path, default_mode: Option, @@ -64,7 +65,6 @@ pub async fn connect( ) -> Result> { let conn = AcpConnection::stdio( server_name, - telemetry_id, command.clone(), root_dir, default_mode, @@ -81,7 +81,6 @@ const MINIMUM_SUPPORTED_VERSION: acp::ProtocolVersion = acp::ProtocolVersion::V1 impl AcpConnection { pub async fn stdio( server_name: SharedString, - telemetry_id: &'static str, command: AgentServerCommand, root_dir: &Path, default_mode: Option, @@ -89,9 +88,11 @@ impl AcpConnection { is_remote: bool, cx: &mut AsyncApp, ) -> Result { - let mut child = util::command::new_smol_command(&command.path); + let shell = cx.update(|cx| TerminalSettings::get(None, cx).shell.clone())?; + let builder = ShellBuilder::new(&shell, cfg!(windows)).non_interactive(); + let mut child = + builder.build_command(Some(command.path.display().to_string()), &command.args); child - .args(command.args.iter().map(|arg| arg.as_str())) .envs(command.env.iter().flatten()) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) @@ -199,6 +200,13 @@ impl AcpConnection { return Err(UnsupportedVersion.into()); } + let telemetry_id = response + .agent_info + // Use the one the agent provides if we have one + .map(|info| info.name.into()) + // Otherwise, just use the name + .unwrap_or_else(|| server_name.clone()); + Ok(Self { auth_methods: response.auth_methods, root_dir: root_dir.to_owned(), @@ -233,8 +241,8 @@ impl Drop for AcpConnection { } impl AgentConnection for AcpConnection { - fn telemetry_id(&self) -> &'static str { - self.telemetry_id + fn telemetry_id(&self) -> SharedString { + self.telemetry_id.clone() } fn new_thread( diff --git a/crates/agent_servers/src/agent_servers.rs b/crates/agent_servers/src/agent_servers.rs index cf03b71a78b358d7b110c450f769f9645094baaa..46e8508e44f07e4fb3d613e30387d5afd3f38423 100644 --- a/crates/agent_servers/src/agent_servers.rs +++ b/crates/agent_servers/src/agent_servers.rs @@ -56,7 +56,6 @@ impl AgentServerDelegate { pub trait AgentServer: Send { fn logo(&self) -> ui::IconName; fn name(&self) -> SharedString; - fn telemetry_id(&self) -> &'static str; fn default_mode(&self, _cx: &mut App) -> Option { None } diff --git a/crates/agent_servers/src/claude.rs b/crates/agent_servers/src/claude.rs index f49dce59c4282eb278e16ef664c75ed56652de2e..e67ddd5c0698758fdec7c7796b26a1351e9990e5 100644 --- a/crates/agent_servers/src/claude.rs +++ b/crates/agent_servers/src/claude.rs @@ -22,10 +22,6 @@ pub struct AgentServerLoginCommand { } impl AgentServer for ClaudeCode { - fn telemetry_id(&self) -> &'static str { - "claude-code" - } - fn name(&self) -> SharedString { "Claude Code".into() } @@ -83,7 +79,6 @@ impl AgentServer for ClaudeCode { cx: &mut App, ) -> Task, Option)>> { let name = self.name(); - let telemetry_id = self.telemetry_id(); let root_dir = root_dir.map(|root_dir| root_dir.to_string_lossy().into_owned()); let is_remote = delegate.project.read(cx).is_via_remote_server(); let store = delegate.store.downgrade(); @@ -108,7 +103,6 @@ impl AgentServer for ClaudeCode { .await?; let connection = crate::acp::connect( name, - telemetry_id, command, root_dir.as_ref(), default_mode, diff --git a/crates/agent_servers/src/codex.rs b/crates/agent_servers/src/codex.rs index d14d2f0c9aeb499624943962437821d571bc0299..c2b308e48b7a984b0374272c0059286e933916b3 100644 --- a/crates/agent_servers/src/codex.rs +++ b/crates/agent_servers/src/codex.rs @@ -23,10 +23,6 @@ pub(crate) mod tests { } impl AgentServer for Codex { - fn telemetry_id(&self) -> &'static str { - "codex" - } - fn name(&self) -> SharedString { "Codex".into() } @@ -84,7 +80,6 @@ impl AgentServer for Codex { cx: &mut App, ) -> Task, Option)>> { let name = self.name(); - let telemetry_id = self.telemetry_id(); let root_dir = root_dir.map(|root_dir| root_dir.to_string_lossy().into_owned()); let is_remote = delegate.project.read(cx).is_via_remote_server(); let store = delegate.store.downgrade(); @@ -110,7 +105,6 @@ impl AgentServer for Codex { let connection = crate::acp::connect( name, - telemetry_id, command, root_dir.as_ref(), default_mode, diff --git a/crates/agent_servers/src/custom.rs b/crates/agent_servers/src/custom.rs index 634b31e90267e064f0d0df9b6014d279a44a7986..6b981ce8b8198b275e5d9aa05b6fb66431d22e08 100644 --- a/crates/agent_servers/src/custom.rs +++ b/crates/agent_servers/src/custom.rs @@ -1,4 +1,4 @@ -use crate::{AgentServerDelegate, load_proxy_env}; +use crate::{AgentServer, AgentServerDelegate, load_proxy_env}; use acp_thread::AgentConnection; use agent_client_protocol as acp; use anyhow::{Context as _, Result}; @@ -20,11 +20,7 @@ impl CustomAgentServer { } } -impl crate::AgentServer for CustomAgentServer { - fn telemetry_id(&self) -> &'static str { - "custom" - } - +impl AgentServer for CustomAgentServer { fn name(&self) -> SharedString { self.name.clone() } @@ -112,14 +108,12 @@ impl crate::AgentServer for CustomAgentServer { cx: &mut App, ) -> Task, Option)>> { let name = self.name(); - let telemetry_id = self.telemetry_id(); let root_dir = root_dir.map(|root_dir| root_dir.to_string_lossy().into_owned()); let is_remote = delegate.project.read(cx).is_via_remote_server(); let default_mode = self.default_mode(cx); let default_model = self.default_model(cx); let store = delegate.store.downgrade(); let extra_env = load_proxy_env(cx); - cx.spawn(async move |cx| { let (command, root_dir, login) = store .update(cx, |store, cx| { @@ -139,7 +133,6 @@ impl crate::AgentServer for CustomAgentServer { .await?; let connection = crate::acp::connect( name, - telemetry_id, command, root_dir.as_ref(), default_mode, diff --git a/crates/agent_servers/src/gemini.rs b/crates/agent_servers/src/gemini.rs index c1b2efb081551f82752dc15a909eec64ff78d94e..5fea74746aec73f3ea7bb33562244e4a6eea5ba7 100644 --- a/crates/agent_servers/src/gemini.rs +++ b/crates/agent_servers/src/gemini.rs @@ -12,10 +12,6 @@ use project::agent_server_store::GEMINI_NAME; pub struct Gemini; impl AgentServer for Gemini { - fn telemetry_id(&self) -> &'static str { - "gemini-cli" - } - fn name(&self) -> SharedString { "Gemini CLI".into() } @@ -31,7 +27,6 @@ impl AgentServer for Gemini { cx: &mut App, ) -> Task, Option)>> { let name = self.name(); - let telemetry_id = self.telemetry_id(); let root_dir = root_dir.map(|root_dir| root_dir.to_string_lossy().into_owned()); let is_remote = delegate.project.read(cx).is_via_remote_server(); let store = delegate.store.downgrade(); @@ -66,7 +61,6 @@ impl AgentServer for Gemini { let connection = crate::acp::connect( name, - telemetry_id, command, root_dir.as_ref(), default_mode, 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 084ac7c3e7a1be4920126f857145e64b65a255dd..b513ec1a70b6f7ab02382dfa312ea2d4d6a47234 100644 --- a/crates/agent_settings/src/agent_settings.rs +++ b/crates/agent_settings/src/agent_settings.rs @@ -2,14 +2,15 @@ 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; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::{ - DefaultAgentView, DockPosition, LanguageModelParameters, LanguageModelSelection, + DefaultAgentView, DockPosition, DockSide, LanguageModelParameters, LanguageModelSelection, NotifyWhenAgentWaiting, RegisterSetting, Settings, }; @@ -24,13 +25,16 @@ pub struct AgentSettings { pub enabled: bool, pub button: bool, pub dock: DockPosition, + pub agents_panel_dock: DockSide, pub default_width: Pixels, pub default_height: Pixels, pub default_model: Option, pub inline_assistant_model: Option, + pub inline_assistant_use_streaming_tools: bool, 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, @@ -94,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)] @@ -151,13 +162,18 @@ impl Settings for AgentSettings { enabled: agent.enabled.unwrap(), button: agent.button.unwrap(), dock: agent.dock.unwrap(), + agents_panel_dock: agent.agents_panel_dock.unwrap(), default_width: px(agent.default_width.unwrap()), default_height: px(agent.default_height.unwrap()), default_model: Some(agent.default_model.unwrap()), inline_assistant_model: agent.inline_assistant_model, + inline_assistant_use_streaming_tools: agent + .inline_assistant_use_streaming_tools + .unwrap_or(true), 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/Cargo.toml b/crates/agent_ui/Cargo.toml index 048ffab9b72bdecce3754320bf34f1702f021554..8a9633e578a85323f2a289bd83c169a1f5d7f272 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -13,7 +13,7 @@ path = "src/agent_ui.rs" doctest = false [features] -test-support = ["gpui/test-support", "language/test-support", "reqwest_client"] +test-support = ["assistant_text_thread/test-support", "eval_utils", "gpui/test-support", "language/test-support", "reqwest_client", "workspace/test-support", "agent/test-support"] unit-eval = [] [dependencies] @@ -40,6 +40,7 @@ component.workspace = true context_server.workspace = true db.workspace = true editor.workspace = true +eval_utils = { workspace = true, optional = true } extension.workspace = true extension_host.workspace = true feature_flags.workspace = true @@ -71,6 +72,7 @@ postage.workspace = true project.workspace = true prompt_store.workspace = true proto.workspace = true +rand.workspace = true release_channel.workspace = true rope.workspace = true rules_library.workspace = true @@ -84,7 +86,6 @@ smol.workspace = true streaming_diff.workspace = true task.workspace = true telemetry.workspace = true -telemetry_events.workspace = true terminal.workspace = true terminal_view.workspace = true text.workspace = true @@ -95,6 +96,7 @@ ui.workspace = true ui_input.workspace = true url.workspace = true util.workspace = true +uuid.workspace = true watch.workspace = true workspace.workspace = true zed_actions.workspace = true @@ -119,7 +121,6 @@ language_model = { workspace = true, "features" = ["test-support"] } pretty_assertions.workspace = true project = { workspace = true, features = ["test-support"] } semver.workspace = true -rand.workspace = true reqwest_client.workspace = true tree-sitter-md.workspace = true unindent.workspace = true diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs index bc293c98a84540c1e00d9895be2cc05b0bdd08a5..6bed82accf876aaaba0668d366216c3a965ad8cb 100644 --- a/crates/agent_ui/src/acp/message_editor.rs +++ b/crates/agent_ui/src/acp/message_editor.rs @@ -34,7 +34,7 @@ use theme::ThemeSettings; use ui::prelude::*; use util::{ResultExt, debug_panic}; use workspace::{CollaboratorId, Workspace}; -use zed_actions::agent::Chat; +use zed_actions::agent::{Chat, PasteRaw}; pub struct MessageEditor { mention_set: Entity, @@ -543,6 +543,9 @@ impl MessageEditor { } fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context) { + let Some(workspace) = self.workspace.upgrade() else { + return; + }; let editor_clipboard_selections = cx .read_from_clipboard() .and_then(|item| item.entries().first().cloned()) @@ -553,115 +556,127 @@ impl MessageEditor { _ => None, }); - let has_file_context = editor_clipboard_selections - .as_ref() - .is_some_and(|selections| { - selections - .iter() - .any(|sel| sel.file_path.is_some() && sel.line_range.is_some()) - }); + // Insert creases for pasted clipboard selections that: + // 1. Contain exactly one selection + // 2. Have an associated file path + // 3. Span multiple lines (not single-line selections) + // 4. Belong to a file that exists in the current project + let should_insert_creases = util::maybe!({ + let selections = editor_clipboard_selections.as_ref()?; + if selections.len() > 1 { + return Some(false); + } + let selection = selections.first()?; + let file_path = selection.file_path.as_ref()?; + let line_range = selection.line_range.as_ref()?; - if has_file_context { - if let Some((workspace, selections)) = - self.workspace.upgrade().zip(editor_clipboard_selections) - { - cx.stop_propagation(); + if line_range.start() == line_range.end() { + return Some(false); + } - let insertion_target = self - .editor + Some( + workspace .read(cx) - .selections - .newest_anchor() - .start - .text_anchor; - - let project = workspace.read(cx).project().clone(); - for selection in selections { - if let (Some(file_path), Some(line_range)) = - (selection.file_path, selection.line_range) - { - let crease_text = - acp_thread::selection_name(Some(file_path.as_ref()), &line_range); + .project() + .read(cx) + .project_path_for_absolute_path(file_path, cx) + .is_some(), + ) + }) + .unwrap_or(false); - let mention_uri = MentionUri::Selection { - abs_path: Some(file_path.clone()), - line_range: line_range.clone(), - }; + if should_insert_creases && let Some(selections) = editor_clipboard_selections { + cx.stop_propagation(); + let insertion_target = self + .editor + .read(cx) + .selections + .newest_anchor() + .start + .text_anchor; + + let project = workspace.read(cx).project().clone(); + for selection in selections { + if let (Some(file_path), Some(line_range)) = + (selection.file_path, selection.line_range) + { + let crease_text = + acp_thread::selection_name(Some(file_path.as_ref()), &line_range); - let mention_text = mention_uri.as_link().to_string(); - let (excerpt_id, text_anchor, content_len) = - self.editor.update(cx, |editor, cx| { - let buffer = editor.buffer().read(cx); - let snapshot = buffer.snapshot(cx); - let (excerpt_id, _, buffer_snapshot) = - snapshot.as_singleton().unwrap(); - let text_anchor = insertion_target.bias_left(&buffer_snapshot); - - editor.insert(&mention_text, window, cx); - editor.insert(" ", window, cx); - - (*excerpt_id, text_anchor, mention_text.len()) - }); - - let Some((crease_id, tx)) = insert_crease_for_mention( - excerpt_id, - text_anchor, - content_len, - crease_text.into(), - mention_uri.icon_path(cx), - None, - self.editor.clone(), - window, - cx, - ) else { - continue; - }; - drop(tx); - - let mention_task = cx - .spawn({ - let project = project.clone(); - async move |_, cx| { - let project_path = project - .update(cx, |project, cx| { - project.project_path_for_absolute_path(&file_path, cx) - }) - .map_err(|e| e.to_string())? - .ok_or_else(|| "project path not found".to_string())?; - - let buffer = project - .update(cx, |project, cx| { - project.open_buffer(project_path, cx) - }) - .map_err(|e| e.to_string())? - .await - .map_err(|e| e.to_string())?; - - buffer - .update(cx, |buffer, cx| { - let start = Point::new(*line_range.start(), 0) - .min(buffer.max_point()); - let end = Point::new(*line_range.end() + 1, 0) - .min(buffer.max_point()); - let content = - buffer.text_for_range(start..end).collect(); - Mention::Text { - content, - tracked_buffers: vec![cx.entity()], - } - }) - .map_err(|e| e.to_string()) - } - }) - .shared(); + let mention_uri = MentionUri::Selection { + abs_path: Some(file_path.clone()), + line_range: line_range.clone(), + }; + + let mention_text = mention_uri.as_link().to_string(); + let (excerpt_id, text_anchor, content_len) = + self.editor.update(cx, |editor, cx| { + let buffer = editor.buffer().read(cx); + let snapshot = buffer.snapshot(cx); + let (excerpt_id, _, buffer_snapshot) = snapshot.as_singleton().unwrap(); + let text_anchor = insertion_target.bias_left(&buffer_snapshot); + + editor.insert(&mention_text, window, cx); + editor.insert(" ", window, cx); - self.mention_set.update(cx, |mention_set, _cx| { - mention_set.insert_mention(crease_id, mention_uri.clone(), mention_task) + (*excerpt_id, text_anchor, mention_text.len()) }); - } + + let Some((crease_id, tx)) = insert_crease_for_mention( + excerpt_id, + text_anchor, + content_len, + crease_text.into(), + mention_uri.icon_path(cx), + None, + self.editor.clone(), + window, + cx, + ) else { + continue; + }; + drop(tx); + + let mention_task = cx + .spawn({ + let project = project.clone(); + async move |_, cx| { + let project_path = project + .update(cx, |project, cx| { + project.project_path_for_absolute_path(&file_path, cx) + }) + .map_err(|e| e.to_string())? + .ok_or_else(|| "project path not found".to_string())?; + + let buffer = project + .update(cx, |project, cx| project.open_buffer(project_path, cx)) + .map_err(|e| e.to_string())? + .await + .map_err(|e| e.to_string())?; + + buffer + .update(cx, |buffer, cx| { + let start = Point::new(*line_range.start(), 0) + .min(buffer.max_point()); + let end = Point::new(*line_range.end() + 1, 0) + .min(buffer.max_point()); + let content = buffer.text_for_range(start..end).collect(); + Mention::Text { + content, + tracked_buffers: vec![cx.entity()], + } + }) + .map_err(|e| e.to_string()) + } + }) + .shared(); + + self.mention_set.update(cx, |mention_set, _cx| { + mention_set.insert_mention(crease_id, mention_uri.clone(), mention_task) + }); } - return; } + return; } if self.prompt_capabilities.borrow().image @@ -672,6 +687,13 @@ impl MessageEditor { } } + fn paste_raw(&mut self, _: &PasteRaw, window: &mut Window, cx: &mut Context) { + let editor = self.editor.clone(); + window.defer(cx, move |window, cx| { + editor.update(cx, |editor, cx| editor.paste(&Paste, window, cx)); + }); + } + pub fn insert_dragged_files( &mut self, paths: Vec, @@ -949,6 +971,7 @@ impl Render for MessageEditor { .on_action(cx.listener(Self::chat)) .on_action(cx.listener(Self::chat_with_follow)) .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::paste_raw)) .capture_action(cx.listener(Self::paste)) .flex_1() .child({ @@ -1347,7 +1370,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() }); @@ -1569,7 +1592,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) }); @@ -2297,7 +2320,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 f9710ad9b3aac29546dbe66a518a198d9b113385..f3c07250de3cefc798b97d9ffad444489d153219 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::{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() { + 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,75 +309,56 @@ 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() - .when_some(model_info.icon, |this, icon| { - this.child( - Icon::new(icon) - .color(model_icon_color) - .size(IconSize::Small) - ) - }) - .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()) + .when_some(model_info.icon, |this, icon| this.icon(icon)) + .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(), ) } } @@ -343,7 +392,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(); @@ -351,43 +400,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: &HashSet, +) -> 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( @@ -403,9 +466,7 @@ async fn fuzzy_search( let candidates = model_list .iter() .enumerate() - .map(|(ix, model)| { - StringMatchCandidate::new(ix, &format!("{}/{}", model.id, model.name)) - }) + .map(|(ix, model)| StringMatchCandidate::new(ix, model.name.as_ref())) .collect::>(); let mut matches = match_strings( &candidates, @@ -511,6 +572,168 @@ mod tests { } } + fn create_favorites(models: Vec<&str>) -> HashSet { + 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 e2393c11bd6c23b79397abf274fb6539c0c7063f..d6709081863c9545fba4c6e2304f195e77b013df 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::{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") @@ -88,9 +134,7 @@ impl Render for AcpModelSelectorPopover { .ml_0p5(), ) .child(Icon::new(icon).color(Color::Muted).size(IconSize::XSmall)), - move |_window, cx| { - Tooltip::for_action_in("Change Model", &ToggleModelSelector, &focus_handle, cx) - }, + tooltip, gpui::Corner::BottomRight, cx, ) diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 36fb7e9097488f8070b740a63ed67ee74445602a..8364fd8c0f4d8fd55df8f2e74e990e603029db78 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -34,7 +34,7 @@ use language::Buffer; use language_model::LanguageModelRegistry; use markdown::{HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle}; -use project::{Project, ProjectEntryId}; +use project::{AgentServerStore, ExternalAgentServerName, Project, ProjectEntryId}; use prompt_store::{PromptId, PromptStore}; use rope::Point; use settings::{NotifyWhenAgentWaiting, Settings as _, SettingsStore}; @@ -63,14 +63,11 @@ use crate::acp::message_editor::{MessageEditor, MessageEditorEvent}; use crate::agent_diff::AgentDiff; use crate::profile_selector::{ProfileProvider, ProfileSelector}; -use crate::ui::{ - AgentNotification, AgentNotificationEvent, BurnModeTooltip, UnavailableEditingTooltip, - UsageCallout, -}; +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)] @@ -170,7 +167,7 @@ impl ThreadFeedbackState { } } let session_id = thread.read(cx).session_id().clone(); - let agent = thread.read(cx).connection().telemetry_id(); + let agent_telemetry_id = thread.read(cx).connection().telemetry_id(); let task = telemetry.thread_data(&session_id, cx); let rating = match feedback { ThreadFeedback::Positive => "positive", @@ -180,7 +177,7 @@ impl ThreadFeedbackState { let thread = task.await?; telemetry::event!( "Agent Thread Rated", - agent = agent, + agent = agent_telemetry_id, session_id = session_id, rating = rating, thread = thread @@ -207,13 +204,13 @@ impl ThreadFeedbackState { self.comments_editor.take(); let session_id = thread.read(cx).session_id().clone(); - let agent = thread.read(cx).connection().telemetry_id(); + let agent_telemetry_id = thread.read(cx).connection().telemetry_id(); let task = telemetry.thread_data(&session_id, cx); cx.background_spawn(async move { let thread = task.await?; telemetry::event!( "Agent Thread Feedback Comments", - agent = agent, + agent = agent_telemetry_id, session_id = session_id, comments = comments, thread = thread @@ -256,13 +253,14 @@ impl ThreadFeedbackState { editor }); - editor.read(cx).focus_handle(cx).focus(window); + editor.read(cx).focus_handle(cx).focus(window, cx); editor } } pub struct AcpThreadView { agent: Rc, + agent_server_store: Entity, workspace: WeakEntity, project: Entity, thread_state: ThreadState, @@ -333,13 +331,20 @@ impl AcpThreadView { project: Entity, history_store: Entity, prompt_store: Option>, + track_load_event: bool, window: &mut Window, cx: &mut Context, ) -> Self { let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default())); let available_commands = Rc::new(RefCell::new(vec![])); - let placeholder = placeholder_text(agent.name().as_ref(), false); + let agent_server_store = project.read(cx).agent_server_store().clone(); + let agent_display_name = agent_server_store + .read(cx) + .agent_display_name(&ExternalAgentServerName(agent.name())) + .unwrap_or_else(|| agent.name()); + + let placeholder = placeholder_text(agent_display_name.as_ref(), false); let message_editor = cx.new(|cx| { let mut editor = MessageEditor::new( @@ -378,7 +383,6 @@ impl AcpThreadView { ) }); - let agent_server_store = project.read(cx).agent_server_store().clone(); let subscriptions = [ cx.observe_global_in::(window, Self::agent_ui_font_size_changed), cx.observe_global_in::(window, Self::agent_ui_font_size_changed), @@ -391,11 +395,24 @@ impl AcpThreadView { ), ]; - let show_codex_windows_warning = crate::ExternalAgent::parse_built_in(agent.as_ref()) - == Some(crate::ExternalAgent::Codex); + 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(); Self { agent: agent.clone(), + agent_server_store, workspace: workspace.clone(), project: project.clone(), entry_view_state, @@ -404,6 +421,7 @@ impl AcpThreadView { resume_thread.clone(), workspace.clone(), project.clone(), + track_load_event, window, cx, ), @@ -448,6 +466,7 @@ impl AcpThreadView { self.resume_thread_metadata.clone(), self.workspace.clone(), self.project.clone(), + true, window, cx, ); @@ -461,6 +480,7 @@ impl AcpThreadView { resume_thread: Option, workspace: WeakEntity, project: Entity, + track_load_event: bool, window: &mut Window, cx: &mut Context, ) -> ThreadState { @@ -519,6 +539,10 @@ impl AcpThreadView { } }; + if track_load_event { + telemetry::event!("Agent Thread Started", agent = connection.telemetry_id()); + } + let result = if let Some(native_agent) = connection .clone() .downcast::() @@ -665,7 +689,7 @@ impl AcpThreadView { }) }); - this.message_editor.focus_handle(cx).focus(window); + this.message_editor.focus_handle(cx).focus(window, cx); cx.notify(); } @@ -684,7 +708,7 @@ impl AcpThreadView { this.new_server_version_available = Some(new_version.into()); cx.notify(); }) - .log_err(); + .ok(); } } }) @@ -720,7 +744,7 @@ impl AcpThreadView { cx: &mut App, ) { let agent_name = agent.name(); - let (configuration_view, subscription) = if let Some(provider_id) = err.provider_id { + let (configuration_view, subscription) = if let Some(provider_id) = &err.provider_id { let registry = LanguageModelRegistry::global(cx); let sub = window.subscribe(®istry, cx, { @@ -762,12 +786,11 @@ impl AcpThreadView { configuration_view, description: err .description - .clone() .map(|desc| cx.new(|cx| Markdown::new(desc.into(), None, None, cx))), _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(); }) @@ -787,7 +810,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(); } @@ -1071,10 +1094,7 @@ impl AcpThreadView { window.defer(cx, |window, cx| { Self::handle_auth_required( this, - AuthRequired { - description: None, - provider_id: None, - }, + AuthRequired::new(), agent, connection, window, @@ -1133,8 +1153,8 @@ impl AcpThreadView { let Some(thread) = self.thread() else { return; }; - let agent_telemetry_id = self.agent.telemetry_id(); let session_id = thread.read(cx).session_id().clone(); + let agent_telemetry_id = thread.read(cx).connection().telemetry_id(); let thread = thread.downgrade(); if self.should_be_following { self.workspace @@ -1253,7 +1273,7 @@ impl AcpThreadView { } }) }; - self.focus_handle(cx).focus(window); + self.focus_handle(cx).focus(window, cx); cx.notify(); } @@ -1305,11 +1325,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( @@ -1448,7 +1468,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 => { @@ -1483,7 +1503,13 @@ impl AcpThreadView { let has_commands = !available_commands.is_empty(); self.available_commands.replace(available_commands); - let new_placeholder = placeholder_text(self.agent.name().as_ref(), has_commands); + let agent_display_name = self + .agent_server_store + .read(cx) + .agent_display_name(&ExternalAgentServerName(self.agent.name())) + .unwrap_or_else(|| self.agent.name()); + + let new_placeholder = placeholder_text(agent_display_name.as_ref(), has_commands); self.message_editor.update(cx, |editor, cx| { editor.set_placeholder_text(&new_placeholder, window, cx); @@ -1512,6 +1538,7 @@ impl AcpThreadView { else { return; }; + let agent_telemetry_id = connection.telemetry_id(); // Check for the experimental "terminal-auth" _meta field let auth_method = connection.auth_methods().iter().find(|m| m.id == method); @@ -1579,19 +1606,18 @@ impl AcpThreadView { ); cx.notify(); self.auth_task = Some(cx.spawn_in(window, { - let agent = self.agent.clone(); async move |this, cx| { let result = authenticate.await; match &result { Ok(_) => telemetry::event!( "Authenticate Agent Succeeded", - agent = agent.telemetry_id() + agent = agent_telemetry_id ), Err(_) => { telemetry::event!( "Authenticate Agent Failed", - agent = agent.telemetry_id(), + agent = agent_telemetry_id, ) } } @@ -1646,43 +1672,6 @@ impl AcpThreadView { }); return; } - } else if method.0.as_ref() == "anthropic-api-key" { - let registry = LanguageModelRegistry::global(cx); - let provider = registry - .read(cx) - .provider(&language_model::ANTHROPIC_PROVIDER_ID) - .unwrap(); - let this = cx.weak_entity(); - let agent = self.agent.clone(); - let connection = connection.clone(); - window.defer(cx, move |window, cx| { - if !provider.is_authenticated(cx) { - Self::handle_auth_required( - this, - AuthRequired { - description: Some("ANTHROPIC_API_KEY must be set".to_owned()), - provider_id: Some(language_model::ANTHROPIC_PROVIDER_ID), - }, - agent, - connection, - window, - cx, - ); - } else { - this.update(cx, |this, cx| { - this.thread_state = Self::initial_state( - agent, - None, - this.workspace.clone(), - this.project.clone(), - window, - cx, - ) - }) - .ok(); - } - }); - return; } else if method.0.as_ref() == "vertex-ai" && std::env::var("GOOGLE_API_KEY").is_err() && (std::env::var("GOOGLE_CLOUD_PROJECT").is_err() @@ -1730,43 +1719,38 @@ impl AcpThreadView { connection.authenticate(method, cx) }; cx.notify(); - self.auth_task = - Some(cx.spawn_in(window, { - let agent = self.agent.clone(); - async move |this, cx| { - let result = authenticate.await; - - match &result { - Ok(_) => telemetry::event!( - "Authenticate Agent Succeeded", - agent = agent.telemetry_id() - ), - Err(_) => { - telemetry::event!( - "Authenticate Agent Failed", - agent = agent.telemetry_id(), - ) - } + self.auth_task = Some(cx.spawn_in(window, { + async move |this, cx| { + let result = authenticate.await; + + match &result { + Ok(_) => telemetry::event!( + "Authenticate Agent Succeeded", + agent = agent_telemetry_id + ), + Err(_) => { + telemetry::event!("Authenticate Agent Failed", agent = agent_telemetry_id,) } + } - this.update_in(cx, |this, window, cx| { - if let Err(err) = result { - if let ThreadState::Unauthenticated { - pending_auth_method, - .. - } = &mut this.thread_state - { - pending_auth_method.take(); - } - this.handle_thread_error(err, cx); - } else { - this.reset(window, cx); + this.update_in(cx, |this, window, cx| { + if let Err(err) = result { + if let ThreadState::Unauthenticated { + pending_auth_method, + .. + } = &mut this.thread_state + { + pending_auth_method.take(); } - this.auth_task.take() - }) - .ok(); - } - })); + this.handle_thread_error(err, cx); + } else { + this.reset(window, cx); + } + this.auth_task.take() + }) + .ok(); + } + })); } fn spawn_external_agent_login( @@ -1885,6 +1869,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, @@ -1896,10 +1891,11 @@ impl AcpThreadView { let Some(thread) = self.thread() else { return; }; + let agent_telemetry_id = thread.read(cx).connection().telemetry_id(); telemetry::event!( "Agent Tool Call Authorized", - agent = self.agent.telemetry_id(), + agent = agent_telemetry_id, session = thread.read(cx).session_id(), option = option_kind ); @@ -1937,6 +1933,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 @@ -1969,7 +1975,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() @@ -2015,6 +2023,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|{ @@ -2085,10 +2096,23 @@ impl AcpThreadView { .icon_size(IconSize::Small) .icon_color(Color::Muted) .style(ButtonStyle::Transparent) - .tooltip(move |_window, cx| { - cx.new(|_| UnavailableEditingTooltip::new(agent_name.clone())) - .into() - }) + .tooltip(Tooltip::element({ + move |_, _| { + v_flex() + .gap_1() + .child(Label::new("Unavailable Editing")).child( + div().max_w_64().child( + Label::new(format!( + "Editing previous messages is not available for {} yet.", + agent_name.clone() + )) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .into_any_element() + } + })) ) ) } @@ -2096,7 +2120,11 @@ impl AcpThreadView { ) .into_any() } - AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) => { + AgentThreadEntry::AssistantMessage(AssistantMessage { + chunks, + indented: _, + }) => { + let mut is_blank = true; let is_last = entry_ix + 1 == total_entries; let style = default_markdown_style(false, false, window, cx); @@ -2106,52 +2134,101 @@ impl AcpThreadView { .children(chunks.iter().enumerate().filter_map( |(chunk_ix, chunk)| match chunk { AssistantMessageChunk::Message { block } => { - block.markdown().map(|md| { - self.render_markdown(md.clone(), style.clone()) - .into_any_element() + block.markdown().and_then(|md| { + let this_is_blank = md.read(cx).source().trim().is_empty(); + is_blank = is_blank && this_is_blank; + if this_is_blank { + return None; + } + + Some( + self.render_markdown(md.clone(), style.clone()) + .into_any_element(), + ) }) } AssistantMessageChunk::Thought { block } => { - block.markdown().map(|md| { - self.render_thinking_block( - entry_ix, - chunk_ix, - md.clone(), - window, - cx, + block.markdown().and_then(|md| { + let this_is_blank = md.read(cx).source().trim().is_empty(); + is_blank = is_blank && this_is_blank; + if this_is_blank { + return None; + } + + Some( + self.render_thinking_block( + entry_ix, + chunk_ix, + md.clone(), + window, + cx, + ) + .into_any_element(), ) - .into_any_element() }) } }, )) .into_any(); - v_flex() - .px_5() - .py_1p5() - .when(is_last, |this| this.pb_4()) - .w_full() - .text_ui(cx) - .child(message_body) - .into_any() + if is_blank { + Empty.into_any() + } else { + v_flex() + .px_5() + .py_1p5() + .when(is_last, |this| this.pb_4()) + .w_full() + .text_ui(cx) + .child(message_body) + .into_any() + } } 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_5() + .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 { @@ -2354,6 +2431,12 @@ impl AcpThreadView { let is_collapsible = !tool_call.content.is_empty() && !needs_confirmation; let is_open = needs_confirmation || self.expanded_tool_calls.contains(&tool_call.id); + let input_output_header = |label: SharedString| { + Label::new(label) + .size(LabelSize::XSmall) + .color(Color::Muted) + .buffer_font(cx) + }; let tool_output_display = if is_open { @@ -2395,7 +2478,25 @@ impl AcpThreadView { | ToolCallStatus::Completed | ToolCallStatus::Failed | ToolCallStatus::Canceled => v_flex() - .w_full() + .when(!is_edit && !is_terminal_tool, |this| { + this.mt_1p5().w_full().child( + v_flex() + .ml(rems(0.4)) + .px_3p5() + .pb_1() + .gap_1() + .border_l_1() + .border_color(self.tool_card_border_color(cx)) + .child(input_output_header("Raw Input:".into())) + .children(tool_call.raw_input_markdown.clone().map(|input| { + self.render_markdown( + input, + default_markdown_style(false, false, window, cx), + ) + })) + .child(input_output_header("Output:".into())), + ) + }) .children(tool_call.content.iter().enumerate().map( |(content_ix, content)| { div().child(self.render_tool_call_content( @@ -2494,7 +2595,7 @@ impl AcpThreadView { .gap_px() .when(is_collapsible, |this| { this.child( - Disclosure::new(("expand", entry_ix), is_open) + Disclosure::new(("expand-output", entry_ix), is_open) .opened_icon(IconName::ChevronUp) .closed_icon(IconName::ChevronDown) .visible_on_hover(&card_header_id) @@ -2680,20 +2781,20 @@ impl AcpThreadView { let button_id = SharedString::from(format!("tool_output-{:?}", tool_call_id)); v_flex() - .mt_1p5() .gap_2() - .when(!card_layout, |this| { - this.ml(rems(0.4)) - .px_3p5() - .border_l_1() - .border_color(self.tool_card_border_color(cx)) - }) - .when(card_layout, |this| { - this.px_2().pb_2().when(context_ix > 0, |this| { - this.border_t_1() - .pt_2() + .map(|this| { + if card_layout { + this.when(context_ix > 0, |this| { + this.pt_2() + .border_t_1() + .border_color(self.tool_card_border_color(cx)) + }) + } else { + this.ml(rems(0.4)) + .px_3p5() + .border_l_1() .border_color(self.tool_card_border_color(cx)) - }) + } }) .text_xs() .text_color(cx.theme().colors().text_muted) @@ -3414,136 +3515,119 @@ impl AcpThreadView { pending_auth_method: Option<&acp::AuthMethodId>, window: &mut Window, cx: &Context, - ) -> Div { - let show_description = - configuration_view.is_none() && description.is_none() && pending_auth_method.is_none(); - + ) -> impl IntoElement { let auth_methods = connection.auth_methods(); - v_flex().flex_1().size_full().justify_end().child( - v_flex() - .p_2() - .pr_3() - .w_full() - .gap_1() - .border_t_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().status().warning.opacity(0.04)) - .child( - h_flex() - .gap_1p5() - .child( - Icon::new(IconName::Warning) - .color(Color::Warning) - .size(IconSize::Small), - ) - .child(Label::new("Authentication Required").size(LabelSize::Small)), - ) - .children(description.map(|desc| { - div().text_ui(cx).child(self.render_markdown( - desc.clone(), - default_markdown_style(false, false, window, cx), - )) - })) - .children( - configuration_view - .cloned() - .map(|view| div().w_full().child(view)), - ) - .when(show_description, |el| { - el.child( - Label::new(format!( - "You are not currently authenticated with {}.{}", - self.agent.name(), - if auth_methods.len() > 1 { - " Please choose one of the following options:" - } else { - "" - } - )) - .size(LabelSize::Small) - .color(Color::Muted) - .mb_1() - .ml_5(), - ) - }) - .when_some(pending_auth_method, |el, _| { - el.child( - h_flex() - .py_4() - .w_full() - .justify_center() - .gap_1() - .child( - Icon::new(IconName::ArrowCircle) - .size(IconSize::Small) - .color(Color::Muted) - .with_rotate_animation(2), - ) - .child(Label::new("Authenticating…").size(LabelSize::Small)), - ) - }) - .when(!auth_methods.is_empty(), |this| { - this.child( - h_flex() - .justify_end() - .flex_wrap() - .gap_1() - .when(!show_description, |this| { - this.border_t_1() - .mt_1() - .pt_2() - .border_color(cx.theme().colors().border.opacity(0.8)) + let agent_display_name = self + .agent_server_store + .read(cx) + .agent_display_name(&ExternalAgentServerName(self.agent.name())) + .unwrap_or_else(|| self.agent.name()); + + let show_fallback_description = auth_methods.len() > 1 + && configuration_view.is_none() + && description.is_none() + && pending_auth_method.is_none(); + + let auth_buttons = || { + h_flex().justify_end().flex_wrap().gap_1().children( + connection + .auth_methods() + .iter() + .enumerate() + .rev() + .map(|(ix, method)| { + let (method_id, name) = if self.project.read(cx).is_via_remote_server() + && method.id.0.as_ref() == "oauth-personal" + && method.name == "Log in with Google" + { + ("spawn-gemini-cli".into(), "Log in with Gemini CLI".into()) + } else { + (method.id.0.clone(), method.name.clone()) + }; + + let agent_telemetry_id = connection.telemetry_id(); + + Button::new(method_id.clone(), name) + .label_size(LabelSize::Small) + .map(|this| { + if ix == 0 { + this.style(ButtonStyle::Tinted(TintColor::Accent)) + } else { + this.style(ButtonStyle::Outlined) + } }) - .children(connection.auth_methods().iter().enumerate().rev().map( - |(ix, method)| { - let (method_id, name) = if self - .project - .read(cx) - .is_via_remote_server() - && method.id.0.as_ref() == "oauth-personal" - && method.name == "Log in with Google" - { - ("spawn-gemini-cli".into(), "Log in with Gemini CLI".into()) - } else { - (method.id.0.clone(), method.name.clone()) - }; + .when_some(method.description.clone(), |this, description| { + this.tooltip(Tooltip::text(description)) + }) + .on_click({ + cx.listener(move |this, _, window, cx| { + telemetry::event!( + "Authenticate Agent Started", + agent = agent_telemetry_id, + method = method_id + ); - Button::new(method_id.clone(), name) - .label_size(LabelSize::Small) - .map(|this| { - if ix == 0 { - this.style(ButtonStyle::Tinted(TintColor::Warning)) - } else { - this.style(ButtonStyle::Outlined) - } - }) - .when_some( - method.description.clone(), - |this, description| { - this.tooltip(Tooltip::text(description)) - }, - ) - .on_click({ - cx.listener(move |this, _, window, cx| { - telemetry::event!( - "Authenticate Agent Started", - agent = this.agent.telemetry_id(), - method = method_id - ); - - this.authenticate( - acp::AuthMethodId::new(method_id.clone()), - window, - cx, - ) - }) - }) - }, - )), - ) - }), - ) + this.authenticate( + acp::AuthMethodId::new(method_id.clone()), + window, + cx, + ) + }) + }) + }), + ) + }; + + if pending_auth_method.is_some() { + return Callout::new() + .icon(IconName::Info) + .title(format!("Authenticating to {}…", agent_display_name)) + .actions_slot( + Icon::new(IconName::ArrowCircle) + .size(IconSize::Small) + .color(Color::Muted) + .with_rotate_animation(2) + .into_any_element(), + ) + .into_any_element(); + } + + Callout::new() + .icon(IconName::Info) + .title(format!("Authenticate to {}", agent_display_name)) + .when(auth_methods.len() == 1, |this| { + this.actions_slot(auth_buttons()) + }) + .description_slot( + v_flex() + .text_ui(cx) + .map(|this| { + if show_fallback_description { + this.child( + Label::new("Choose one of the following authentication options:") + .size(LabelSize::Small) + .color(Color::Muted), + ) + } else { + this.children( + configuration_view + .cloned() + .map(|view| div().w_full().child(view)), + ) + .children(description.map(|desc| { + self.render_markdown( + desc.clone(), + default_markdown_style(false, false, window, cx), + ) + })) + } + }) + .when(auth_methods.len() > 1, |this| { + this.gap_1().child(auth_buttons()) + }), + ) + .into_any_element() } fn render_load_error( @@ -4033,6 +4117,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)) @@ -4066,7 +4152,6 @@ impl AcpThreadView { .relative() .pr_8() .w_full() - .overflow_x_scroll() .child( h_flex() .id(("file-name-path", index)) @@ -4078,7 +4163,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| { @@ -4200,7 +4292,11 @@ impl AcpThreadView { } })) .on_action(cx.listener(|this, _: &CycleModeSelector, window, cx| { - if let Some(mode_selector) = this.mode_selector() { + if let Some(profile_selector) = this.profile_selector.as_ref() { + profile_selector.update(cx, |profile_selector, cx| { + profile_selector.cycle_profile(cx); + }); + } else if let Some(mode_selector) = this.mode_selector() { mode_selector.update(cx, |mode_selector, cx| { mode_selector.cycle_mode(window, cx); }); @@ -4212,6 +4308,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() @@ -4851,6 +4954,32 @@ impl AcpThreadView { cx.notify(); } + fn scroll_to_most_recent_user_prompt(&mut self, cx: &mut Context) { + let Some(thread) = self.thread() else { + return; + }; + + let entries = thread.read(cx).entries(); + if entries.is_empty() { + return; + } + + // Find the most recent user message and scroll it to the top of the viewport. + // (Fallback: if no user message exists, scroll to the bottom.) + if let Some(ix) = entries + .iter() + .rposition(|entry| matches!(entry, AgentThreadEntry::UserMessage(_))) + { + self.list_state.scroll_to(ListOffset { + item_ix: ix, + offset_in_item: px(0.0), + }); + cx.notify(); + } else { + self.scroll_to_bottom(cx); + } + } + pub fn scroll_to_bottom(&mut self, cx: &mut Context) { if let Some(thread) = self.thread() { let entry_count = thread.read(cx).entries().len(); @@ -4946,8 +5075,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) }) }) @@ -5069,6 +5198,16 @@ impl AcpThreadView { } })); + let scroll_to_recent_user_prompt = + IconButton::new("scroll_to_recent_user_prompt", IconName::ForwardArrow) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) + .icon_color(Color::Ignored) + .tooltip(Tooltip::text("Scroll To Most Recent User Prompt")) + .on_click(cx.listener(move |this, _, _, cx| { + this.scroll_to_most_recent_user_prompt(cx); + })); + let scroll_to_top = IconButton::new("scroll_to_top", IconName::ArrowUp) .shape(ui::IconButtonShape::Square) .icon_size(IconSize::Small) @@ -5145,6 +5284,7 @@ impl AcpThreadView { container .child(open_as_markdown) + .child(scroll_to_recent_user_prompt) .child(scroll_to_top) .into_any_element() } @@ -5376,47 +5516,39 @@ impl AcpThreadView { ) } - fn render_codex_windows_warning(&self, cx: &mut Context) -> Option { - if self.show_codex_windows_warning { - Some( - Callout::new() - .icon(IconName::Warning) - .severity(Severity::Warning) - .title("Codex on Windows") - .description( - "For best performance, run Codex in Windows Subsystem for Linux (WSL2)", - ) - .actions_slot( - Button::new("open-wsl-modal", "Open in WSL") - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .on_click(cx.listener({ - move |_, _, _window, cx| { - #[cfg(windows)] - _window.dispatch_action( - zed_actions::wsl_actions::OpenWsl::default().boxed_clone(), - cx, - ); - cx.notify(); - } - })), - ) - .dismiss_action( - IconButton::new("dismiss", IconName::Close) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .tooltip(Tooltip::text("Dismiss Warning")) - .on_click(cx.listener({ - move |this, _, _, cx| { - this.show_codex_windows_warning = false; - cx.notify(); - } - })), - ), + fn render_codex_windows_warning(&self, cx: &mut Context) -> Callout { + Callout::new() + .icon(IconName::Warning) + .severity(Severity::Warning) + .title("Codex on Windows") + .description("For best performance, run Codex in Windows Subsystem for Linux (WSL2)") + .actions_slot( + Button::new("open-wsl-modal", "Open in WSL") + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .on_click(cx.listener({ + move |_, _, _window, cx| { + #[cfg(windows)] + _window.dispatch_action( + zed_actions::wsl_actions::OpenWsl::default().boxed_clone(), + cx, + ); + cx.notify(); + } + })), + ) + .dismiss_action( + IconButton::new("dismiss", IconName::Close) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("Dismiss Warning")) + .on_click(cx.listener({ + move |this, _, _, cx| { + this.show_codex_windows_warning = false; + cx.notify(); + } + })), ) - } else { - None - } } fn render_thread_error(&mut self, window: &mut Window, cx: &mut Context) -> Option
{ @@ -5744,10 +5876,6 @@ impl AcpThreadView { }; let connection = thread.read(cx).connection().clone(); - let err = AuthRequired { - description: None, - provider_id: None, - }; this.clear_thread_error(cx); if let Some(message) = this.in_flight_prompt.take() { this.message_editor.update(cx, |editor, cx| { @@ -5756,7 +5884,14 @@ impl AcpThreadView { } let this = cx.weak_entity(); window.defer(cx, |window, cx| { - Self::handle_auth_required(this, err, agent, connection, window, cx); + Self::handle_auth_required( + this, + AuthRequired::new(), + agent, + connection, + window, + cx, + ); }) } })) @@ -5769,14 +5904,10 @@ impl AcpThreadView { }; let connection = thread.read(cx).connection().clone(); - let err = AuthRequired { - description: None, - provider_id: None, - }; self.clear_thread_error(cx); let this = cx.weak_entity(); window.defer(cx, |window, cx| { - Self::handle_auth_required(this, err, agent, connection, window, cx); + Self::handle_auth_required(this, AuthRequired::new(), agent, connection, window, cx); }) } @@ -5879,16 +6010,19 @@ impl Render for AcpThreadView { configuration_view, pending_auth_method, .. - } => self - .render_auth_required_state( + } => v_flex() + .flex_1() + .size_full() + .justify_end() + .child(self.render_auth_required_state( connection, description.as_ref(), configuration_view.as_ref(), pending_auth_method.as_ref(), window, cx, - ) - .into_any(), + )) + .into_any_element(), ThreadState::Loading { .. } => v_flex() .flex_1() .child(self.render_recent_history(cx)) @@ -5936,12 +6070,8 @@ impl Render for AcpThreadView { _ => this, }) .children(self.render_thread_retry_status_callout(window, cx)) - .children({ - if cfg!(windows) && self.project.read(cx).is_local() { - self.render_codex_windows_warning(cx) - } else { - None - } + .when(self.show_codex_windows_warning, |this| { + this.child(self.render_codex_windows_warning(cx)) }) .children(self.render_thread_error(window, cx)) .when_some( @@ -6057,13 +6187,13 @@ fn default_markdown_style( }, border_color: Some(colors.border_variant), background: Some(colors.editor_background.into()), - text: Some(TextStyleRefinement { + text: TextStyleRefinement { font_family: Some(theme_settings.buffer_font.family.clone()), font_fallbacks: theme_settings.buffer_font.fallbacks.clone(), font_features: Some(theme_settings.buffer_font.features.clone()), font_size: Some(buffer_font_size.into()), ..Default::default() - }), + }, ..Default::default() }, inline_code: TextStyleRefinement { @@ -6374,6 +6504,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, @@ -6398,6 +6579,7 @@ pub(crate) mod tests { project, history_store, None, + false, window, cx, ) @@ -6475,10 +6657,6 @@ pub(crate) mod tests { where C: 'static + AgentConnection + Send + Clone, { - fn telemetry_id(&self) -> &'static str { - "test" - } - fn logo(&self) -> ui::IconName { ui::IconName::Ai } @@ -6505,8 +6683,8 @@ pub(crate) mod tests { struct SaboteurAgentConnection; impl AgentConnection for SaboteurAgentConnection { - fn telemetry_id(&self) -> &'static str { - "saboteur" + fn telemetry_id(&self) -> SharedString { + "saboteur".into() } fn new_thread( @@ -6569,8 +6747,8 @@ pub(crate) mod tests { struct RefusalAgentConnection; impl AgentConnection for RefusalAgentConnection { - fn telemetry_id(&self) -> &'static str { - "refusal" + fn telemetry_id(&self) -> SharedString { + "refusal".into() } fn new_thread( @@ -6671,6 +6849,7 @@ pub(crate) mod tests { project.clone(), history_store.clone(), None, + false, window, cx, ) @@ -6791,6 +6970,70 @@ pub(crate) mod tests { }); } + #[gpui::test] + async fn test_scroll_to_most_recent_user_prompt(cx: &mut TestAppContext) { + init_test(cx); + + let connection = StubAgentConnection::new(); + + // Each user prompt will result in a user message entry plus an agent message entry. + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Response 1".into()), + )]); + + let (thread_view, cx) = + setup_thread_view(StubAgentServer::new(connection.clone()), cx).await; + + let thread = thread_view + .read_with(cx, |view, _| view.thread().cloned()) + .unwrap(); + + thread + .update(cx, |thread, cx| thread.send_raw("Prompt 1", cx)) + .await + .unwrap(); + cx.run_until_parked(); + + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Response 2".into()), + )]); + + thread + .update(cx, |thread, cx| thread.send_raw("Prompt 2", cx)) + .await + .unwrap(); + cx.run_until_parked(); + + // Move somewhere else first so we're not trivially already on the last user prompt. + thread_view.update(cx, |view, cx| { + view.scroll_to_top(cx); + }); + cx.run_until_parked(); + + thread_view.update(cx, |view, cx| { + view.scroll_to_most_recent_user_prompt(cx); + let scroll_top = view.list_state.logical_scroll_top(); + // Entries layout is: [User1, Assistant1, User2, Assistant2] + assert_eq!(scroll_top.item_ix, 2); + }); + } + + #[gpui::test] + async fn test_scroll_to_most_recent_user_prompt_falls_back_to_bottom_without_user_messages( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await; + + // With no entries, scrolling should be a no-op and must not panic. + thread_view.update(cx, |view, cx| { + view.scroll_to_most_recent_user_prompt(cx); + let scroll_top = view.list_state.logical_scroll_top(); + assert_eq!(scroll_top.item_ix, 0); + }); + } + #[gpui::test] async fn test_message_editing_cancel(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index aa4cbc8e5b261d1953a91fb090e7ecd28b4e3a31..24f019c605d1b167e62a6e68dfc1f3ed07c73f1c 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -34,9 +34,9 @@ use project::{ }; use settings::{Settings, SettingsStore, update_settings_file}; use ui::{ - Button, ButtonStyle, Chip, CommonAnimationExt, ContextMenu, ContextMenuEntry, Disclosure, - Divider, DividerColor, ElevationIndex, IconName, IconPosition, IconSize, Indicator, LabelSize, - PopoverMenu, Switch, Tooltip, WithScrollbar, prelude::*, + ButtonStyle, Chip, CommonAnimationExt, ContextMenu, ContextMenuEntry, Disclosure, Divider, + DividerColor, ElevationIndex, Indicator, LabelSize, PopoverMenu, Switch, Tooltip, + WithScrollbar, prelude::*, }; use util::ResultExt as _; use workspace::{Workspace, create_and_open_local_file}; @@ -975,9 +975,12 @@ impl AgentConfiguration { let icon = if let Some(icon_path) = agent_server_store.agent_icon(&name) { AgentIcon::Path(icon_path) } else { - AgentIcon::Name(IconName::Ai) + AgentIcon::Name(IconName::Sparkle) }; - (name, icon) + let display_name = agent_server_store + .agent_display_name(&name) + .unwrap_or_else(|| name.0.clone()); + (name, icon, display_name) }) .collect(); @@ -1084,6 +1087,7 @@ impl AgentConfiguration { .child(self.render_agent_server( AgentIcon::Name(IconName::AiClaude), "Claude Code", + "Claude Code", false, cx, )) @@ -1091,6 +1095,7 @@ impl AgentConfiguration { .child(self.render_agent_server( AgentIcon::Name(IconName::AiOpenAi), "Codex CLI", + "Codex CLI", false, cx, )) @@ -1098,16 +1103,23 @@ impl AgentConfiguration { .child(self.render_agent_server( AgentIcon::Name(IconName::AiGemini), "Gemini CLI", + "Gemini CLI", false, cx, )) .map(|mut parent| { - for (name, icon) in user_defined_agents { + for (name, icon, display_name) in user_defined_agents { parent = parent .child( Divider::horizontal().color(DividerColor::BorderFaded), ) - .child(self.render_agent_server(icon, name, true, cx)); + .child(self.render_agent_server( + icon, + name, + display_name, + true, + cx, + )); } parent }), @@ -1118,11 +1130,14 @@ impl AgentConfiguration { fn render_agent_server( &self, icon: AgentIcon, - name: impl Into, + id: impl Into, + display_name: impl Into, external: bool, cx: &mut Context, ) -> impl IntoElement { - let name = name.into(); + let id = id.into(); + let display_name = display_name.into(); + let icon = match icon { AgentIcon::Name(icon_name) => Icon::new(icon_name) .size(IconSize::Small) @@ -1132,12 +1147,15 @@ impl AgentConfiguration { .color(Color::Muted), }; - let tooltip_id = SharedString::new(format!("agent-source-{}", name)); - let tooltip_message = format!("The {} agent was installed from an extension.", name); + let tooltip_id = SharedString::new(format!("agent-source-{}", id)); + let tooltip_message = format!( + "The {} agent was installed from an extension.", + display_name + ); - let agent_server_name = ExternalAgentServerName(name.clone()); + let agent_server_name = ExternalAgentServerName(id.clone()); - let uninstall_btn_id = SharedString::from(format!("uninstall-{}", name)); + let uninstall_btn_id = SharedString::from(format!("uninstall-{}", id)); let uninstall_button = IconButton::new(uninstall_btn_id, IconName::Trash) .icon_color(Color::Muted) .icon_size(IconSize::Small) @@ -1161,7 +1179,7 @@ impl AgentConfiguration { h_flex() .gap_1p5() .child(icon) - .child(Label::new(name)) + .child(Label::new(display_name)) .when(external, |this| { this.child( div() 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 2f17349c3d1da1cf68a3ab513ccad434a115087b..c7f395ebbd813cfd7c28f33a7e69ec32f6d90fca 100644 --- a/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs +++ b/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs @@ -8,6 +8,7 @@ use editor::Editor; use fs::Fs; use gpui::{DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Subscription, prelude::*}; use language_model::{LanguageModel, LanguageModelRegistry}; +use settings::SettingsStore; use settings::{ LanguageModelProviderSetting, LanguageModelSelection, Settings as _, update_settings_file, }; @@ -94,6 +95,7 @@ pub struct ViewProfileMode { configure_default_model: NavigableEntry, configure_tools: NavigableEntry, configure_mcps: NavigableEntry, + delete_profile: NavigableEntry, cancel_item: NavigableEntry, } @@ -109,6 +111,7 @@ pub struct ManageProfilesModal { active_model: Option>, focus_handle: FocusHandle, mode: Mode, + _settings_subscription: Subscription, } impl ManageProfilesModal { @@ -148,18 +151,29 @@ impl ManageProfilesModal { ) -> Self { let focus_handle = cx.focus_handle(); + // Keep this modal in sync with settings changes (including profile deletion). + let settings_subscription = + 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, cx); + cx.notify(); + } + }); + Self { fs, active_model, context_server_registry, focus_handle, mode: Mode::choose_profile(window, cx), + _settings_subscription: settings_subscription, } } 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( @@ -177,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( @@ -192,9 +206,10 @@ impl ManageProfilesModal { configure_default_model: NavigableEntry::focusable(cx), configure_tools: NavigableEntry::focusable(cx), configure_mcps: NavigableEntry::focusable(cx), + 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( @@ -207,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( @@ -235,22 +249,36 @@ impl ManageProfilesModal { }) } }, - move |model, cx| { - let provider = model.provider_id().0.to_string(); - let model_id = model.id().0.to_string(); - let profile_id = profile_id.clone(); + { + let fs = fs.clone(); + move |model, cx| { + let provider = model.provider_id().0.to_string(); + let model_id = model.id().0.to_string(); + let profile_id = profile_id.clone(); - update_settings_file(fs.clone(), cx, move |settings, _cx| { - let agent_settings = settings.agent.get_or_insert_default(); - if let Some(profiles) = agent_settings.profiles.as_mut() { - if let Some(profile) = profiles.get_mut(profile_id.0.as_ref()) { - profile.default_model = Some(LanguageModelSelection { - provider: LanguageModelProviderSetting(provider.clone()), - model: model_id.clone(), - }); + update_settings_file(fs.clone(), cx, move |settings, _cx| { + let agent_settings = settings.agent.get_or_insert_default(); + if let Some(profiles) = agent_settings.profiles.as_mut() { + if let Some(profile) = profiles.get_mut(profile_id.0.as_ref()) { + profile.default_model = Some(LanguageModelSelection { + provider: LanguageModelProviderSetting(provider.clone()), + model: model_id.clone(), + }); + } } - } - }); + }); + } + }, + { + let fs = fs.clone(); + move |model, should_be_favorite, cx| { + crate::favorite_models::toggle_in_settings( + model, + should_be_favorite, + fs.clone(), + cx, + ); + } }, false, // Do not use popover styles for the model picker self.focus_handle.clone(), @@ -272,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( @@ -308,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( @@ -349,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) { @@ -369,6 +397,42 @@ impl ManageProfilesModal { } } + fn delete_profile( + &mut self, + profile_id: AgentProfileId, + window: &mut Window, + cx: &mut Context, + ) { + if builtin_profiles::is_builtin(&profile_id) { + self.view_profile(profile_id, window, cx); + return; + } + + let fs = self.fs.clone(); + + update_settings_file(fs, cx, move |settings, _cx| { + let Some(agent_settings) = settings.agent.as_mut() else { + return; + }; + + let Some(profiles) = agent_settings.profiles.as_mut() else { + return; + }; + + profiles.shift_remove(profile_id.0.as_ref()); + + if agent_settings + .default_profile + .as_deref() + .is_some_and(|default_profile| default_profile == profile_id.0.as_ref()) + { + agent_settings.default_profile = Some(AgentProfileId::default().0); + } + }); + + self.choose_profile(window, cx); + } + fn cancel(&mut self, window: &mut Window, cx: &mut Context) { match &self.mode { Mode::ChooseProfile { .. } => { @@ -756,6 +820,40 @@ impl ManageProfilesModal { }), ), ) + .child( + div() + .id("delete-profile") + .track_focus(&mode.delete_profile.focus_handle) + .on_action({ + let profile_id = mode.profile_id.clone(); + cx.listener(move |this, _: &menu::Confirm, window, cx| { + this.delete_profile(profile_id.clone(), window, cx); + }) + }) + .child( + ListItem::new("delete-profile") + .toggle_state( + mode.delete_profile + .focus_handle + .contains_focused(window, cx), + ) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .start_slot( + Icon::new(IconName::Trash) + .size(IconSize::Small) + .color(Color::Error), + ) + .child(Label::new("Delete Profile").color(Color::Error)) + .disabled(builtin_profiles::is_builtin(&mode.profile_id)) + .on_click({ + let profile_id = mode.profile_id.clone(); + cx.listener(move |this, _, window, cx| { + this.delete_profile(profile_id.clone(), window, cx); + }) + }), + ), + ) .child(ListSeparator) .child( div() @@ -805,6 +903,7 @@ impl ManageProfilesModal { .entry(mode.configure_default_model) .entry(mode.configure_tools) .entry(mode.configure_mcps) + .entry(mode.delete_profile) .entry(mode.cancel_item) } } @@ -852,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 11acd649ef9df500edf99926e754228e4c41e7bc..91d345b7ebb9dae5225626d7a054d0de1882dfe0 100644 --- a/crates/agent_ui/src/agent_diff.rs +++ b/crates/agent_ui/src/agent_diff.rs @@ -130,7 +130,12 @@ impl AgentDiffPane { .action_log() .read(cx) .changed_buffers(cx); - let mut paths_to_delete = self.multibuffer.read(cx).paths().collect::>(); + let mut paths_to_delete = self + .multibuffer + .read(cx) + .paths() + .cloned() + .collect::>(); for (buffer, diff_handle) in changed_buffers { if buffer.read(cx).file().is_none() { @@ -207,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); }); } } @@ -869,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 3840e40cf4d22db9d52e74ef0489c06ca8a15f26..ac57ed575d9d1b6de2c53d3e0e4a91b4bd16ab1a 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, @@ -63,6 +76,10 @@ impl AgentModelSelector { pub fn toggle(&self, window: &mut Window, cx: &mut Context) { self.menu_handle.toggle(window, cx); } + + pub fn active_model(&self, cx: &App) -> Option { + self.selector.read(cx).delegate.active_model(cx) + } } impl Render for AgentModelSelector { diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 18e8f1e731defa82e865dd45e66389634992037c..294cd8b4888950f6ea92d6bea1eba78c3d6d6de2 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, @@ -259,7 +260,7 @@ impl AgentType { Self::Gemini => Some(IconName::AiGemini), Self::ClaudeCode => Some(IconName::AiClaude), Self::Codex => Some(IconName::AiOpenAi), - Self::Custom { .. } => Some(IconName::Terminal), + Self::Custom { .. } => Some(IconName::Sparkle), } } } @@ -287,7 +288,7 @@ impl ActiveView { } } - pub fn native_agent( + fn native_agent( fs: Arc, prompt_store: Option>, history_store: Entity, @@ -305,6 +306,7 @@ impl ActiveView { project, history_store, prompt_store, + false, window, cx, ) @@ -441,6 +443,7 @@ pub struct AgentPanel { pending_serialization: Option>>, onboarding: Entity, selected_agent: AgentType, + show_trust_workspace_message: bool, } impl AgentPanel { @@ -691,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 @@ -818,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( @@ -884,39 +888,21 @@ impl AgentPanel { }; let server = ext_agent.server(fs, history); - - if !loading { - telemetry::event!("Agent Thread Started", agent = server.telemetry_id()); - } - - 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(), - 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); } @@ -949,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); }); } } @@ -1030,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 => {} } @@ -1183,7 +1169,7 @@ impl AgentPanel { Self::handle_agent_configuration_event, )); - configuration.focus_handle(cx).focus(window); + configuration.focus_handle(cx).focus(window, cx); } } @@ -1319,7 +1305,7 @@ impl AgentPanel { } if focus { - self.focus_handle(cx).focus(window); + self.focus_handle(cx).focus(window, cx); } } @@ -1479,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 { @@ -1593,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); } } }) @@ -1608,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) @@ -1643,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() } } @@ -1686,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, @@ -1705,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) @@ -1727,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()) @@ -1764,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( @@ -1853,14 +1986,17 @@ impl AgentPanel { let agent_server_store = self.project.read(cx).agent_server_store().clone(); let focus_handle = self.focus_handle(cx); - // Get custom icon path for selected agent before building menu (to avoid borrow issues) - let selected_agent_custom_icon = + let (selected_agent_custom_icon, selected_agent_label) = if let AgentType::Custom { name, .. } = &self.selected_agent { - agent_server_store - .read(cx) - .agent_icon(&ExternalAgentServerName(name.clone())) + let store = agent_server_store.read(cx); + let icon = store.agent_icon(&ExternalAgentServerName(name.clone())); + + let label = store + .agent_display_name(&ExternalAgentServerName(name.clone())) + .unwrap_or_else(|| self.selected_agent.label()); + (icon, label) } else { - None + (None, self.selected_agent.label()) }; let active_thread = match &self.active_view { @@ -2083,13 +2219,16 @@ impl AgentPanel { for agent_name in agent_names { let icon_path = agent_server_store.agent_icon(&agent_name); + let display_name = agent_server_store + .agent_display_name(&agent_name) + .unwrap_or_else(|| agent_name.0.clone()); - let mut entry = ContextMenuEntry::new(agent_name.clone()); + let mut entry = ContextMenuEntry::new(display_name); if let Some(icon_path) = icon_path { entry = entry.custom_icon_svg(icon_path); } else { - entry = entry.icon(IconName::Terminal); + entry = entry.icon(IconName::Sparkle); } entry = entry .when( @@ -2153,8 +2292,6 @@ impl AgentPanel { } }); - let selected_agent_label = self.selected_agent.label(); - let is_thread_loading = self .active_thread_view() .map(|thread| thread.read(cx).is_loading()) @@ -2555,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"); @@ -2607,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 f7b07b7bd393b8d3efffc3757eaf6025d5c651cd..02cb7e59948b10274302bd8cd6f74f1accbd30a3 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -1,4 +1,4 @@ -mod acp; +pub mod acp; mod agent_configuration; mod agent_diff; mod agent_model_selector; @@ -7,8 +7,7 @@ mod buffer_codegen; mod completion_provider; mod context; mod context_server_configuration; -#[cfg(test)] -mod evals; +mod favorite_models; mod inline_assistant; mod inline_prompt_editor; mod language_model_selector; @@ -28,7 +27,7 @@ use agent_settings::{AgentProfileId, AgentSettings}; use assistant_slash_command::SlashCommandRegistry; use client::Client; use command_palette_hooks::CommandPaletteFilter; -use feature_flags::FeatureFlagAppExt as _; +use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt as _}; use fs::Fs; use gpui::{Action, App, Entity, SharedString, actions}; use language::{ @@ -69,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. @@ -160,16 +161,6 @@ pub enum ExternalAgent { } impl ExternalAgent { - pub fn parse_built_in(server: &dyn agent_servers::AgentServer) -> Option { - match server.telemetry_id() { - "gemini-cli" => Some(Self::Gemini), - "claude-code" => Some(Self::ClaudeCode), - "codex" => Some(Self::Codex), - "zed" => Some(Self::NativeAgent), - _ => None, - } - } - pub fn server( &self, fs: Arc, @@ -226,7 +217,7 @@ pub fn init( is_eval: bool, cx: &mut App, ) { - assistant_text_thread::init(client.clone(), cx); + assistant_text_thread::init(client, cx); rules_library::init(cx); if !is_eval { // Initializing the language model from the user settings messes with the eval, so we only initialize them when @@ -239,13 +230,8 @@ pub fn init( TextThreadEditor::init(cx); register_slash_commands(cx); - inline_assistant::init( - fs.clone(), - prompt_builder.clone(), - client.telemetry().clone(), - cx, - ); - terminal_inline_assistant::init(fs.clone(), prompt_builder, client.telemetry().clone(), cx); + inline_assistant::init(fs.clone(), prompt_builder.clone(), cx); + terminal_inline_assistant::init(fs.clone(), prompt_builder, cx); cx.observe_new(move |workspace, window, cx| { ConfigureContextServerModal::register(workspace, language_registry.clone(), window, cx) }) @@ -261,23 +247,31 @@ pub fn init( update_command_palette_filter(app_cx); }) .detach(); + + cx.on_flags_ready(|_, cx| { + update_command_palette_filter(cx); + }) + .detach(); } fn update_command_palette_filter(cx: &mut App) { let disable_ai = DisableAiSettings::get_global(cx).disable_ai; let agent_enabled = AgentSettings::get_global(cx).enabled; + let agent_v2_enabled = cx.has_flag::(); let edit_prediction_provider = AllLanguageSettings::get_global(cx) .edit_predictions .provider; CommandPaletteFilter::update_global(cx, |filter, _| { use editor::actions::{ - AcceptEditPrediction, AcceptPartialEditPrediction, NextEditPrediction, - PreviousEditPrediction, ShowEditPrediction, ToggleEditPrediction, + AcceptEditPrediction, AcceptNextLineEditPrediction, AcceptNextWordEditPrediction, + NextEditPrediction, PreviousEditPrediction, ShowEditPrediction, ToggleEditPrediction, }; let edit_prediction_actions = [ TypeId::of::(), - TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), TypeId::of::(), TypeId::of::(), TypeId::of::(), @@ -286,6 +280,7 @@ fn update_command_palette_filter(cx: &mut App) { if disable_ai { filter.hide_namespace("agent"); + filter.hide_namespace("agents"); filter.hide_namespace("assistant"); filter.hide_namespace("copilot"); filter.hide_namespace("supermaven"); @@ -297,8 +292,10 @@ fn update_command_palette_filter(cx: &mut App) { } else { if agent_enabled { filter.show_namespace("agent"); + filter.show_namespace("agents"); } else { filter.hide_namespace("agent"); + filter.hide_namespace("agents"); } filter.show_namespace("assistant"); @@ -334,6 +331,9 @@ fn update_command_palette_filter(cx: &mut App) { filter.show_namespace("zed_predict_onboarding"); filter.show_action_types(&[TypeId::of::()]); + if !agent_v2_enabled { + filter.hide_action_types(&[TypeId::of::()]); + } } }); } @@ -432,7 +432,7 @@ mod tests { use gpui::{BorrowAppContext, TestAppContext, px}; use project::DisableAiSettings; use settings::{ - DefaultAgentView, DockPosition, NotifyWhenAgentWaiting, Settings, SettingsStore, + DefaultAgentView, DockPosition, DockSide, NotifyWhenAgentWaiting, Settings, SettingsStore, }; #[gpui::test] @@ -451,13 +451,16 @@ mod tests { enabled: true, button: true, dock: DockPosition::Right, + agents_panel_dock: DockSide::Left, default_width: px(300.), default_height: px(600.), default_model: None, inline_assistant_model: None, + inline_assistant_use_streaming_tools: false, 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 f7e7884310458e97421768882df57934a19b4430..a296d4d20918fba6eb32bfcf7fcc657f9db2b3ac 100644 --- a/crates/agent_ui/src/buffer_codegen.rs +++ b/crates/agent_ui/src/buffer_codegen.rs @@ -1,23 +1,26 @@ use crate::{context::LoadedContext, inline_prompt_editor::CodegenStatus}; use agent_settings::AgentSettings; use anyhow::{Context as _, Result}; -use client::telemetry::Telemetry; +use uuid::Uuid; + use cloud_llm_client::CompletionIntent; use collections::HashSet; use editor::{Anchor, AnchorRangeExt, MultiBuffer, MultiBufferSnapshot, ToOffset as _, ToPoint}; -use feature_flags::{FeatureFlagAppExt as _, InlineAssistantV2FeatureFlag}; +use feature_flags::{FeatureFlagAppExt as _, InlineAssistantUseToolFeatureFlag}; use futures::{ SinkExt, Stream, StreamExt, TryStreamExt as _, channel::mpsc, future::{LocalBoxFuture, Shared}, join, + stream::BoxStream, }; use gpui::{App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Subscription, Task}; -use language::{Buffer, IndentKind, Point, TransactionId, line_diff}; +use language::{Buffer, IndentKind, LanguageName, Point, TransactionId, line_diff}; use language_model::{ - LanguageModel, LanguageModelCompletionError, LanguageModelRegistry, LanguageModelRequest, - LanguageModelRequestMessage, LanguageModelRequestTool, LanguageModelTextStream, Role, - report_assistant_event, + LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, + LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, + LanguageModelRequestTool, LanguageModelTextStream, LanguageModelToolChoice, + LanguageModelToolUse, Role, TokenUsage, }; use multi_buffer::MultiBufferRow; use parking_lot::Mutex; @@ -25,6 +28,7 @@ use prompt_store::PromptBuilder; use rope::Rope; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use settings::Settings as _; use smol::future::FutureExt; use std::{ cmp, @@ -37,28 +41,24 @@ use std::{ time::Instant, }; use streaming_diff::{CharOperation, LineDiff, LineOperation, StreamingDiff}; -use telemetry_events::{AssistantEventData, AssistantKind, AssistantPhase}; -use ui::SharedString; -/// Use this tool to provide a message to the user when you're unable to complete a task. +/// Use this tool when you cannot or should not make a rewrite. This includes: +/// - The user's request is unclear, ambiguous, or nonsensical +/// - The requested change cannot be made by only editing the section #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct FailureMessageInput { /// A brief message to the user explaining why you're unable to fulfill the request or to ask a question about the request. - /// - /// The message may use markdown formatting if you wish. + #[serde(default)] pub message: String, } /// Replaces text in tags with your replacement_text. +/// Only use this tool when you are confident you understand the user's request and can fulfill it +/// by editing the marked section. #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct RewriteSectionInput { - /// A brief description of the edit you have made. - /// - /// The description may use markdown formatting if you wish. - /// This is optional - if the edit is simple or obvious, you should leave it empty. - pub description: String, - /// The text to replace the section with. + #[serde(default)] pub replacement_text: String, } @@ -70,17 +70,20 @@ pub struct BufferCodegen { buffer: Entity, range: Range, initial_transaction_id: Option, - telemetry: Arc, builder: Arc, pub is_insertion: bool, + session_id: Uuid, } +pub const REWRITE_SECTION_TOOL_NAME: &str = "rewrite_section"; +pub const FAILURE_MESSAGE_TOOL_NAME: &str = "failure_message"; + impl BufferCodegen { pub fn new( buffer: Entity, range: Range, initial_transaction_id: Option, - telemetry: Arc, + session_id: Uuid, builder: Arc, cx: &mut Context, ) -> Self { @@ -89,8 +92,8 @@ impl BufferCodegen { buffer.clone(), range.clone(), false, - Some(telemetry.clone()), builder.clone(), + session_id, cx, ) }); @@ -103,8 +106,8 @@ impl BufferCodegen { buffer, range, initial_transaction_id, - telemetry, builder, + session_id, }; this.activate(0, cx); this @@ -119,10 +122,18 @@ impl BufferCodegen { .push(cx.subscribe(&codegen, |_, _, event, cx| cx.emit(*event))); } + pub fn active_completion(&self, cx: &App) -> Option { + self.active_alternative().read(cx).current_completion() + } + pub fn active_alternative(&self) -> &Entity { &self.alternatives[self.active_alternative] } + pub fn language_name(&self, cx: &App) -> Option { + self.active_alternative().read(cx).language_name(cx) + } + pub fn status<'a>(&self, cx: &'a App) -> &'a CodegenStatus { &self.active_alternative().read(cx).status } @@ -181,8 +192,8 @@ impl BufferCodegen { self.buffer.clone(), self.range.clone(), false, - Some(self.telemetry.clone()), self.builder.clone(), + self.session_id, cx, ) })); @@ -241,6 +252,14 @@ impl BufferCodegen { pub fn last_equal_ranges<'a>(&self, cx: &'a App) -> &'a [Range] { self.active_alternative().read(cx).last_equal_ranges() } + + pub fn selected_text<'a>(&self, cx: &'a App) -> Option<&'a str> { + self.active_alternative().read(cx).selected_text() + } + + pub fn session_id(&self) -> Uuid { + self.session_id + } } impl EventEmitter for BufferCodegen {} @@ -256,7 +275,6 @@ pub struct CodegenAlternative { status: CodegenStatus, generation: Task<()>, diff: Diff, - telemetry: Option>, _subscription: gpui::Subscription, builder: Arc, active: bool, @@ -264,8 +282,11 @@ pub struct CodegenAlternative { line_operations: Vec, elapsed_time: Option, completion: Option, + selected_text: Option, pub message_id: Option, - pub model_explanation: Option, + session_id: Uuid, + pub description: Option, + pub failure: Option, } impl EventEmitter for CodegenAlternative {} @@ -275,8 +296,8 @@ impl CodegenAlternative { buffer: Entity, range: Range, active: bool, - telemetry: Option>, builder: Arc, + session_id: Uuid, cx: &mut Context, ) -> Self { let snapshot = buffer.read(cx).snapshot(cx); @@ -315,7 +336,6 @@ impl CodegenAlternative { status: CodegenStatus::Idle, generation: Task::ready(()), diff: Diff::default(), - telemetry, builder, active: active, edits: Vec::new(), @@ -323,11 +343,21 @@ impl CodegenAlternative { range, elapsed_time: None, completion: None, - model_explanation: None, + selected_text: None, + session_id, + description: None, + failure: None, _subscription: cx.subscribe(&buffer, Self::handle_buffer_event), } } + pub fn language_name(&self, cx: &App) -> Option { + self.old_buffer + .read(cx) + .language() + .map(|language| language.name()) + } + pub fn set_active(&mut self, active: bool, cx: &mut Context) { if active != self.active { self.active = active; @@ -369,6 +399,12 @@ impl CodegenAlternative { &self.last_equal_ranges } + pub fn use_streaming_tools(model: &dyn LanguageModel, cx: &App) -> bool { + model.supports_streaming_tools() + && cx.has_flag::() + && AgentSettings::get_global(cx).inline_assistant_use_streaming_tools + } + pub fn start( &mut self, user_prompt: String, @@ -376,6 +412,9 @@ impl CodegenAlternative { model: Arc, cx: &mut Context, ) -> Result<()> { + // Clear the model explanation since the user has started a new generation. + self.description = None; + if let Some(transformation_transaction_id) = self.transformation_transaction_id.take() { self.buffer.update(cx, |buffer, cx| { buffer.undo_transaction(transformation_transaction_id, cx); @@ -384,33 +423,35 @@ impl CodegenAlternative { self.edit_position = Some(self.range.start.bias_right(&self.snapshot)); - let api_key = model.api_key(cx); - let telemetry_id = model.telemetry_id(); - let provider_id = model.provider_id(); - - if cx.has_flag::() { + if Self::use_streaming_tools(model.as_ref(), cx) { let request = self.build_request(&model, user_prompt, context_task, cx)?; - let tool_use = - cx.spawn(async move |_, cx| model.stream_completion_tool(request.await, cx).await); - self.handle_tool_use(telemetry_id, provider_id.to_string(), api_key, tool_use, cx); + let completion_events = cx.spawn({ + let model = model.clone(); + async move |_, cx| model.stream_completion(request.await, cx).await + }); + self.generation = self.handle_completion(model, completion_events, cx); } else { let stream: LocalBoxFuture> = if user_prompt.trim().to_lowercase() == "delete" { async { Ok(LanguageModelTextStream::default()) }.boxed_local() } else { let request = self.build_request(&model, user_prompt, context_task, cx)?; - cx.spawn(async move |_, cx| { - Ok(model.stream_completion_text(request.await, cx).await?) + cx.spawn({ + let model = model.clone(); + async move |_, cx| { + Ok(model.stream_completion_text(request.await, cx).await?) + } }) .boxed_local() }; - self.handle_stream(telemetry_id, provider_id.to_string(), api_key, stream, cx); + self.generation = + self.handle_stream(model, /* strip_invalid_spans: */ true, stream, cx); } Ok(()) } - fn build_request_v2( + fn build_request_tools( &self, model: &Arc, user_prompt: String, @@ -446,7 +487,7 @@ impl CodegenAlternative { let system_prompt = self .builder - .generate_inline_transformation_prompt_v2( + .generate_inline_transformation_prompt_tools( language_name, buffer, range.start.0..range.end.0, @@ -456,6 +497,9 @@ impl CodegenAlternative { let temperature = AgentSettings::temperature_for_model(model, cx); let tool_input_format = model.tool_input_format(); + let tool_choice = model + .supports_tool_choice(LanguageModelToolChoice::Any) + .then_some(LanguageModelToolChoice::Any); Ok(cx.spawn(async move |_cx| { let mut messages = vec![LanguageModelRequestMessage { @@ -481,12 +525,12 @@ impl CodegenAlternative { let tools = vec![ LanguageModelRequestTool { - name: "rewrite_section".to_string(), + name: REWRITE_SECTION_TOOL_NAME.to_string(), description: "Replaces text in tags with your replacement_text.".to_string(), input_schema: language_model::tool_schema::root_schema_for::(tool_input_format).to_value(), }, LanguageModelRequestTool { - name: "failure_message".to_string(), + name: FAILURE_MESSAGE_TOOL_NAME.to_string(), description: "Use this tool to provide a message to the user when you're unable to complete a task.".to_string(), input_schema: language_model::tool_schema::root_schema_for::(tool_input_format).to_value(), }, @@ -498,7 +542,7 @@ impl CodegenAlternative { intent: Some(CompletionIntent::InlineAssist), mode: None, tools, - tool_choice: None, + tool_choice, stop: Vec::new(), temperature, messages, @@ -514,8 +558,8 @@ impl CodegenAlternative { context_task: Shared>>, cx: &mut App, ) -> Result> { - if cx.has_flag::() { - return self.build_request_v2(model, user_prompt, context_task, cx); + if Self::use_streaming_tools(model.as_ref(), cx) { + return self.build_request_tools(model, user_prompt, context_task, cx); } let buffer = self.buffer.read(cx).snapshot(cx); @@ -588,12 +632,15 @@ impl CodegenAlternative { pub fn handle_stream( &mut self, - model_telemetry_id: String, - model_provider_id: String, - model_api_key: Option, + model: Arc, + strip_invalid_spans: bool, stream: impl 'static + Future>, cx: &mut Context, - ) { + ) -> Task<()> { + let anthropic_reporter = language_model::AnthropicEventReporter::new(&model, cx); + let session_id = self.session_id; + let model_telemetry_id = model.telemetry_id(); + let model_provider_id = model.provider_id().to_string(); let start_time = Instant::now(); // Make a new snapshot and re-resolve anchor in case the document was modified. @@ -608,6 +655,8 @@ impl CodegenAlternative { .text_for_range(self.range.start..self.range.end) .collect::(); + self.selected_text = Some(selected_text.to_string()); + let selection_start = self.range.start.to_point(&snapshot); // Start with the indentation of the first line in the selection @@ -629,8 +678,6 @@ impl CodegenAlternative { } } - let http_client = cx.http_client(); - let telemetry = self.telemetry.clone(); let language_name = { let multibuffer = self.buffer.read(cx); let snapshot = multibuffer.snapshot(cx); @@ -647,7 +694,8 @@ impl CodegenAlternative { let completion = Arc::new(Mutex::new(String::new())); let completion_clone = completion.clone(); - self.generation = cx.spawn(async move |codegen, cx| { + cx.notify(); + cx.spawn(async move |codegen, cx| { let stream = stream.await; let token_usage = stream @@ -662,17 +710,25 @@ impl CodegenAlternative { let model_telemetry_id = model_telemetry_id.clone(); let model_provider_id = model_provider_id.clone(); let (mut diff_tx, mut diff_rx) = mpsc::channel(1); - let executor = cx.background_executor().clone(); let message_id = message_id.clone(); - let line_based_stream_diff: Task> = - cx.background_spawn(async move { + let line_based_stream_diff: Task> = cx.background_spawn({ + let anthropic_reporter = anthropic_reporter.clone(); + let language_name = language_name.clone(); + async move { 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(); @@ -761,27 +817,30 @@ impl CodegenAlternative { let result = diff.await; let error_message = result.as_ref().err().map(|error| error.to_string()); - report_assistant_event( - AssistantEventData { - conversation_id: None, - message_id, - kind: AssistantKind::Inline, - phase: AssistantPhase::Response, - model: model_telemetry_id, - model_provider: model_provider_id, - response_latency, - error_message, - language_name: language_name.map(|name| name.to_proto()), - }, - telemetry, - http_client, - model_api_key, - &executor, + telemetry::event!( + "Assistant Responded", + kind = "inline", + phase = "response", + session_id = session_id.to_string(), + model = model_telemetry_id, + model_provider = model_provider_id, + language_name = language_name.as_ref().map(|n| n.to_string()), + message_id = message_id.as_deref(), + response_latency = response_latency, + error_message = error_message.as_deref(), ); + anthropic_reporter.report(language_model::AnthropicEventData { + completion_type: language_model::AnthropicCompletionType::Editor, + event: language_model::AnthropicEventType::Response, + language_name: language_name.map(|n| n.to_string()), + message_id, + }); + result?; Ok(()) - }); + } + }); while let Some((char_ops, line_ops)) = diff_rx.next().await { codegen.update(cx, |codegen, cx| { @@ -864,8 +923,25 @@ impl CodegenAlternative { cx.notify(); }) .ok(); - }); - cx.notify(); + }) + } + + pub fn current_completion(&self) -> Option { + self.completion.clone() + } + + #[cfg(any(test, feature = "test-support"))] + pub fn current_description(&self) -> Option { + self.description.clone() + } + + #[cfg(any(test, feature = "test-support"))] + pub fn current_failure(&self) -> Option { + self.failure.clone() + } + + pub fn selected_text(&self) -> Option<&str> { + self.selected_text.as_deref() } pub fn stop(&mut self, cx: &mut Context) { @@ -1040,21 +1116,27 @@ impl CodegenAlternative { }) } - fn handle_tool_use( + fn handle_completion( &mut self, - _telemetry_id: String, - _provider_id: String, - _api_key: Option, - tool_use: impl 'static - + Future< - Output = Result, + model: Arc, + completion_stream: Task< + Result< + BoxStream< + 'static, + Result, + >, + LanguageModelCompletionError, + >, >, cx: &mut Context, - ) { + ) -> Task<()> { self.diff = Diff::default(); self.status = CodegenStatus::Pending; - self.generation = cx.spawn(async move |codegen, cx| { + cx.notify(); + // Leaving this in generation so that STOP equivalent events are respected even + // while we're still pre-processing the completion event + cx.spawn(async move |codegen, cx| { let finish_with_status = |status: CodegenStatus, cx: &mut AsyncApp| { let _ = codegen.update(cx, |this, cx| { this.status = status; @@ -1063,76 +1145,193 @@ impl CodegenAlternative { }); }; - let tool_use = tool_use.await; - - match tool_use { - Ok(tool_use) if tool_use.name.as_ref() == "rewrite_section" => { - // Parse the input JSON into RewriteSectionInput - match serde_json::from_value::(tool_use.input) { - Ok(input) => { - // Store the description if non-empty - let description = if !input.description.trim().is_empty() { - Some(input.description.clone()) - } else { - None - }; - - // Apply the replacement text to the buffer and compute diff - let batch_diff_task = codegen - .update(cx, |this, cx| { - this.model_explanation = description.map(Into::into); - let range = this.range.clone(); - this.apply_edits( - std::iter::once((range, input.replacement_text)), - cx, - ); - this.reapply_batch_diff(cx) - }) - .ok(); - - // Wait for the diff computation to complete - if let Some(diff_task) = batch_diff_task { - diff_task.await; - } + let mut completion_events = match completion_stream.await { + Ok(events) => events, + Err(err) => { + finish_with_status(CodegenStatus::Error(err.into()), cx); + return; + } + }; - finish_with_status(CodegenStatus::Done, cx); - return; - } - Err(e) => { - finish_with_status(CodegenStatus::Error(e.into()), cx); - return; - } + enum ToolUseOutput { + Rewrite { + text: String, + description: Option, + }, + Failure(String), + } + + enum ModelUpdate { + Description(String), + Failure(String), + } + + let chars_read_so_far = Arc::new(Mutex::new(0usize)); + let process_tool_use = move |tool_use: LanguageModelToolUse| -> Option { + let mut chars_read_so_far = chars_read_so_far.lock(); + match tool_use.name.as_ref() { + REWRITE_SECTION_TOOL_NAME => { + let Ok(input) = + serde_json::from_value::(tool_use.input) + else { + return None; + }; + let text = input.replacement_text[*chars_read_so_far..].to_string(); + *chars_read_so_far = input.replacement_text.len(); + Some(ToolUseOutput::Rewrite { + text, + description: None, + }) + } + FAILURE_MESSAGE_TOOL_NAME => { + let Ok(mut input) = + serde_json::from_value::(tool_use.input) + else { + return None; + }; + Some(ToolUseOutput::Failure(std::mem::take(&mut input.message))) + } + _ => None, + } + }; + + let (message_tx, mut message_rx) = futures::channel::mpsc::unbounded::(); + + cx.spawn({ + let codegen = codegen.clone(); + async move |cx| { + while let Some(update) = message_rx.next().await { + let _ = codegen.update(cx, |this, _cx| match update { + ModelUpdate::Description(d) => this.description = Some(d), + ModelUpdate::Failure(f) => this.failure = Some(f), + }); } } - Ok(tool_use) if tool_use.name.as_ref() == "failure_message" => { - // Handle failure message tool use - match serde_json::from_value::(tool_use.input) { - Ok(input) => { - let _ = codegen.update(cx, |this, _cx| { - // Store the failure message as the tool description - this.model_explanation = Some(input.message.into()); - }); - finish_with_status(CodegenStatus::Done, cx); - return; + }) + .detach(); + + let mut message_id = None; + let mut first_text = None; + let last_token_usage = Arc::new(Mutex::new(TokenUsage::default())); + let total_text = Arc::new(Mutex::new(String::new())); + + loop { + if let Some(first_event) = completion_events.next().await { + match first_event { + Ok(LanguageModelCompletionEvent::StartMessage { message_id: id }) => { + message_id = Some(id); + } + Ok(LanguageModelCompletionEvent::ToolUse(tool_use)) => { + if let Some(output) = process_tool_use(tool_use) { + let (text, update) = match output { + ToolUseOutput::Rewrite { text, description } => { + (Some(text), description.map(ModelUpdate::Description)) + } + ToolUseOutput::Failure(message) => { + (None, Some(ModelUpdate::Failure(message))) + } + }; + if let Some(update) = update { + let _ = message_tx.unbounded_send(update); + } + first_text = text; + if first_text.is_some() { + break; + } + } + } + Ok(LanguageModelCompletionEvent::UsageUpdate(token_usage)) => { + *last_token_usage.lock() = token_usage; + } + Ok(LanguageModelCompletionEvent::Text(text)) => { + let mut lock = total_text.lock(); + lock.push_str(&text); + } + Ok(e) => { + log::warn!("Unexpected event: {:?}", e); + break; } Err(e) => { finish_with_status(CodegenStatus::Error(e.into()), cx); - return; + break; } } } - Ok(_tool_use) => { - // Unexpected tool. - finish_with_status(CodegenStatus::Done, cx); - return; - } - Err(e) => { - finish_with_status(CodegenStatus::Error(e.into()), cx); - return; - } } - }); - cx.notify(); + + let Some(first_text) = first_text else { + finish_with_status(CodegenStatus::Done, cx); + return; + }; + + let move_last_token_usage = last_token_usage.clone(); + + let text_stream = Box::pin(futures::stream::once(async { Ok(first_text) }).chain( + completion_events.filter_map(move |e| { + let process_tool_use = process_tool_use.clone(); + let last_token_usage = move_last_token_usage.clone(); + let total_text = total_text.clone(); + let mut message_tx = message_tx.clone(); + async move { + match e { + Ok(LanguageModelCompletionEvent::ToolUse(tool_use)) => { + let Some(output) = process_tool_use(tool_use) else { + return None; + }; + let (text, update) = match output { + ToolUseOutput::Rewrite { text, description } => { + (Some(text), description.map(ModelUpdate::Description)) + } + ToolUseOutput::Failure(message) => { + (None, Some(ModelUpdate::Failure(message))) + } + }; + if let Some(update) = update { + let _ = message_tx.send(update).await; + } + text.map(Ok) + } + Ok(LanguageModelCompletionEvent::UsageUpdate(token_usage)) => { + *last_token_usage.lock() = token_usage; + None + } + Ok(LanguageModelCompletionEvent::Text(text)) => { + let mut lock = total_text.lock(); + lock.push_str(&text); + None + } + Ok(LanguageModelCompletionEvent::Stop(_reason)) => None, + e => { + log::error!("UNEXPECTED EVENT {:?}", e); + None + } + } + } + }), + )); + + let language_model_text_stream = LanguageModelTextStream { + message_id: message_id, + stream: text_stream, + last_token_usage, + }; + + let Some(task) = codegen + .update(cx, move |codegen, cx| { + codegen.handle_stream( + model, + /* strip_invalid_spans: */ false, + async { Ok(language_model_text_stream) }, + cx, + ) + }) + .ok() + else { + return; + }; + + task.await; + }) } } @@ -1296,7 +1495,11 @@ mod tests { use gpui::TestAppContext; use indoc::indoc; use language::{Buffer, Point}; - use language_model::{LanguageModelRegistry, TokenUsage}; + use language_model::fake_provider::FakeLanguageModel; + use language_model::{ + LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelRegistry, + LanguageModelToolUse, StopReason, TokenUsage, + }; use languages::rust_lang; use rand::prelude::*; use settings::SettingsStore; @@ -1326,8 +1529,8 @@ mod tests { buffer.clone(), range.clone(), true, - None, prompt_builder, + Uuid::new_v4(), cx, ) }); @@ -1388,8 +1591,8 @@ mod tests { buffer.clone(), range.clone(), true, - None, prompt_builder, + Uuid::new_v4(), cx, ) }); @@ -1452,8 +1655,8 @@ mod tests { buffer.clone(), range.clone(), true, - None, prompt_builder, + Uuid::new_v4(), cx, ) }); @@ -1516,8 +1719,8 @@ mod tests { buffer.clone(), range.clone(), true, - None, prompt_builder, + Uuid::new_v4(), cx, ) }); @@ -1568,8 +1771,8 @@ mod tests { buffer.clone(), range.clone(), false, - None, prompt_builder, + Uuid::new_v4(), cx, ) }); @@ -1608,6 +1811,51 @@ mod tests { ); } + // When not streaming tool calls, we strip backticks as part of parsing the model's + // plain text response. This is a regression test for a bug where we stripped + // backticks incorrectly. + #[gpui::test] + async fn test_allows_model_to_output_backticks(cx: &mut TestAppContext) { + init_test(cx); + let text = "- Improved; `cmd+click` behavior. Now requires `cmd` to be pressed before the click starts or it doesn't run. ([#44579](https://github.com/zed-industries/zed/pull/44579); thanks [Zachiah](https://github.com/Zachiah))"; + let buffer = cx.new(|cx| Buffer::local("", cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let range = buffer.read_with(cx, |buffer, cx| { + let snapshot = buffer.snapshot(cx); + snapshot.anchor_before(Point::new(0, 0))..snapshot.anchor_after(Point::new(0, 0)) + }); + let prompt_builder = Arc::new(PromptBuilder::new(None).unwrap()); + let codegen = cx.new(|cx| { + CodegenAlternative::new( + buffer.clone(), + range.clone(), + true, + prompt_builder, + Uuid::new_v4(), + cx, + ) + }); + + let events_tx = simulate_tool_based_completion(&codegen, cx); + let chunk_len = text.find('`').unwrap(); + events_tx + .unbounded_send(rewrite_tool_use("tool_1", &text[..chunk_len], false)) + .unwrap(); + events_tx + .unbounded_send(rewrite_tool_use("tool_2", &text, true)) + .unwrap(); + events_tx + .unbounded_send(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)) + .unwrap(); + drop(events_tx); + cx.run_until_parked(); + + assert_eq!( + buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx).text()), + text + ); + } + #[gpui::test] async fn test_strip_invalid_spans_from_codeblock() { assert_chunks("Lorem ipsum dolor", "Lorem ipsum dolor").await; @@ -1658,11 +1906,11 @@ mod tests { cx: &mut TestAppContext, ) -> mpsc::UnboundedSender { let (chunks_tx, chunks_rx) = mpsc::unbounded(); + let model = Arc::new(FakeLanguageModel::default()); codegen.update(cx, |codegen, cx| { - codegen.handle_stream( - String::new(), - String::new(), - None, + codegen.generation = codegen.handle_stream( + model, + /* strip_invalid_spans: */ false, future::ready(Ok(LanguageModelTextStream { message_id: None, stream: chunks_rx.map(Ok).boxed(), @@ -1673,4 +1921,39 @@ mod tests { }); chunks_tx } + + fn simulate_tool_based_completion( + codegen: &Entity, + cx: &mut TestAppContext, + ) -> mpsc::UnboundedSender { + let (events_tx, events_rx) = mpsc::unbounded(); + let model = Arc::new(FakeLanguageModel::default()); + codegen.update(cx, |codegen, cx| { + let completion_stream = Task::ready(Ok(events_rx.map(Ok).boxed() + as BoxStream< + 'static, + Result, + >)); + codegen.generation = codegen.handle_completion(model, completion_stream, cx); + }); + events_tx + } + + fn rewrite_tool_use( + id: &str, + replacement_text: &str, + is_complete: bool, + ) -> LanguageModelCompletionEvent { + let input = RewriteSectionInput { + replacement_text: replacement_text.into(), + }; + LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse { + id: id.into(), + name: REWRITE_SECTION_TOOL_NAME.into(), + raw_input: serde_json::to_string(&input).unwrap(), + input: serde_json::to_value(&input).unwrap(), + is_input_complete: is_complete, + thought_signature: None, + }) + } } diff --git a/crates/agent_ui/src/completion_provider.rs b/crates/agent_ui/src/completion_provider.rs index a2b6e0510e25c12cfbfb98d3e72cb0d2c830887a..a7b955b81ef3a7edccca98f15fa73bb40787a2c9 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.as_user()?, + title: metadata.title?, + }) } }) .collect::>() diff --git a/crates/agent_ui/src/evals.rs b/crates/agent_ui/src/evals.rs deleted file mode 100644 index e82d21bd1fdb02a666c61bdf4754f27e79f92fda..0000000000000000000000000000000000000000 --- a/crates/agent_ui/src/evals.rs +++ /dev/null @@ -1,89 +0,0 @@ -use std::str::FromStr; - -use crate::inline_assistant::test::run_inline_assistant_test; - -use eval_utils::{EvalOutput, NoProcessor}; -use gpui::TestAppContext; -use language_model::{LanguageModelRegistry, SelectedModel}; -use rand::{SeedableRng as _, rngs::StdRng}; - -#[test] -#[cfg_attr(not(feature = "unit-eval"), ignore)] -fn eval_single_cursor_edit() { - eval_utils::eval(20, 1.0, NoProcessor, move || { - run_eval( - &EvalInput { - prompt: "Rename this variable to buffer_text".to_string(), - buffer: indoc::indoc! {" - struct EvalExampleStruct { - text: Strˇing, - prompt: String, - } - "} - .to_string(), - }, - &|_, output| { - let expected = indoc::indoc! {" - struct EvalExampleStruct { - buffer_text: String, - prompt: String, - } - "}; - if output == expected { - EvalOutput { - outcome: eval_utils::OutcomeKind::Passed, - data: "Passed!".to_string(), - metadata: (), - } - } else { - EvalOutput { - outcome: eval_utils::OutcomeKind::Failed, - data: format!("Failed to rename variable, output: {}", output), - metadata: (), - } - } - }, - ) - }); -} - -struct EvalInput { - buffer: String, - prompt: String, -} - -fn run_eval( - input: &EvalInput, - judge: &dyn Fn(&EvalInput, &str) -> eval_utils::EvalOutput<()>, -) -> eval_utils::EvalOutput<()> { - let dispatcher = gpui::TestDispatcher::new(StdRng::from_os_rng()); - let mut cx = TestAppContext::build(dispatcher, None); - cx.skip_drawing(); - - let buffer_text = run_inline_assistant_test( - input.buffer.clone(), - input.prompt.clone(), - |cx| { - // Reconfigure to use a real model instead of the fake one - let model_name = std::env::var("ZED_AGENT_MODEL") - .unwrap_or("anthropic/claude-sonnet-4-latest".into()); - - let selected_model = SelectedModel::from_str(&model_name) - .expect("Invalid model format. Use 'provider/model-id'"); - - log::info!("Selected model: {selected_model:?}"); - - cx.update(|_, cx| { - LanguageModelRegistry::global(cx).update(cx, |registry, cx| { - registry.select_inline_assistant_model(Some(&selected_model), cx); - }); - }); - }, - |_cx| { - log::info!("Waiting for actual response from the LLM..."); - }, - &mut cx, - ); - - judge(input, &buffer_text) -} 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 48da85d38554da8227d76d3cbe290e29ef4fc531..671579f9ef018b495b7993279a852595c78d3e02 100644 --- a/crates/agent_ui/src/inline_assistant.rs +++ b/crates/agent_ui/src/inline_assistant.rs @@ -1,8 +1,11 @@ +use language_model::AnthropicEventData; +use language_model::report_anthropic_event; use std::cmp; use std::mem; use std::ops::Range; use std::rc::Rc; use std::sync::Arc; +use uuid::Uuid; use crate::context::load_context; use crate::mention_set::MentionSet; @@ -15,7 +18,6 @@ use crate::{ use agent::HistoryStore; use agent_settings::AgentSettings; use anyhow::{Context as _, Result}; -use client::telemetry::Telemetry; use collections::{HashMap, HashSet, VecDeque, hash_map}; use editor::EditorSnapshot; use editor::MultiBufferOffset; @@ -38,15 +40,13 @@ use gpui::{ WeakEntity, Window, point, }; use language::{Buffer, Point, Selection, TransactionId}; -use language_model::{ - ConfigurationError, ConfiguredModel, LanguageModelRegistry, report_assistant_event, -}; +use language_model::{ConfigurationError, ConfiguredModel, LanguageModelRegistry}; use multi_buffer::MultiBufferRow; use parking_lot::Mutex; use project::{CodeAction, DisableAiSettings, LspAction, Project, ProjectTransaction}; use prompt_store::{PromptBuilder, PromptStore}; use settings::{Settings, SettingsStore}; -use telemetry_events::{AssistantEventData, AssistantKind, AssistantPhase}; + use terminal_view::{TerminalView, terminal_panel::TerminalPanel}; use text::{OffsetRangeExt, ToPoint as _}; use ui::prelude::*; @@ -54,13 +54,8 @@ use util::{RangeExt, ResultExt, maybe}; use workspace::{ItemHandle, Toast, Workspace, dock::Panel, notifications::NotificationId}; use zed_actions::agent::OpenSettings; -pub fn init( - fs: Arc, - prompt_builder: Arc, - telemetry: Arc, - cx: &mut App, -) { - cx.set_global(InlineAssistant::new(fs, prompt_builder, telemetry)); +pub fn init(fs: Arc, prompt_builder: Arc, cx: &mut App) { + cx.set_global(InlineAssistant::new(fs, prompt_builder)); cx.observe_global::(|cx| { if DisableAiSettings::get_global(cx).disable_ai { @@ -100,7 +95,6 @@ pub struct InlineAssistant { confirmed_assists: HashMap>, prompt_history: VecDeque, prompt_builder: Arc, - telemetry: Arc, fs: Arc, _inline_assistant_completions: Option>>, } @@ -108,11 +102,7 @@ pub struct InlineAssistant { impl Global for InlineAssistant {} impl InlineAssistant { - pub fn new( - fs: Arc, - prompt_builder: Arc, - telemetry: Arc, - ) -> Self { + pub fn new(fs: Arc, prompt_builder: Arc) -> Self { Self { next_assist_id: InlineAssistId::default(), next_assist_group_id: InlineAssistGroupId::default(), @@ -122,20 +112,11 @@ impl InlineAssistant { confirmed_assists: HashMap::default(), prompt_history: VecDeque::default(), prompt_builder, - telemetry, fs, _inline_assistant_completions: None, } } - #[cfg(any(test, feature = "test-support"))] - pub fn set_completion_receiver( - &mut self, - sender: mpsc::UnboundedSender>, - ) { - self._inline_assistant_completions = Some(sender); - } - pub fn register_workspace( &mut self, workspace: &Entity, @@ -457,17 +438,25 @@ impl InlineAssistant { codegen_ranges.push(anchor_range); if let Some(model) = LanguageModelRegistry::read_global(cx).inline_assistant_model() { - self.telemetry.report_assistant_event(AssistantEventData { - conversation_id: None, - kind: AssistantKind::Inline, - phase: AssistantPhase::Invoked, - message_id: None, - model: model.model.telemetry_id(), - model_provider: model.provider.id().to_string(), - response_latency: None, - error_message: None, - language_name: buffer.language().map(|language| language.name().to_proto()), - }); + telemetry::event!( + "Assistant Invoked", + kind = "inline", + phase = "invoked", + model = model.model.telemetry_id(), + model_provider = model.provider.id().to_string(), + language_name = buffer.language().map(|language| language.name().to_proto()) + ); + + report_anthropic_event( + &model.model, + AnthropicEventData { + completion_type: language_model::AnthropicCompletionType::Editor, + event: language_model::AnthropicEventType::Invoked, + language_name: buffer.language().map(|language| language.name().to_proto()), + message_id: None, + }, + cx, + ); } } @@ -491,6 +480,7 @@ impl InlineAssistant { let snapshot = editor.update(cx, |editor, cx| editor.snapshot(window, cx)); let assist_group_id = self.next_assist_group_id.post_inc(); + let session_id = Uuid::new_v4(); let prompt_buffer = cx.new(|cx| { MultiBuffer::singleton( cx.new(|cx| Buffer::local(initial_prompt.unwrap_or_default(), cx)), @@ -508,7 +498,7 @@ impl InlineAssistant { editor.read(cx).buffer().clone(), range.clone(), initial_transaction_id, - self.telemetry.clone(), + session_id, self.prompt_builder.clone(), cx, ) @@ -522,6 +512,7 @@ impl InlineAssistant { self.prompt_history.clone(), prompt_buffer.clone(), codegen.clone(), + session_id, self.fs.clone(), thread_store.clone(), prompt_store.clone(), @@ -1069,8 +1060,6 @@ impl InlineAssistant { } let active_alternative = assist.codegen.read(cx).active_alternative().clone(); - let message_id = active_alternative.read(cx).message_id.clone(); - if let Some(model) = LanguageModelRegistry::read_global(cx).inline_assistant_model() { let language_name = assist.editor.upgrade().and_then(|editor| { let multibuffer = editor.read(cx).buffer().read(cx); @@ -1079,28 +1068,49 @@ impl InlineAssistant { ranges .first() .and_then(|(buffer, _, _)| buffer.language()) - .map(|language| language.name()) + .map(|language| language.name().0.to_string()) }); - report_assistant_event( - AssistantEventData { - conversation_id: None, - kind: AssistantKind::Inline, + + let codegen = assist.codegen.read(cx); + let session_id = codegen.session_id(); + let message_id = active_alternative.read(cx).message_id.clone(); + let model_telemetry_id = model.model.telemetry_id(); + let model_provider_id = model.model.provider_id().to_string(); + + let (phase, event_type, anthropic_event_type) = if undo { + ( + "rejected", + "Assistant Response Rejected", + language_model::AnthropicEventType::Reject, + ) + } else { + ( + "accepted", + "Assistant Response Accepted", + language_model::AnthropicEventType::Accept, + ) + }; + + telemetry::event!( + event_type, + phase, + session_id = session_id.to_string(), + kind = "inline", + model = model_telemetry_id, + model_provider = model_provider_id, + language_name = language_name, + message_id = message_id.as_deref(), + ); + + report_anthropic_event( + &model.model, + language_model::AnthropicEventData { + completion_type: language_model::AnthropicCompletionType::Editor, + event: anthropic_event_type, + language_name, message_id, - phase: if undo { - AssistantPhase::Rejected - } else { - AssistantPhase::Accepted - }, - model: model.model.telemetry_id(), - model_provider: model.model.provider_id().to_string(), - response_latency: None, - error_message: None, - language_name: language_name.map(|name| name.to_proto()), }, - Some(self.telemetry.clone()), - cx.http_client(), - model.model.api_key(cx), - cx.background_executor(), + cx, ); } @@ -1187,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(); } @@ -1199,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); }) }); @@ -1455,60 +1465,8 @@ impl InlineAssistant { let old_snapshot = codegen.snapshot(cx); let old_buffer = codegen.old_buffer(cx); let deleted_row_ranges = codegen.diff(cx).deleted_row_ranges.clone(); - // let model_explanation = codegen.model_explanation(cx); editor.update(cx, |editor, cx| { - // Update tool description block - // if let Some(description) = model_explanation { - // if let Some(block_id) = decorations.model_explanation { - // editor.remove_blocks(HashSet::from_iter([block_id]), None, cx); - // let new_block_id = editor.insert_blocks( - // [BlockProperties { - // style: BlockStyle::Flex, - // placement: BlockPlacement::Below(assist.range.end), - // height: Some(1), - // render: Arc::new({ - // let description = description.clone(); - // move |cx| { - // div() - // .w_full() - // .py_1() - // .px_2() - // .bg(cx.theme().colors().editor_background) - // .border_y_1() - // .border_color(cx.theme().status().info_border) - // .child( - // Label::new(description.clone()) - // .color(Color::Muted) - // .size(LabelSize::Small), - // ) - // .into_any_element() - // } - // }), - // priority: 0, - // }], - // None, - // cx, - // ); - // decorations.model_explanation = new_block_id.into_iter().next(); - // } - // } else if let Some(block_id) = decorations.model_explanation { - // // Hide the block if there's no description - // editor.remove_blocks(HashSet::from_iter([block_id]), None, cx); - // let new_block_id = editor.insert_blocks( - // [BlockProperties { - // style: BlockStyle::Flex, - // placement: BlockPlacement::Below(assist.range.end), - // height: Some(0), - // render: Arc::new(|_cx| div().into_any_element()), - // priority: 0, - // }], - // None, - // cx, - // ); - // decorations.model_explanation = new_block_id.into_iter().next(); - // } - let old_blocks = mem::take(&mut decorations.removed_line_block_ids); editor.remove_blocks(old_blocks, None, cx); @@ -1627,6 +1585,27 @@ impl InlineAssistant { .map(InlineAssistTarget::Terminal) } } + + #[cfg(any(test, feature = "test-support"))] + pub fn set_completion_receiver( + &mut self, + sender: mpsc::UnboundedSender>, + ) { + self._inline_assistant_completions = Some(sender); + } + + #[cfg(any(test, feature = "test-support"))] + pub fn get_codegen( + &mut self, + assist_id: InlineAssistId, + cx: &mut App, + ) -> Option> { + self.assists.get(&assist_id).map(|inline_assist| { + inline_assist + .codegen + .update(cx, |codegen, _cx| codegen.active_alternative().clone()) + }) + } } struct EditorInlineAssists { @@ -2048,8 +2027,10 @@ fn merge_ranges(ranges: &mut Vec>, buffer: &MultiBufferSnapshot) { } } -#[cfg(any(test, feature = "test-support"))] +#[cfg(any(test, feature = "unit-eval"))] +#[cfg_attr(not(test), allow(dead_code))] pub mod test { + use std::sync::Arc; use agent::HistoryStore; @@ -2060,7 +2041,6 @@ pub mod test { use futures::channel::mpsc; use gpui::{AppContext, TestAppContext, UpdateGlobal as _}; use language::Buffer; - use language_model::LanguageModelRegistry; use project::Project; use prompt_store::PromptBuilder; use smol::stream::StreamExt as _; @@ -2069,13 +2049,32 @@ pub mod test { use crate::InlineAssistant; + #[derive(Debug)] + pub enum InlineAssistantOutput { + Success { + completion: Option, + description: Option, + full_buffer_text: String, + }, + Failure { + failure: String, + }, + // These fields are used for logging + #[allow(unused)] + Malformed { + completion: Option, + description: Option, + failure: Option, + }, + } + pub fn run_inline_assistant_test( base_buffer: String, prompt: String, setup: SetupF, test: TestF, cx: &mut TestAppContext, - ) -> String + ) -> InlineAssistantOutput where SetupF: FnOnce(&mut gpui::VisualTestContext), TestF: FnOnce(&mut gpui::VisualTestContext), @@ -2088,8 +2087,7 @@ pub mod test { cx.set_http_client(http); Client::production(cx) }); - let mut inline_assistant = - InlineAssistant::new(fs.clone(), prompt_builder, client.telemetry().clone()); + let mut inline_assistant = InlineAssistant::new(fs.clone(), prompt_builder); let (tx, mut completion_rx) = mpsc::unbounded(); inline_assistant.set_completion_receiver(tx); @@ -2168,39 +2166,247 @@ pub mod test { test(cx); - cx.executor() - .block_test(async { completion_rx.next().await }); + let assist_id = cx + .executor() + .block_test(async { completion_rx.next().await }) + .unwrap() + .unwrap(); + + let (completion, description, failure) = cx.update(|_, cx| { + InlineAssistant::update_global(cx, |inline_assistant, cx| { + let codegen = inline_assistant.get_codegen(assist_id, cx).unwrap(); + + let completion = codegen.read(cx).current_completion(); + let description = codegen.read(cx).current_description(); + let failure = codegen.read(cx).current_failure(); + + (completion, description, failure) + }) + }); - buffer.read_with(cx, |buffer, _| buffer.text()) + if failure.is_some() && (completion.is_some() || description.is_some()) { + InlineAssistantOutput::Malformed { + completion, + description, + failure, + } + } else if let Some(failure) = failure { + InlineAssistantOutput::Failure { failure } + } else { + InlineAssistantOutput::Success { + completion, + description, + full_buffer_text: buffer.read_with(cx, |buffer, _| buffer.text()), + } + } } +} - #[allow(unused)] - pub fn test_inline_assistant( - base_buffer: &'static str, - llm_output: &'static str, - cx: &mut TestAppContext, - ) -> String { - run_inline_assistant_test( - base_buffer.to_string(), - "Prompt doesn't matter because we're using a fake model".to_string(), - |cx| { - cx.update(|_, cx| LanguageModelRegistry::test(cx)); - }, - |cx| { - let fake_model = cx.update(|_, cx| { - LanguageModelRegistry::global(cx) - .update(cx, |registry, _| registry.fake_model()) - }); - let fake = fake_model.as_fake(); +#[cfg(any(test, feature = "unit-eval"))] +#[cfg_attr(not(test), allow(dead_code))] +pub mod evals { + use std::str::FromStr; + + use eval_utils::{EvalOutput, NoProcessor}; + use gpui::TestAppContext; + use language_model::{LanguageModelRegistry, SelectedModel}; + use rand::{SeedableRng as _, rngs::StdRng}; + + use crate::inline_assistant::test::{InlineAssistantOutput, run_inline_assistant_test}; + + #[test] + #[cfg_attr(not(feature = "unit-eval"), ignore)] + fn eval_single_cursor_edit() { + run_eval( + 20, + 1.0, + "Rename this variable to buffer_text".to_string(), + indoc::indoc! {" + struct EvalExampleStruct { + text: Strˇing, + prompt: String, + } + "} + .to_string(), + exact_buffer_match(indoc::indoc! {" + struct EvalExampleStruct { + buffer_text: String, + prompt: String, + } + "}), + ); + } - // let fake = fake_model; - fake.send_last_completion_stream_text_chunk(llm_output.to_string()); - fake.end_last_completion_stream(); + #[test] + #[cfg_attr(not(feature = "unit-eval"), ignore)] + fn eval_cant_do() { + run_eval( + 20, + 0.95, + "Rename the struct to EvalExampleStructNope", + indoc::indoc! {" + struct EvalExampleStruct { + text: Strˇing, + prompt: String, + } + "}, + uncertain_output, + ); + } - // Run again to process the model's response - cx.run_until_parked(); + #[test] + #[cfg_attr(not(feature = "unit-eval"), ignore)] + fn eval_unclear() { + run_eval( + 20, + 0.95, + "Make exactly the change I want you to make", + indoc::indoc! {" + struct EvalExampleStruct { + text: Strˇing, + prompt: String, + } + "}, + uncertain_output, + ); + } + + #[test] + #[cfg_attr(not(feature = "unit-eval"), ignore)] + fn eval_empty_buffer() { + run_eval( + 20, + 1.0, + "Write a Python hello, world program".to_string(), + "ˇ".to_string(), + |output| match output { + InlineAssistantOutput::Success { + full_buffer_text, .. + } => { + if full_buffer_text.is_empty() { + EvalOutput::failed("expected some output".to_string()) + } else { + EvalOutput::passed(format!("Produced {full_buffer_text}")) + } + } + o @ InlineAssistantOutput::Failure { .. } => EvalOutput::failed(format!( + "Assistant output does not match expected output: {:?}", + o + )), + o @ InlineAssistantOutput::Malformed { .. } => EvalOutput::failed(format!( + "Assistant output does not match expected output: {:?}", + o + )), }, - cx, - ) + ); + } + + fn run_eval( + iterations: usize, + expected_pass_ratio: f32, + prompt: impl Into, + buffer: impl Into, + judge: impl Fn(InlineAssistantOutput) -> eval_utils::EvalOutput<()> + Send + Sync + 'static, + ) { + let buffer = buffer.into(); + let prompt = prompt.into(); + + eval_utils::eval(iterations, expected_pass_ratio, NoProcessor, move || { + let dispatcher = gpui::TestDispatcher::new(StdRng::from_os_rng()); + let mut cx = TestAppContext::build(dispatcher, None); + cx.skip_drawing(); + + let output = run_inline_assistant_test( + buffer.clone(), + prompt.clone(), + |cx| { + // Reconfigure to use a real model instead of the fake one + let model_name = std::env::var("ZED_AGENT_MODEL") + .unwrap_or("anthropic/claude-sonnet-4-latest".into()); + + let selected_model = SelectedModel::from_str(&model_name) + .expect("Invalid model format. Use 'provider/model-id'"); + + log::info!("Selected model: {selected_model:?}"); + + cx.update(|_, cx| { + LanguageModelRegistry::global(cx).update(cx, |registry, cx| { + registry.select_inline_assistant_model(Some(&selected_model), cx); + }); + }); + }, + |_cx| { + log::info!("Waiting for actual response from the LLM..."); + }, + &mut cx, + ); + + cx.quit(); + + judge(output) + }); + } + + fn uncertain_output(output: InlineAssistantOutput) -> EvalOutput<()> { + match &output { + o @ InlineAssistantOutput::Success { + completion, + description, + .. + } => { + if description.is_some() && completion.is_none() { + EvalOutput::passed(format!( + "Assistant produced no completion, but a description:\n{}", + description.as_ref().unwrap() + )) + } else { + EvalOutput::failed(format!("Assistant produced a completion:\n{:?}", o)) + } + } + InlineAssistantOutput::Failure { + failure: error_message, + } => EvalOutput::passed(format!( + "Assistant produced a failure message: {}", + error_message + )), + o @ InlineAssistantOutput::Malformed { .. } => { + EvalOutput::failed(format!("Assistant produced a malformed response:\n{:?}", o)) + } + } + } + + fn exact_buffer_match( + correct_output: impl Into, + ) -> impl Fn(InlineAssistantOutput) -> EvalOutput<()> { + let correct_output = correct_output.into(); + move |output| match output { + InlineAssistantOutput::Success { + description, + full_buffer_text, + .. + } => { + if full_buffer_text == correct_output && description.is_none() { + EvalOutput::passed("Assistant output matches") + } else if full_buffer_text == correct_output { + EvalOutput::failed(format!( + "Assistant output produced an unescessary description description:\n{:?}", + description + )) + } else { + EvalOutput::failed(format!( + "Assistant output does not match expected output:\n{:?}\ndescription:\n{:?}", + full_buffer_text, description + )) + } + } + o @ InlineAssistantOutput::Failure { .. } => EvalOutput::failed(format!( + "Assistant output does not match expected output: {:?}", + o + )), + o @ InlineAssistantOutput::Malformed { .. } => EvalOutput::failed(format!( + "Assistant output does not match expected output: {:?}", + o + )), + } } } diff --git a/crates/agent_ui/src/inline_prompt_editor.rs b/crates/agent_ui/src/inline_prompt_editor.rs index b9852ea727c7974e3564fadc652f132076c01f09..8d96d56ea67cc9366df420b23e2221636d3450fb 100644 --- a/crates/agent_ui/src/inline_prompt_editor.rs +++ b/crates/agent_ui/src/inline_prompt_editor.rs @@ -8,10 +8,11 @@ use editor::{ ContextMenuOptions, Editor, EditorElement, EditorEvent, EditorMode, EditorStyle, MultiBuffer, actions::{MoveDown, MoveUp}, }; +use feature_flags::{FeatureFlagAppExt, InlineAssistantUseToolFeatureFlag}; use fs::Fs; use gpui::{ - AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Subscription, - TextStyle, TextStyleRefinement, WeakEntity, Window, + AnyElement, App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, + Subscription, TextStyle, TextStyleRefinement, WeakEntity, Window, actions, }; use language_model::{LanguageModel, LanguageModelRegistry}; use markdown::{HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle}; @@ -26,11 +27,13 @@ use std::sync::Arc; use theme::ThemeSettings; use ui::utils::WithRemSize; use ui::{IconButtonShape, KeyBinding, PopoverMenuHandle, Tooltip, prelude::*}; -use workspace::Workspace; +use uuid::Uuid; +use workspace::notifications::NotificationId; +use workspace::{Toast, Workspace}; use zed_actions::agent::ToggleModelSelector; use crate::agent_model_selector::AgentModelSelector; -use crate::buffer_codegen::BufferCodegen; +use crate::buffer_codegen::{BufferCodegen, CodegenAlternative}; use crate::completion_provider::{ PromptCompletionProvider, PromptCompletionProviderDelegate, PromptContextType, }; @@ -39,6 +42,19 @@ use crate::mention_set::{MentionSet, crease_for_mention}; use crate::terminal_codegen::TerminalCodegen; use crate::{CycleNextInlineAssist, CyclePreviousInlineAssist, ModelUsageContext}; +actions!(inline_assistant, [ThumbsUpResult, ThumbsDownResult]); + +enum CompletionState { + Pending, + Generated { completion_text: Option }, + Rated, +} + +struct SessionState { + session_id: Uuid, + completion: CompletionState, +} + pub struct PromptEditor { pub editor: Entity, mode: PromptEditorMode, @@ -54,6 +70,7 @@ pub struct PromptEditor { _codegen_subscription: Subscription, editor_subscriptions: Vec, show_rate_limit_notice: bool, + session_state: SessionState, _phantom: std::marker::PhantomData, } @@ -84,11 +101,11 @@ impl Render for PromptEditor { let left_gutter_width = gutter.full_width() + (gutter.margin / 2.0); let right_padding = editor_margins.right + RIGHT_PADDING; - let explanation = codegen - .active_alternative() - .read(cx) - .model_explanation - .clone(); + let active_alternative = codegen.active_alternative().read(cx); + let explanation = active_alternative + .description + .clone() + .or_else(|| active_alternative.failure.clone()); (left_gutter_width, right_padding, explanation) } @@ -122,7 +139,7 @@ impl Render for PromptEditor { if let Some(explanation) = &explanation { markdown.update(cx, |markdown, cx| { - markdown.reset(explanation.clone(), cx); + markdown.reset(SharedString::from(explanation), cx); }); } @@ -153,6 +170,8 @@ impl Render for PromptEditor { .on_action(cx.listener(Self::cancel)) .on_action(cx.listener(Self::move_up)) .on_action(cx.listener(Self::move_down)) + .on_action(cx.listener(Self::thumbs_up)) + .on_action(cx.listener(Self::thumbs_down)) .capture_action(cx.listener(Self::cycle_prev)) .capture_action(cx.listener(Self::cycle_next)) .child( @@ -338,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 }); @@ -429,6 +448,7 @@ impl PromptEditor { } self.edited_since_done = true; + self.session_state.completion = CompletionState::Pending; cx.notify(); } EditorEvent::Blurred => { @@ -500,22 +520,207 @@ impl PromptEditor { fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context) { match self.codegen_status(cx) { CodegenStatus::Idle => { + self.fire_started_telemetry(cx); cx.emit(PromptEditorEvent::StartRequested); } CodegenStatus::Pending => {} CodegenStatus::Done => { if self.edited_since_done { + self.fire_started_telemetry(cx); cx.emit(PromptEditorEvent::StartRequested); } else { cx.emit(PromptEditorEvent::ConfirmRequested { execute: false }); } } CodegenStatus::Error(_) => { + self.fire_started_telemetry(cx); cx.emit(PromptEditorEvent::StartRequested); } } } + fn fire_started_telemetry(&self, cx: &Context) { + let Some(model) = LanguageModelRegistry::read_global(cx).inline_assistant_model() else { + return; + }; + + let model_telemetry_id = model.model.telemetry_id(); + let model_provider_id = model.provider.id().to_string(); + + let (kind, language_name) = match &self.mode { + PromptEditorMode::Buffer { codegen, .. } => { + let codegen = codegen.read(cx); + ( + "inline", + codegen.language_name(cx).map(|name| name.to_string()), + ) + } + PromptEditorMode::Terminal { .. } => ("inline_terminal", None), + }; + + telemetry::event!( + "Assistant Started", + session_id = self.session_state.session_id.to_string(), + kind = kind, + phase = "started", + model = model_telemetry_id, + model_provider = model_provider_id, + language_name = language_name, + ); + } + + fn thumbs_up(&mut self, _: &ThumbsUpResult, _window: &mut Window, cx: &mut Context) { + match &self.session_state.completion { + CompletionState::Pending => { + self.toast("Can't rate, still generating...", None, cx); + return; + } + CompletionState::Rated => { + self.toast( + "Already rated this completion", + Some(self.session_state.session_id), + cx, + ); + return; + } + CompletionState::Generated { completion_text } => { + let model_info = self.model_selector.read(cx).active_model(cx); + let (model_id, use_streaming_tools) = { + let Some(configured_model) = model_info else { + self.toast("No configured model", None, cx); + return; + }; + ( + configured_model.model.telemetry_id(), + CodegenAlternative::use_streaming_tools( + configured_model.model.as_ref(), + cx, + ), + ) + }; + + let selected_text = match &self.mode { + PromptEditorMode::Buffer { codegen, .. } => { + codegen.read(cx).selected_text(cx).map(|s| s.to_string()) + } + PromptEditorMode::Terminal { .. } => None, + }; + + let prompt = self.editor.read(cx).text(cx); + + let kind = match &self.mode { + PromptEditorMode::Buffer { .. } => "inline", + PromptEditorMode::Terminal { .. } => "inline_terminal", + }; + + telemetry::event!( + "Inline Assistant Rated", + rating = "positive", + session_id = self.session_state.session_id.to_string(), + kind = kind, + model = model_id, + prompt = prompt, + completion = completion_text, + selected_text = selected_text, + use_streaming_tools + ); + + self.session_state.completion = CompletionState::Rated; + + cx.notify(); + } + } + } + + fn thumbs_down(&mut self, _: &ThumbsDownResult, _window: &mut Window, cx: &mut Context) { + match &self.session_state.completion { + CompletionState::Pending => { + self.toast("Can't rate, still generating...", None, cx); + return; + } + CompletionState::Rated => { + self.toast( + "Already rated this completion", + Some(self.session_state.session_id), + cx, + ); + return; + } + CompletionState::Generated { completion_text } => { + let model_info = self.model_selector.read(cx).active_model(cx); + let (model_telemetry_id, use_streaming_tools) = { + let Some(configured_model) = model_info else { + self.toast("No configured model", None, cx); + return; + }; + ( + configured_model.model.telemetry_id(), + CodegenAlternative::use_streaming_tools( + configured_model.model.as_ref(), + cx, + ), + ) + }; + + let selected_text = match &self.mode { + PromptEditorMode::Buffer { codegen, .. } => { + codegen.read(cx).selected_text(cx).map(|s| s.to_string()) + } + PromptEditorMode::Terminal { .. } => None, + }; + + let prompt = self.editor.read(cx).text(cx); + + let kind = match &self.mode { + PromptEditorMode::Buffer { .. } => "inline", + PromptEditorMode::Terminal { .. } => "inline_terminal", + }; + + telemetry::event!( + "Inline Assistant Rated", + rating = "negative", + session_id = self.session_state.session_id.to_string(), + kind = kind, + model = model_telemetry_id, + prompt = prompt, + completion = completion_text, + selected_text = selected_text, + use_streaming_tools + ); + + self.session_state.completion = CompletionState::Rated; + + cx.notify(); + } + } + } + + fn toast(&mut self, msg: &str, uuid: Option, cx: &mut Context<'_, PromptEditor>) { + self.workspace + .update(cx, |workspace, cx| { + enum InlinePromptRating {} + workspace.show_toast( + { + let mut toast = Toast::new( + NotificationId::unique::(), + msg.to_string(), + ) + .autohide(); + + if let Some(uuid) = uuid { + toast = toast.on_click("Click to copy rating ID", move |_, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(uuid.to_string())); + }); + }; + + toast + }, + cx, + ); + }) + .ok(); + } + fn move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context) { if let Some(ix) = self.prompt_history_ix { if ix > 0 { @@ -621,6 +826,9 @@ impl PromptEditor { .into_any_element(), ] } else { + let show_rating_buttons = cx.has_flag::(); + let rated = matches!(self.session_state.completion, CompletionState::Rated); + let accept = IconButton::new("accept", IconName::Check) .icon_color(Color::Info) .shape(IconButtonShape::Square) @@ -632,25 +840,92 @@ impl PromptEditor { })) .into_any_element(); - match &self.mode { - PromptEditorMode::Terminal { .. } => vec![ - accept, - IconButton::new("confirm", IconName::PlayFilled) - .icon_color(Color::Info) - .shape(IconButtonShape::Square) - .tooltip(|_window, cx| { - Tooltip::for_action( - "Execute Generated Command", - &menu::SecondaryConfirm, - cx, - ) - }) - .on_click(cx.listener(|_, _, _, cx| { - cx.emit(PromptEditorEvent::ConfirmRequested { execute: true }); - })) + let mut buttons = Vec::new(); + + if show_rating_buttons { + buttons.push( + 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(), - ], - PromptEditorMode::Buffer { .. } => vec![accept], + ); + } + + buttons.push(accept); + + match &self.mode { + PromptEditorMode::Terminal { .. } => { + buttons.push( + IconButton::new("confirm", IconName::PlayFilled) + .icon_color(Color::Info) + .shape(IconButtonShape::Square) + .tooltip(|_window, cx| { + Tooltip::for_action( + "Execute Generated Command", + &menu::SecondaryConfirm, + cx, + ) + }) + .on_click(cx.listener(|_, _, _, cx| { + cx.emit(PromptEditorEvent::ConfirmRequested { + execute: true, + }); + })) + .into_any_element(), + ); + buttons + } + PromptEditorMode::Buffer { .. } => buttons, } } } @@ -685,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() } @@ -909,6 +1195,7 @@ impl PromptEditor { prompt_history: VecDeque, prompt_buffer: Entity, codegen: Entity, + session_id: Uuid, fs: Arc, history_store: Entity, prompt_store: Option>, @@ -979,6 +1266,10 @@ impl PromptEditor { editor_subscriptions: Vec::new(), show_rate_limit_notice: false, mode, + session_state: SessionState { + session_id, + completion: CompletionState::Pending, + }, _phantom: Default::default(), }; @@ -989,7 +1280,7 @@ impl PromptEditor { fn handle_codegen_changed( &mut self, - _: Entity, + codegen: Entity, cx: &mut Context>, ) { match self.codegen_status(cx) { @@ -998,10 +1289,15 @@ impl PromptEditor { .update(cx, |editor, _| editor.set_read_only(false)); } CodegenStatus::Pending => { + self.session_state.completion = CompletionState::Pending; self.editor .update(cx, |editor, _| editor.set_read_only(true)); } CodegenStatus::Done => { + let completion = codegen.read(cx).active_completion(cx); + self.session_state.completion = CompletionState::Generated { + completion_text: completion, + }; self.edited_since_done = false; self.editor .update(cx, |editor, _| editor.set_read_only(false)); @@ -1057,6 +1353,7 @@ impl PromptEditor { prompt_history: VecDeque, prompt_buffer: Entity, codegen: Entity, + session_id: Uuid, fs: Arc, history_store: Entity, prompt_store: Option>, @@ -1122,6 +1419,10 @@ impl PromptEditor { editor_subscriptions: Vec::new(), mode, show_rate_limit_notice: false, + session_state: SessionState { + session_id, + completion: CompletionState::Pending, + }, _phantom: Default::default(), }; this.count_lines(cx); @@ -1154,17 +1455,21 @@ impl PromptEditor { } } - fn handle_codegen_changed(&mut self, _: Entity, cx: &mut Context) { + fn handle_codegen_changed(&mut self, codegen: Entity, cx: &mut Context) { match &self.codegen().read(cx).status { CodegenStatus::Idle => { self.editor .update(cx, |editor, _| editor.set_read_only(false)); } CodegenStatus::Pending => { + self.session_state.completion = CompletionState::Pending; self.editor .update(cx, |editor, _| editor.set_read_only(true)); } CodegenStatus::Done | CodegenStatus::Error(_) => { + self.session_state.completion = CompletionState::Generated { + completion_text: codegen.read(cx).completion(), + }; self.edited_since_done = false; self.editor .update(cx, |editor, _| editor.set_read_only(false)); diff --git a/crates/agent_ui/src/language_model_selector.rs b/crates/agent_ui/src/language_model_selector.rs index 5b5a4513c6dca32e985c966e07ad84e84fc9a872..77c8c95255908dc54639ad7ac6c55f1e8b8151f0 100644 --- a/crates/agent_ui/src/language_model_selector.rs +++ b/crates/agent_ui/src/language_model_selector.rs @@ -1,27 +1,33 @@ use std::{cmp::Reverse, sync::Arc}; -use collections::IndexMap; +use agent_settings::AgentSettings; +use collections::{HashMap, HashSet, IndexMap}; use fuzzy::{StringMatch, StringMatchCandidate, match_strings}; use gpui::{ Action, AnyElement, App, BackgroundExecutor, DismissEvent, FocusHandle, Subscription, Task, }; use language_model::{ - AuthenticateError, ConfiguredModel, LanguageModel, LanguageModelProviderId, - LanguageModelRegistry, + AuthenticateError, ConfiguredModel, LanguageModel, LanguageModelId, LanguageModelProvider, + LanguageModelProviderId, LanguageModelRegistry, }; use ordered_float::OrderedFloat; use picker::{Picker, PickerDelegate}; -use 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, @@ -30,6 +36,7 @@ pub fn language_model_selector( let delegate = LanguageModelPickerDelegate::new( get_active_model, on_model_changed, + on_toggle_favorite, popover_styles, focus_handle, window, @@ -47,7 +54,17 @@ pub fn language_model_selector( } fn all_models(cx: &App) -> GroupedModels { - let providers = LanguageModelRegistry::global(cx).read(cx).providers(); + let lm_registry = LanguageModelRegistry::global(cx).read(cx); + let providers = lm_registry.providers(); + + let mut favorites_index = FavoritesIndex::default(); + + for sel in &AgentSettings::get_global(cx).favorite_models { + favorites_index + .entry(sel.provider.0.clone().into()) + .or_default() + .insert(sel.model.clone().into()); + } let recommended = providers .iter() @@ -55,10 +72,7 @@ fn all_models(cx: &App) -> GroupedModels { provider .recommended_models(cx) .into_iter() - .map(|model| ModelInfo { - model, - icon: provider.icon(), - }) + .map(|model| ModelInfo::new(&**provider, model, &favorites_index)) }) .collect(); @@ -68,25 +82,44 @@ fn all_models(cx: &App) -> GroupedModels { provider .provided_models(cx) .into_iter() - .map(|model| ModelInfo { - model, - icon: provider.icon(), - }) + .map(|model| ModelInfo::new(&**provider, model, &favorites_index)) }) .collect(); GroupedModels::new(all, recommended) } +type FavoritesIndex = HashMap>; + #[derive(Clone)] struct ModelInfo { model: Arc, icon: IconName, + 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: provider.icon(), + 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, @@ -100,6 +133,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, @@ -115,6 +149,7 @@ impl LanguageModelPickerDelegate { selected_index: Self::get_active_model_index(&entries, get_active_model(cx)), filtered_entries: entries, get_active_model: Arc::new(get_active_model), + on_toggle_favorite: Arc::new(on_toggle_favorite), _authenticate_all_providers_task: Self::authenticate_all_providers(cx), _subscriptions: vec![cx.subscribe_in( &LanguageModelRegistry::global(cx), @@ -214,15 +249,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(); @@ -234,6 +311,7 @@ impl GroupedModels { } Self { + favorites, recommended, all: all_by_provider, } @@ -242,13 +320,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() { @@ -258,12 +341,11 @@ impl GroupedModels { entries.push(LanguageModelPickerEntry::Separator( models[0].model.provider_name().0, )); - entries.extend( - models - .iter() - .map(|info| LanguageModelPickerEntry::Model(info.clone())), - ); + for info in models { + entries.push(LanguageModelPickerEntry::Model(info.clone())); + } } + entries } } @@ -464,23 +546,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()); @@ -489,35 +557,20 @@ impl PickerDelegate for LanguageModelPickerDelegate { let is_selected = Some(model_info.model.provider_id()) == active_provider_id && Some(model_info.model.id()) == active_model_id; - let 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( - Icon::new(model_info.icon) - .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) + .icon(model_info.icon) + .is_selected(is_selected) + .is_focused(selected) + .is_favorite(is_favorite) + .on_toggle_favorite(handle_action_click) .into_any_element(), ) } @@ -527,7 +580,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(); @@ -535,26 +588,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()) } } @@ -653,11 +687,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: IconName::Ai, + .map(|(provider, name)| { + let is_favorite = favorites + .iter() + .any(|(fav_provider, fav_name)| *fav_provider == provider && *fav_name == name); + ModelInfo { + model: Arc::new(TestLanguageModel::new(name, provider)), + icon: IconName::Ai, + is_favorite, + } }) .collect() } @@ -795,4 +842,93 @@ mod tests { vec!["zed/claude", "zed/gemini", "copilot/claude"], ); } + + #[gpui::test] + fn test_favorites_section_appears_when_favorites_exist(_cx: &mut TestAppContext) { + let recommended_models = create_models(vec![("zed", "claude")]); + let all_models = create_models_with_favorites( + vec![("zed", "claude"), ("zed", "gemini"), ("openai", "gpt-4")], + vec![("zed", "gemini")], + ); + + let grouped_models = GroupedModels::new(all_models, recommended_models); + let entries = grouped_models.entries(); + + assert!(matches!( + entries.first(), + Some(LanguageModelPickerEntry::Separator(s)) if s == "Favorite" + )); + + assert_models_eq(grouped_models.favorites, vec!["zed/gemini"]); + } + + #[gpui::test] + fn test_no_favorites_section_when_no_favorites(_cx: &mut TestAppContext) { + let recommended_models = create_models(vec![("zed", "claude")]); + let all_models = create_models(vec![("zed", "claude"), ("zed", "gemini")]); + + let grouped_models = GroupedModels::new(all_models, recommended_models); + let entries = grouped_models.entries(); + + assert!(matches!( + entries.first(), + Some(LanguageModelPickerEntry::Separator(s)) if s == "Recommended" + )); + + assert!(grouped_models.favorites.is_empty()); + } + + #[gpui::test] + fn test_models_have_correct_actions(_cx: &mut TestAppContext) { + let recommended_models = + create_models_with_favorites(vec![("zed", "claude")], vec![("zed", "claude")]); + let all_models = create_models_with_favorites( + vec![("zed", "claude"), ("zed", "gemini"), ("openai", "gpt-4")], + vec![("zed", "claude")], + ); + + let grouped_models = GroupedModels::new(all_models, recommended_models); + let entries = grouped_models.entries(); + + for entry in &entries { + if let LanguageModelPickerEntry::Model(info) = entry { + if info.model.telemetry_id() == "zed/claude" { + assert!(info.is_favorite, "zed/claude should be a favorite"); + } else { + assert!( + !info.is_favorite, + "{} should not be a favorite", + info.model.telemetry_id() + ); + } + } + } + } + + #[gpui::test] + fn test_favorites_appear_in_other_sections(_cx: &mut TestAppContext) { + let favorites = vec![("zed", "gemini"), ("openai", "gpt-4")]; + + let recommended_models = + create_models_with_favorites(vec![("zed", "claude")], favorites.clone()); + + let all_models = create_models_with_favorites( + vec![ + ("zed", "claude"), + ("zed", "gemini"), + ("openai", "gpt-4"), + ("openai", "gpt-3.5"), + ], + favorites, + ); + + let grouped_models = GroupedModels::new(all_models, recommended_models); + + assert_models_eq(grouped_models.favorites, vec!["zed/gemini", "openai/gpt-4"]); + assert_models_eq(grouped_models.recommended, vec!["zed/claude"]); + assert_models_eq( + grouped_models.all.values().flatten().cloned().collect(), + vec!["zed/claude", "zed/gemini", "openai/gpt-4", "openai/gpt-3.5"], + ); + } } diff --git a/crates/agent_ui/src/profile_selector.rs b/crates/agent_ui/src/profile_selector.rs index 0182be0912d3b8a8a046371ce725e7d21a0ddb58..ac08070fcefa92854b51bc8a66d4d388d08e087d 100644 --- a/crates/agent_ui/src/profile_selector.rs +++ b/crates/agent_ui/src/profile_selector.rs @@ -1,4 +1,4 @@ -use crate::{ManageProfiles, ToggleProfileSelector}; +use crate::{CycleModeSelector, ManageProfiles, ToggleProfileSelector}; use agent_settings::{ AgentProfile, AgentProfileId, AgentSettings, AvailableProfiles, builtin_profiles, }; @@ -70,6 +70,29 @@ impl ProfileSelector { self.picker_handle.clone() } + pub fn cycle_profile(&mut self, cx: &mut Context) { + if !self.provider.profiles_supported(cx) { + return; + } + + let profiles = AgentProfile::available_profiles(cx); + if profiles.is_empty() { + return; + } + + let current_profile_id = self.provider.profile_id(cx); + let current_index = profiles + .keys() + .position(|id| id == ¤t_profile_id) + .unwrap_or(0); + + let next_index = (current_index + 1) % profiles.len(); + + if let Some((next_profile_id, _)) = profiles.get_index(next_index) { + self.provider.set_profile(next_profile_id.clone(), cx); + } + } + fn ensure_picker( &mut self, window: &mut Window, @@ -163,14 +186,29 @@ impl Render for ProfileSelector { PickerPopoverMenu::new( picker, trigger_button, - move |_window, cx| { - Tooltip::for_action_in( - "Toggle Profile Menu", - &ToggleProfileSelector, - &focus_handle, - cx, - ) - }, + Tooltip::element({ + move |_window, cx| { + let container = || h_flex().gap_1().justify_between(); + v_flex() + .gap_1() + .child( + container() + .pb_1() + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .child(Label::new("Cycle Through Profiles")) + .child(KeyBinding::for_action_in( + &CycleModeSelector, + &focus_handle, + cx, + )), + ) + .child(container().child(Label::new("Toggle Profile Menu")).child( + KeyBinding::for_action_in(&ToggleProfileSelector, &focus_handle, cx), + )) + .into_any() + } + }), gpui::Corner::BottomRight, cx, ) diff --git a/crates/agent_ui/src/terminal_codegen.rs b/crates/agent_ui/src/terminal_codegen.rs index 5a4a9d560a16e858dcaedf706f2067a24bc12c5f..e93d3d3991378ddb4156b264be1f0a5ab4d4faac 100644 --- a/crates/agent_ui/src/terminal_codegen.rs +++ b/crates/agent_ui/src/terminal_codegen.rs @@ -1,37 +1,38 @@ use crate::inline_prompt_editor::CodegenStatus; -use client::telemetry::Telemetry; use futures::{SinkExt, StreamExt, channel::mpsc}; use gpui::{App, AppContext as _, Context, Entity, EventEmitter, Task}; -use language_model::{ - ConfiguredModel, LanguageModelRegistry, LanguageModelRequest, report_assistant_event, -}; -use std::{sync::Arc, time::Instant}; -use telemetry_events::{AssistantEventData, AssistantKind, AssistantPhase}; +use language_model::{ConfiguredModel, LanguageModelRegistry, LanguageModelRequest}; +use std::time::Instant; use terminal::Terminal; +use uuid::Uuid; pub struct TerminalCodegen { pub status: CodegenStatus, - pub telemetry: Option>, terminal: Entity, generation: Task<()>, pub message_id: Option, transaction: Option, + session_id: Uuid, } impl EventEmitter for TerminalCodegen {} impl TerminalCodegen { - pub fn new(terminal: Entity, telemetry: Option>) -> Self { + pub fn new(terminal: Entity, session_id: Uuid) -> Self { Self { terminal, - telemetry, status: CodegenStatus::Idle, generation: Task::ready(()), message_id: None, transaction: None, + session_id, } } + pub fn session_id(&self) -> Uuid { + self.session_id + } + pub fn start(&mut self, prompt_task: Task, cx: &mut Context) { let Some(ConfiguredModel { model, .. }) = LanguageModelRegistry::read_global(cx).inline_assistant_model() @@ -39,15 +40,15 @@ impl TerminalCodegen { return; }; - let model_api_key = model.api_key(cx); - let http_client = cx.http_client(); - let telemetry = self.telemetry.clone(); + let anthropic_reporter = language_model::AnthropicEventReporter::new(&model, cx); + let session_id = self.session_id; + let model_telemetry_id = model.telemetry_id(); + let model_provider_id = model.provider_id().to_string(); + self.status = CodegenStatus::Pending; self.transaction = Some(TerminalTransaction::start(self.terminal.clone())); self.generation = cx.spawn(async move |this, cx| { let prompt = prompt_task.await; - let model_telemetry_id = model.telemetry_id(); - let model_provider_id = model.provider_id(); let response = model.stream_completion_text(prompt, cx).await; let generate = async { let message_id = response @@ -59,7 +60,7 @@ impl TerminalCodegen { let task = cx.background_spawn({ let message_id = message_id.clone(); - let executor = cx.background_executor().clone(); + let anthropic_reporter = anthropic_reporter.clone(); async move { let mut response_latency = None; let request_start = Instant::now(); @@ -79,24 +80,27 @@ impl TerminalCodegen { let result = task.await; let error_message = result.as_ref().err().map(|error| error.to_string()); - report_assistant_event( - AssistantEventData { - conversation_id: None, - kind: AssistantKind::InlineTerminal, - message_id, - phase: AssistantPhase::Response, - model: model_telemetry_id, - model_provider: model_provider_id.to_string(), - response_latency, - error_message, - language_name: None, - }, - telemetry, - http_client, - model_api_key, - &executor, + + telemetry::event!( + "Assistant Responded", + session_id = session_id.to_string(), + kind = "inline_terminal", + phase = "response", + model = model_telemetry_id, + model_provider = model_provider_id, + language_name = Option::<&str>::None, + message_id = message_id, + response_latency = response_latency, + error_message = error_message, ); + anthropic_reporter.report(language_model::AnthropicEventData { + completion_type: language_model::AnthropicCompletionType::Terminal, + event: language_model::AnthropicEventType::Response, + language_name: None, + message_id, + }); + result?; anyhow::Ok(()) } @@ -135,6 +139,12 @@ impl TerminalCodegen { cx.notify(); } + pub fn completion(&self) -> Option { + self.transaction + .as_ref() + .map(|transaction| transaction.completion.clone()) + } + pub fn stop(&mut self, cx: &mut Context) { self.status = CodegenStatus::Done; self.generation = Task::ready(()); @@ -167,27 +177,32 @@ pub const CLEAR_INPUT: &str = "\x03"; const CARRIAGE_RETURN: &str = "\x0d"; struct TerminalTransaction { + completion: String, terminal: Entity, } impl TerminalTransaction { pub fn start(terminal: Entity) -> Self { - Self { terminal } + Self { + completion: String::new(), + terminal, + } } pub fn push(&mut self, hunk: String, cx: &mut App) { // Ensure that the assistant cannot accidentally execute commands that are streamed into the terminal let input = Self::sanitize_input(hunk); + self.completion.push_str(&input); self.terminal .update(cx, |terminal, _| terminal.input(input.into_bytes())); } - pub fn undo(&self, cx: &mut App) { + pub fn undo(self, cx: &mut App) { self.terminal .update(cx, |terminal, _| terminal.input(CLEAR_INPUT.as_bytes())); } - pub fn complete(&self, cx: &mut App) { + pub fn complete(self, cx: &mut App) { self.terminal .update(cx, |terminal, _| terminal.input(CARRIAGE_RETURN.as_bytes())); } diff --git a/crates/agent_ui/src/terminal_inline_assistant.rs b/crates/agent_ui/src/terminal_inline_assistant.rs index 43ea697bece318699f350259a0e2e38d1a4f4d8d..cacbc316bb84e74e5c369451791f777a9bf58e82 100644 --- a/crates/agent_ui/src/terminal_inline_assistant.rs +++ b/crates/agent_ui/src/terminal_inline_assistant.rs @@ -8,7 +8,7 @@ use crate::{ use agent::HistoryStore; use agent_settings::AgentSettings; use anyhow::{Context as _, Result}; -use client::telemetry::Telemetry; + use cloud_llm_client::CompletionIntent; use collections::{HashMap, VecDeque}; use editor::{MultiBuffer, actions::SelectAll}; @@ -17,24 +17,19 @@ use gpui::{App, Entity, Focusable, Global, Subscription, Task, UpdateGlobal, Wea use language::Buffer; use language_model::{ ConfiguredModel, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, - Role, report_assistant_event, + Role, report_anthropic_event, }; use project::Project; use prompt_store::{PromptBuilder, PromptStore}; use std::sync::Arc; -use telemetry_events::{AssistantEventData, AssistantKind, AssistantPhase}; use terminal_view::TerminalView; use ui::prelude::*; use util::ResultExt; +use uuid::Uuid; use workspace::{Toast, Workspace, notifications::NotificationId}; -pub fn init( - fs: Arc, - prompt_builder: Arc, - telemetry: Arc, - cx: &mut App, -) { - cx.set_global(TerminalInlineAssistant::new(fs, prompt_builder, telemetry)); +pub fn init(fs: Arc, prompt_builder: Arc, cx: &mut App) { + cx.set_global(TerminalInlineAssistant::new(fs, prompt_builder)); } const DEFAULT_CONTEXT_LINES: usize = 50; @@ -44,7 +39,6 @@ pub struct TerminalInlineAssistant { next_assist_id: TerminalInlineAssistId, assists: HashMap, prompt_history: VecDeque, - telemetry: Option>, fs: Arc, prompt_builder: Arc, } @@ -52,16 +46,11 @@ pub struct TerminalInlineAssistant { impl Global for TerminalInlineAssistant {} impl TerminalInlineAssistant { - pub fn new( - fs: Arc, - prompt_builder: Arc, - telemetry: Arc, - ) -> Self { + pub fn new(fs: Arc, prompt_builder: Arc) -> Self { Self { next_assist_id: TerminalInlineAssistId::default(), assists: HashMap::default(), prompt_history: VecDeque::default(), - telemetry: Some(telemetry), fs, prompt_builder, } @@ -80,13 +69,14 @@ impl TerminalInlineAssistant { ) { let terminal = terminal_view.read(cx).terminal().clone(); let assist_id = self.next_assist_id.post_inc(); + let session_id = Uuid::new_v4(); let prompt_buffer = cx.new(|cx| { MultiBuffer::singleton( cx.new(|cx| Buffer::local(initial_prompt.unwrap_or_default(), cx)), cx, ) }); - let codegen = cx.new(|_| TerminalCodegen::new(terminal, self.telemetry.clone())); + let codegen = cx.new(|_| TerminalCodegen::new(terminal, session_id)); let prompt_editor = cx.new(|cx| { PromptEditor::new_terminal( @@ -94,6 +84,7 @@ impl TerminalInlineAssistant { self.prompt_history.clone(), prompt_buffer.clone(), codegen, + session_id, self.fs.clone(), thread_store.clone(), prompt_store.clone(), @@ -136,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); }); }); @@ -301,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(); @@ -309,27 +300,45 @@ impl TerminalInlineAssistant { LanguageModelRegistry::read_global(cx).inline_assistant_model() { let codegen = assist.codegen.read(cx); - let executor = cx.background_executor().clone(); - report_assistant_event( - AssistantEventData { - conversation_id: None, - kind: AssistantKind::InlineTerminal, - message_id: codegen.message_id.clone(), - phase: if undo { - AssistantPhase::Rejected - } else { - AssistantPhase::Accepted - }, - model: model.telemetry_id(), - model_provider: model.provider_id().to_string(), - response_latency: None, - error_message: None, + let session_id = codegen.session_id(); + let message_id = codegen.message_id.clone(); + let model_telemetry_id = model.telemetry_id(); + let model_provider_id = model.provider_id().to_string(); + + let (phase, event_type, anthropic_event_type) = if undo { + ( + "rejected", + "Assistant Response Rejected", + language_model::AnthropicEventType::Reject, + ) + } else { + ( + "accepted", + "Assistant Response Accepted", + language_model::AnthropicEventType::Accept, + ) + }; + + // Fire Zed telemetry + telemetry::event!( + event_type, + kind = "inline_terminal", + phase = phase, + model = model_telemetry_id, + model_provider = model_provider_id, + message_id = message_id, + session_id = session_id, + ); + + report_anthropic_event( + &model, + language_model::AnthropicEventData { + completion_type: language_model::AnthropicCompletionType::Terminal, + event: anthropic_event_type, language_name: None, + message_id, }, - codegen.telemetry.clone(), - cx.http_client(), - model.api_key(cx), - &executor, + cx, ); } @@ -360,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 fb9ee5e49e22fe7b70c02537a3e9a60394ddcc6f..16d12cf261d3bbb8eb0b879394fedc1cc96e046c 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}; @@ -71,7 +71,9 @@ use workspace::{ pane, searchable::{SearchEvent, SearchableItem}, }; -use zed_actions::agent::{AddSelectionToThread, ToggleModelSelector}; +use zed_actions::agent::{AddSelectionToThread, PasteRaw, ToggleModelSelector}; + +use crate::CycleFavoriteModels; use crate::{slash_command::SlashCommandCompletionProvider, slash_command_picker}; use assistant_text_thread::{ @@ -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); }) } } @@ -1682,6 +1698,9 @@ impl TextThreadEditor { window: &mut Window, cx: &mut Context, ) { + let Some(workspace) = self.workspace.upgrade() else { + return; + }; let editor_clipboard_selections = cx .read_from_clipboard() .and_then(|item| item.entries().first().cloned()) @@ -1692,84 +1711,101 @@ impl TextThreadEditor { _ => None, }); - let has_file_context = editor_clipboard_selections - .as_ref() - .is_some_and(|selections| { - selections - .iter() - .any(|sel| sel.file_path.is_some() && sel.line_range.is_some()) - }); - - if has_file_context { - if let Some(clipboard_item) = cx.read_from_clipboard() { - if let Some(ClipboardEntry::String(clipboard_text)) = - clipboard_item.entries().first() - { - if let Some(selections) = editor_clipboard_selections { - cx.stop_propagation(); - - let text = clipboard_text.text(); - self.editor.update(cx, |editor, cx| { - let mut current_offset = 0; - let weak_editor = cx.entity().downgrade(); - - for selection in selections { - if let (Some(file_path), Some(line_range)) = - (selection.file_path, selection.line_range) - { - let selected_text = - &text[current_offset..current_offset + selection.len]; - let fence = assistant_slash_commands::codeblock_fence_for_path( - file_path.to_str(), - Some(line_range.clone()), - ); - let formatted_text = format!("{fence}{selected_text}\n```"); - - let insert_point = editor - .selections - .newest::(&editor.display_snapshot(cx)) - .head(); - let start_row = MultiBufferRow(insert_point.row); - - editor.insert(&formatted_text, window, cx); + // Insert creases for pasted clipboard selections that: + // 1. Contain exactly one selection + // 2. Have an associated file path + // 3. Span multiple lines (not single-line selections) + // 4. Belong to a file that exists in the current project + let should_insert_creases = util::maybe!({ + let selections = editor_clipboard_selections.as_ref()?; + if selections.len() > 1 { + return Some(false); + } + let selection = selections.first()?; + let file_path = selection.file_path.as_ref()?; + let line_range = selection.line_range.as_ref()?; - let snapshot = editor.buffer().read(cx).snapshot(cx); - let anchor_before = snapshot.anchor_after(insert_point); - let anchor_after = editor - .selections - .newest_anchor() - .head() - .bias_left(&snapshot); + if line_range.start() == line_range.end() { + return Some(false); + } - editor.insert("\n", window, cx); + Some( + workspace + .read(cx) + .project() + .read(cx) + .project_path_for_absolute_path(file_path, cx) + .is_some(), + ) + }) + .unwrap_or(false); - let crease_text = acp_thread::selection_name( - Some(file_path.as_ref()), - &line_range, - ); + if should_insert_creases && let Some(clipboard_item) = cx.read_from_clipboard() { + if let Some(ClipboardEntry::String(clipboard_text)) = clipboard_item.entries().first() { + if let Some(selections) = editor_clipboard_selections { + cx.stop_propagation(); - let fold_placeholder = quote_selection_fold_placeholder( - crease_text, - weak_editor.clone(), - ); - let crease = Crease::inline( - anchor_before..anchor_after, - fold_placeholder, - render_quote_selection_output_toggle, - |_, _, _, _| Empty.into_any(), - ); - editor.insert_creases(vec![crease], cx); - editor.fold_at(start_row, window, cx); + let text = clipboard_text.text(); + self.editor.update(cx, |editor, cx| { + let mut current_offset = 0; + let weak_editor = cx.entity().downgrade(); - current_offset += selection.len; - if !selection.is_entire_line && current_offset < text.len() { - current_offset += 1; - } + for selection in selections { + if let (Some(file_path), Some(line_range)) = + (selection.file_path, selection.line_range) + { + let selected_text = + &text[current_offset..current_offset + selection.len]; + let fence = assistant_slash_commands::codeblock_fence_for_path( + file_path.to_str(), + Some(line_range.clone()), + ); + let formatted_text = format!("{fence}{selected_text}\n```"); + + let insert_point = editor + .selections + .newest::(&editor.display_snapshot(cx)) + .head(); + let start_row = MultiBufferRow(insert_point.row); + + editor.insert(&formatted_text, window, cx); + + let snapshot = editor.buffer().read(cx).snapshot(cx); + let anchor_before = snapshot.anchor_after(insert_point); + let anchor_after = editor + .selections + .newest_anchor() + .head() + .bias_left(&snapshot); + + editor.insert("\n", window, cx); + + let crease_text = acp_thread::selection_name( + Some(file_path.as_ref()), + &line_range, + ); + + let fold_placeholder = quote_selection_fold_placeholder( + crease_text, + weak_editor.clone(), + ); + let crease = Crease::inline( + anchor_before..anchor_after, + fold_placeholder, + render_quote_selection_output_toggle, + |_, _, _, _| Empty.into_any(), + ); + editor.insert_creases(vec![crease], cx); + editor.fold_at(start_row, window, cx); + + current_offset += selection.len; + if !selection.is_entire_line && current_offset < text.len() { + current_offset += 1; } } - }); - return; - } + } + }); + return; } } } @@ -1928,6 +1964,12 @@ impl TextThreadEditor { } } + fn paste_raw(&mut self, _: &PasteRaw, window: &mut Window, cx: &mut Context) { + self.editor.update(cx, |editor, cx| { + editor.paste(&editor::actions::Paste, window, cx); + }); + } + fn update_image_blocks(&mut self, cx: &mut Context) { self.editor.update(cx, |editor, cx| { let buffer = editor.buffer().read(cx).snapshot(cx); @@ -2195,12 +2237,53 @@ impl TextThreadEditor { }; let focus_handle = self.editor().focus_handle(cx); + let (color, icon) = if self.language_model_selector_menu_handle.is_deployed() { (Color::Accent, IconName::ChevronUp) } else { (Color::Muted, IconName::ChevronDown) }; + let tooltip = Tooltip::element({ + move |_, cx| { + let focus_handle = focus_handle.clone(); + let should_show_cycle_row = !AgentSettings::get_global(cx) + .favorite_model_ids() + .is_empty(); + + v_flex() + .gap_1() + .child( + h_flex() + .gap_2() + .justify_between() + .child(Label::new("Change Model")) + .child(KeyBinding::for_action_in( + &ToggleModelSelector, + &focus_handle, + cx, + )), + ) + .when(should_show_cycle_row, |this| { + this.child( + h_flex() + .pt_1() + .gap_2() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .justify_between() + .child(Label::new("Cycle Favorited Models")) + .child(KeyBinding::for_action_in( + &CycleFavoriteModels, + &focus_handle, + cx, + )), + ) + }) + .into_any() + } + }); + PickerPopoverMenu::new( self.language_model_selector.clone(), ButtonLike::new("active-model") @@ -2217,9 +2300,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, ) @@ -2572,6 +2653,7 @@ impl Render for TextThreadEditor { .capture_action(cx.listener(TextThreadEditor::copy)) .capture_action(cx.listener(TextThreadEditor::cut)) .capture_action(cx.listener(TextThreadEditor::paste)) + .on_action(cx.listener(TextThreadEditor::paste_raw)) .capture_action(cx.listener(TextThreadEditor::cycle_message_role)) .capture_action(cx.listener(TextThreadEditor::confirm_command)) .on_action(cx.listener(TextThreadEditor::assist)) @@ -2579,6 +2661,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() @@ -3324,7 +3411,6 @@ mod tests { let mut text_thread = TextThread::local( registry, None, - None, prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), cx, diff --git a/crates/agent_ui/src/ui.rs b/crates/agent_ui/src/ui.rs index e604df416e2725a6f1b7bff8eed883a8cc36e184..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 unavailable_editing_tooltip; 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 unavailable_editing_tooltip::*; 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..061b4f58288798696b068a091fb392c033906627 --- /dev/null +++ b/crates/agent_ui/src/ui/model_selector_components.rs @@ -0,0 +1,176 @@ +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), + ) + } +} + +#[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(icon); + 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( + Icon::new(icon) + .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/agent_ui/src/ui/unavailable_editing_tooltip.rs b/crates/agent_ui/src/ui/unavailable_editing_tooltip.rs deleted file mode 100644 index 2993fb89a989619ecfe3d79b06d82a2a6f71fc31..0000000000000000000000000000000000000000 --- a/crates/agent_ui/src/ui/unavailable_editing_tooltip.rs +++ /dev/null @@ -1,29 +0,0 @@ -use gpui::{Context, IntoElement, Render, Window}; -use ui::{prelude::*, tooltip_container}; - -pub struct UnavailableEditingTooltip { - agent_name: SharedString, -} - -impl UnavailableEditingTooltip { - pub fn new(agent_name: SharedString) -> Self { - Self { agent_name } - } -} - -impl Render for UnavailableEditingTooltip { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - tooltip_container(cx, |this, _| { - this.child(Label::new("Unavailable Editing")).child( - div().max_w_64().child( - Label::new(format!( - "Editing previous messages is not available for {} yet.", - self.agent_name - )) - .size(LabelSize::Small) - .color(Color::Muted), - ), - ) - }) - } -} diff --git a/crates/agent_ui_v2/Cargo.toml b/crates/agent_ui_v2/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..2b2cf337adf578432d594ce14f2f58e5911c45fb --- /dev/null +++ b/crates/agent_ui_v2/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "agent_ui_v2" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/agent_ui_v2.rs" +doctest = false + +[features] +test-support = ["agent/test-support"] + + +[dependencies] +agent.workspace = true +agent_servers.workspace = true +agent_settings.workspace = true +agent_ui.workspace = true +anyhow.workspace = true +assistant_text_thread.workspace = true +chrono.workspace = true +db.workspace = true +editor.workspace = true +feature_flags.workspace = true +fs.workspace = true +fuzzy.workspace = true +gpui.workspace = true +menu.workspace = true +project.workspace = true +prompt_store.workspace = true +serde.workspace = true +serde_json.workspace = true +settings.workspace = true +text.workspace = true +time.workspace = true +time_format.workspace = true +ui.workspace = true +util.workspace = true +workspace.workspace = true + +[dev-dependencies] +agent = { workspace = true, features = ["test-support"] } diff --git a/crates/cloud_zeta2_prompt/LICENSE-GPL b/crates/agent_ui_v2/LICENSE-GPL similarity index 100% rename from crates/cloud_zeta2_prompt/LICENSE-GPL rename to crates/agent_ui_v2/LICENSE-GPL diff --git a/crates/agent_ui_v2/src/agent_thread_pane.rs b/crates/agent_ui_v2/src/agent_thread_pane.rs new file mode 100644 index 0000000000000000000000000000000000000000..72886f87eca38c630ec29b9b410930f1d3936b50 --- /dev/null +++ b/crates/agent_ui_v2/src/agent_thread_pane.rs @@ -0,0 +1,287 @@ +use agent::{HistoryEntry, HistoryEntryId, HistoryStore, NativeAgentServer}; +use agent_servers::AgentServer; +use agent_settings::AgentSettings; +use agent_ui::acp::AcpThreadView; +use fs::Fs; +use gpui::{ + Entity, EventEmitter, Focusable, Pixels, SharedString, Subscription, WeakEntity, prelude::*, +}; +use project::Project; +use prompt_store::PromptStore; +use serde::{Deserialize, Serialize}; +use settings::DockSide; +use settings::Settings as _; +use std::rc::Rc; +use std::sync::Arc; +use ui::{Tab, Tooltip, prelude::*}; +use workspace::{ + Workspace, + dock::{ClosePane, MinimizePane, UtilityPane, UtilityPanePosition}, + utility_pane::UtilityPaneSlot, +}; + +pub const DEFAULT_UTILITY_PANE_WIDTH: Pixels = gpui::px(400.0); + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub enum SerializedHistoryEntryId { + AcpThread(String), + TextThread(String), +} + +impl From for SerializedHistoryEntryId { + fn from(id: HistoryEntryId) -> Self { + match id { + HistoryEntryId::AcpThread(session_id) => { + SerializedHistoryEntryId::AcpThread(session_id.0.to_string()) + } + HistoryEntryId::TextThread(path) => { + SerializedHistoryEntryId::TextThread(path.to_string_lossy().to_string()) + } + } + } +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct SerializedAgentThreadPane { + pub expanded: bool, + pub width: Option, + pub thread_id: Option, +} + +pub enum AgentsUtilityPaneEvent { + StateChanged, +} + +impl EventEmitter for AgentThreadPane {} +impl EventEmitter for AgentThreadPane {} +impl EventEmitter for AgentThreadPane {} + +struct ActiveThreadView { + view: Entity, + thread_id: HistoryEntryId, + _notify: Subscription, +} + +pub struct AgentThreadPane { + focus_handle: gpui::FocusHandle, + expanded: bool, + width: Option, + thread_view: Option, + workspace: WeakEntity, +} + +impl AgentThreadPane { + pub fn new(workspace: WeakEntity, cx: &mut ui::Context) -> Self { + let focus_handle = cx.focus_handle(); + Self { + focus_handle, + expanded: false, + width: None, + thread_view: None, + workspace, + } + } + + pub fn thread_id(&self) -> Option { + self.thread_view.as_ref().map(|tv| tv.thread_id.clone()) + } + + pub fn serialize(&self) -> SerializedAgentThreadPane { + SerializedAgentThreadPane { + expanded: self.expanded, + width: self.width, + thread_id: self.thread_id().map(SerializedHistoryEntryId::from), + } + } + + pub fn open_thread( + &mut self, + entry: HistoryEntry, + fs: Arc, + workspace: WeakEntity, + project: Entity, + history_store: Entity, + prompt_store: Option>, + window: &mut Window, + cx: &mut Context, + ) { + let thread_id = entry.id(); + + let resume_thread = match &entry { + HistoryEntry::AcpThread(thread) => Some(thread.clone()), + HistoryEntry::TextThread(_) => None, + }; + + let agent: Rc = Rc::new(NativeAgentServer::new(fs, history_store.clone())); + + let thread_view = cx.new(|cx| { + AcpThreadView::new( + agent, + resume_thread, + None, + workspace, + project, + history_store, + prompt_store, + true, + window, + cx, + ) + }); + + let notify = cx.observe(&thread_view, |_, _, cx| { + cx.notify(); + }); + + self.thread_view = Some(ActiveThreadView { + view: thread_view, + thread_id, + _notify: notify, + }); + + cx.notify(); + } + + fn title(&self, cx: &App) -> SharedString { + if let Some(active_thread_view) = &self.thread_view { + let thread_view = active_thread_view.view.read(cx); + if let Some(thread) = thread_view.thread() { + let title = thread.read(cx).title(); + if !title.is_empty() { + return title; + } + } + thread_view.title(cx) + } else { + "Thread".into() + } + } + + fn render_header(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let position = self.position(window, cx); + let slot = match position { + UtilityPanePosition::Left => UtilityPaneSlot::Left, + UtilityPanePosition::Right => UtilityPaneSlot::Right, + }; + + let workspace = self.workspace.clone(); + let toggle_icon = self.toggle_icon(cx); + let title = self.title(cx); + + let pane_toggle_button = |workspace: WeakEntity| { + IconButton::new("toggle_utility_pane", toggle_icon) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Toggle Agent Pane")) + .on_click(move |_, window, cx| { + workspace + .update(cx, |workspace, cx| { + workspace.toggle_utility_pane(slot, window, cx) + }) + .ok(); + }) + }; + + h_flex() + .id("utility-pane-header") + .w_full() + .h(Tab::container_height(cx)) + .px_1p5() + .gap(DynamicSpacing::Base06.rems(cx)) + .when(slot == UtilityPaneSlot::Right, |this| { + this.flex_row_reverse() + }) + .flex_none() + .border_b_1() + .border_color(cx.theme().colors().border) + .child(pane_toggle_button(workspace)) + .child( + h_flex() + .size_full() + .min_w_0() + .gap_1() + .map(|this| { + if slot == UtilityPaneSlot::Right { + this.flex_row_reverse().justify_start() + } else { + this.justify_between() + } + }) + .child(Label::new(title).truncate()) + .child( + IconButton::new("close_btn", IconName::Close) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Close Agent Pane")) + .on_click(cx.listener(|this, _: &gpui::ClickEvent, _window, cx| { + cx.emit(ClosePane); + this.thread_view = None; + cx.notify() + })), + ), + ) + } +} + +impl Focusable for AgentThreadPane { + fn focus_handle(&self, cx: &ui::App) -> gpui::FocusHandle { + if let Some(thread_view) = &self.thread_view { + thread_view.view.focus_handle(cx) + } else { + self.focus_handle.clone() + } + } +} + +impl UtilityPane for AgentThreadPane { + fn position(&self, _window: &Window, cx: &App) -> UtilityPanePosition { + match AgentSettings::get_global(cx).agents_panel_dock { + DockSide::Left => UtilityPanePosition::Left, + DockSide::Right => UtilityPanePosition::Right, + } + } + + fn toggle_icon(&self, _cx: &App) -> IconName { + IconName::Thread + } + + fn expanded(&self, _cx: &App) -> bool { + self.expanded + } + + fn set_expanded(&mut self, expanded: bool, cx: &mut Context) { + self.expanded = expanded; + cx.emit(AgentsUtilityPaneEvent::StateChanged); + cx.notify(); + } + + fn width(&self, _cx: &App) -> Pixels { + self.width.unwrap_or(DEFAULT_UTILITY_PANE_WIDTH) + } + + fn set_width(&mut self, width: Option, cx: &mut Context) { + self.width = width; + cx.emit(AgentsUtilityPaneEvent::StateChanged); + cx.notify(); + } +} + +impl Render for AgentThreadPane { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let content = if let Some(thread_view) = &self.thread_view { + div().size_full().child(thread_view.view.clone()) + } else { + div() + .size_full() + .flex() + .items_center() + .justify_center() + .child(Label::new("Select a thread to view details").size(LabelSize::Default)) + }; + + div() + .size_full() + .flex() + .flex_col() + .child(self.render_header(window, cx)) + .child(content) + } +} diff --git a/crates/agent_ui_v2/src/agent_ui_v2.rs b/crates/agent_ui_v2/src/agent_ui_v2.rs new file mode 100644 index 0000000000000000000000000000000000000000..92a4144e304e9afbdcdde54623a3bbf3c65b8746 --- /dev/null +++ b/crates/agent_ui_v2/src/agent_ui_v2.rs @@ -0,0 +1,4 @@ +mod agent_thread_pane; +mod thread_history; + +pub mod agents_panel; diff --git a/crates/agent_ui_v2/src/agents_panel.rs b/crates/agent_ui_v2/src/agents_panel.rs new file mode 100644 index 0000000000000000000000000000000000000000..254b8d2999dd3f9ce99c07a20273cbb1ca9cb929 --- /dev/null +++ b/crates/agent_ui_v2/src/agents_panel.rs @@ -0,0 +1,437 @@ +use agent::{HistoryEntry, HistoryEntryId, HistoryStore}; +use agent_settings::AgentSettings; +use anyhow::Result; +use assistant_text_thread::TextThreadStore; +use db::kvp::KEY_VALUE_STORE; +use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt}; +use fs::Fs; +use gpui::{ + Action, AsyncWindowContext, Entity, EventEmitter, Focusable, Pixels, Subscription, Task, + WeakEntity, actions, prelude::*, +}; +use project::Project; +use prompt_store::{PromptBuilder, PromptStore}; +use serde::{Deserialize, Serialize}; +use settings::{Settings as _, update_settings_file}; +use std::sync::Arc; +use ui::{App, Context, IconName, IntoElement, ParentElement, Render, Styled, Window}; +use util::ResultExt; +use workspace::{ + Panel, Workspace, + dock::{ClosePane, DockPosition, PanelEvent, UtilityPane}, + utility_pane::{UtilityPaneSlot, utility_slot_for_dock_position}, +}; + +use crate::agent_thread_pane::{ + AgentThreadPane, AgentsUtilityPaneEvent, SerializedAgentThreadPane, SerializedHistoryEntryId, +}; +use crate::thread_history::{AcpThreadHistory, ThreadHistoryEvent}; + +const AGENTS_PANEL_KEY: &str = "agents_panel"; + +#[derive(Serialize, Deserialize, Debug)] +struct SerializedAgentsPanel { + width: Option, + pane: Option, +} + +actions!( + agents, + [ + /// Toggle the visibility of the agents panel. + ToggleAgentsPanel + ] +); + +pub fn init(cx: &mut App) { + cx.observe_new(|workspace: &mut Workspace, _, _| { + workspace.register_action(|workspace, _: &ToggleAgentsPanel, window, cx| { + workspace.toggle_panel_focus::(window, cx); + }); + }) + .detach(); +} + +pub struct AgentsPanel { + focus_handle: gpui::FocusHandle, + workspace: WeakEntity, + project: Entity, + agent_thread_pane: Option>, + history: Entity, + history_store: Entity, + prompt_store: Option>, + fs: Arc, + width: Option, + pending_serialization: Task>, + _subscriptions: Vec, +} + +impl AgentsPanel { + pub fn load( + workspace: WeakEntity, + cx: AsyncWindowContext, + ) -> Task, anyhow::Error>> { + cx.spawn(async move |cx| { + let serialized_panel = cx + .background_spawn(async move { + KEY_VALUE_STORE + .read_kvp(AGENTS_PANEL_KEY) + .ok() + .flatten() + .and_then(|panel| { + serde_json::from_str::(&panel).ok() + }) + }) + .await; + + let (fs, project, prompt_builder) = workspace.update(cx, |workspace, cx| { + let fs = workspace.app_state().fs.clone(); + let project = workspace.project().clone(); + let prompt_builder = PromptBuilder::load(fs.clone(), false, cx); + (fs, project, prompt_builder) + })?; + + let text_thread_store = workspace + .update(cx, |_, cx| { + TextThreadStore::new( + project.clone(), + prompt_builder.clone(), + Default::default(), + cx, + ) + })? + .await?; + + let prompt_store = workspace + .update(cx, |_, cx| PromptStore::global(cx))? + .await + .log_err(); + + workspace.update_in(cx, |_, window, cx| { + cx.new(|cx| { + let mut panel = Self::new( + workspace.clone(), + fs, + project, + prompt_store, + text_thread_store, + window, + cx, + ); + if let Some(serialized_panel) = serialized_panel { + panel.width = serialized_panel.width; + if let Some(serialized_pane) = serialized_panel.pane { + panel.restore_utility_pane(serialized_pane, window, cx); + } + } + panel + }) + }) + }) + } + + fn new( + workspace: WeakEntity, + fs: Arc, + project: Entity, + prompt_store: Option>, + text_thread_store: Entity, + window: &mut Window, + cx: &mut ui::Context, + ) -> Self { + let focus_handle = cx.focus_handle(); + + let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); + let history = cx.new(|cx| AcpThreadHistory::new(history_store.clone(), window, cx)); + + let this = cx.weak_entity(); + let subscriptions = vec![ + cx.subscribe_in(&history, window, Self::handle_history_event), + cx.on_flags_ready(move |_, cx| { + this.update(cx, |_, cx| { + cx.notify(); + }) + .ok(); + }), + ]; + + Self { + focus_handle, + workspace, + project, + agent_thread_pane: None, + history, + history_store, + prompt_store, + fs, + width: None, + pending_serialization: Task::ready(None), + _subscriptions: subscriptions, + } + } + + fn restore_utility_pane( + &mut self, + serialized_pane: SerializedAgentThreadPane, + window: &mut Window, + cx: &mut Context, + ) { + let Some(thread_id) = &serialized_pane.thread_id else { + return; + }; + + let entry = self + .history_store + .read(cx) + .entries() + .find(|e| match (&e.id(), thread_id) { + ( + HistoryEntryId::AcpThread(session_id), + SerializedHistoryEntryId::AcpThread(id), + ) => session_id.to_string() == *id, + (HistoryEntryId::TextThread(path), SerializedHistoryEntryId::TextThread(id)) => { + path.to_string_lossy() == *id + } + _ => false, + }); + + if let Some(entry) = entry { + self.open_thread( + entry, + serialized_pane.expanded, + serialized_pane.width, + window, + cx, + ); + } + } + + fn handle_utility_pane_event( + &mut self, + _utility_pane: Entity, + event: &AgentsUtilityPaneEvent, + cx: &mut Context, + ) { + match event { + AgentsUtilityPaneEvent::StateChanged => { + self.serialize(cx); + cx.notify(); + } + } + } + + fn handle_close_pane_event( + &mut self, + _utility_pane: Entity, + _event: &ClosePane, + cx: &mut Context, + ) { + self.agent_thread_pane = None; + self.serialize(cx); + cx.notify(); + } + + fn handle_history_event( + &mut self, + _history: &Entity, + event: &ThreadHistoryEvent, + window: &mut Window, + cx: &mut Context, + ) { + match event { + ThreadHistoryEvent::Open(entry) => { + self.open_thread(entry.clone(), true, None, window, cx); + } + } + } + + fn open_thread( + &mut self, + entry: HistoryEntry, + expanded: bool, + width: Option, + window: &mut Window, + cx: &mut Context, + ) { + let entry_id = entry.id(); + + if let Some(existing_pane) = &self.agent_thread_pane { + if existing_pane.read(cx).thread_id() == Some(entry_id) { + existing_pane.update(cx, |pane, cx| { + pane.set_expanded(true, cx); + }); + return; + } + } + + let fs = self.fs.clone(); + let workspace = self.workspace.clone(); + let project = self.project.clone(); + let history_store = self.history_store.clone(); + let prompt_store = self.prompt_store.clone(); + + let agent_thread_pane = cx.new(|cx| { + let mut pane = AgentThreadPane::new(workspace.clone(), cx); + pane.open_thread( + entry, + fs, + workspace.clone(), + project, + history_store, + prompt_store, + window, + cx, + ); + if let Some(width) = width { + pane.set_width(Some(width), cx); + } + pane.set_expanded(expanded, cx); + pane + }); + + let state_subscription = cx.subscribe(&agent_thread_pane, Self::handle_utility_pane_event); + let close_subscription = cx.subscribe(&agent_thread_pane, Self::handle_close_pane_event); + + self._subscriptions.push(state_subscription); + self._subscriptions.push(close_subscription); + + let slot = self.utility_slot(window, cx); + let panel_id = cx.entity_id(); + + if let Some(workspace) = self.workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + workspace.register_utility_pane(slot, panel_id, agent_thread_pane.clone(), cx); + }); + } + + self.agent_thread_pane = Some(agent_thread_pane); + self.serialize(cx); + cx.notify(); + } + + fn utility_slot(&self, window: &Window, cx: &App) -> UtilityPaneSlot { + let position = self.position(window, cx); + utility_slot_for_dock_position(position) + } + + fn re_register_utility_pane(&mut self, window: &mut Window, cx: &mut Context) { + if let Some(pane) = &self.agent_thread_pane { + let slot = self.utility_slot(window, cx); + let panel_id = cx.entity_id(); + let pane = pane.clone(); + + if let Some(workspace) = self.workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + workspace.register_utility_pane(slot, panel_id, pane, cx); + }); + } + } + } + + fn serialize(&mut self, cx: &mut Context) { + let width = self.width; + let pane = self + .agent_thread_pane + .as_ref() + .map(|pane| pane.read(cx).serialize()); + + self.pending_serialization = cx.background_spawn(async move { + KEY_VALUE_STORE + .write_kvp( + AGENTS_PANEL_KEY.into(), + serde_json::to_string(&SerializedAgentsPanel { width, pane }).unwrap(), + ) + .await + .log_err() + }); + } +} + +impl EventEmitter for AgentsPanel {} + +impl Focusable for AgentsPanel { + fn focus_handle(&self, _cx: &ui::App) -> gpui::FocusHandle { + self.focus_handle.clone() + } +} + +impl Panel for AgentsPanel { + fn persistent_name() -> &'static str { + "AgentsPanel" + } + + fn panel_key() -> &'static str { + AGENTS_PANEL_KEY + } + + fn position(&self, _window: &Window, cx: &App) -> DockPosition { + match AgentSettings::get_global(cx).agents_panel_dock { + settings::DockSide::Left => DockPosition::Left, + settings::DockSide::Right => DockPosition::Right, + } + } + + fn position_is_valid(&self, position: DockPosition) -> bool { + position != DockPosition::Bottom + } + + fn set_position( + &mut self, + position: DockPosition, + window: &mut Window, + cx: &mut Context, + ) { + update_settings_file(self.fs.clone(), cx, move |settings, _| { + settings.agent.get_or_insert_default().agents_panel_dock = Some(match position { + DockPosition::Left => settings::DockSide::Left, + DockPosition::Right | DockPosition::Bottom => settings::DockSide::Right, + }); + }); + self.re_register_utility_pane(window, cx); + } + + fn size(&self, window: &Window, cx: &App) -> Pixels { + let settings = AgentSettings::get_global(cx); + match self.position(window, cx) { + DockPosition::Left | DockPosition::Right => { + self.width.unwrap_or(settings.default_width) + } + DockPosition::Bottom => self.width.unwrap_or(settings.default_height), + } + } + + fn set_size(&mut self, size: Option, window: &mut Window, cx: &mut Context) { + match self.position(window, cx) { + DockPosition::Left | DockPosition::Right => self.width = size, + DockPosition::Bottom => {} + } + self.serialize(cx); + cx.notify(); + } + + fn icon(&self, _window: &Window, cx: &App) -> Option { + (self.enabled(cx) && AgentSettings::get_global(cx).button).then_some(IconName::ZedAgentTwo) + } + + fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> { + Some("Agents Panel") + } + + fn toggle_action(&self) -> Box { + Box::new(ToggleAgentsPanel) + } + + fn activation_priority(&self) -> u32 { + 4 + } + + fn enabled(&self, cx: &App) -> bool { + AgentSettings::get_global(cx).enabled(cx) && cx.has_flag::() + } +} + +impl Render for AgentsPanel { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + gpui::div().size_full().child(self.history.clone()) + } +} diff --git a/crates/agent_ui_v2/src/thread_history.rs b/crates/agent_ui_v2/src/thread_history.rs new file mode 100644 index 0000000000000000000000000000000000000000..8f6626814902a9489536439e90041437a527e151 --- /dev/null +++ b/crates/agent_ui_v2/src/thread_history.rs @@ -0,0 +1,735 @@ +use agent::{HistoryEntry, HistoryStore}; +use chrono::{Datelike as _, Local, NaiveDate, TimeDelta}; +use editor::{Editor, EditorEvent}; +use fuzzy::StringMatchCandidate; +use gpui::{ + App, Entity, EventEmitter, FocusHandle, Focusable, ScrollStrategy, Task, + UniformListScrollHandle, Window, actions, uniform_list, +}; +use std::{fmt::Display, ops::Range}; +use text::Bias; +use time::{OffsetDateTime, UtcOffset}; +use ui::{ + HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Tab, Tooltip, WithScrollbar, + prelude::*, +}; + +actions!( + agents, + [ + /// Removes all thread history. + RemoveHistory, + /// Removes the currently selected thread. + RemoveSelectedThread, + ] +); + +pub struct AcpThreadHistory { + pub(crate) history_store: Entity, + scroll_handle: UniformListScrollHandle, + selected_index: usize, + hovered_index: Option, + search_editor: Entity, + search_query: SharedString, + visible_items: Vec, + local_timezone: UtcOffset, + confirming_delete_history: bool, + _update_task: Task<()>, + _subscriptions: Vec, +} + +enum ListItemType { + BucketSeparator(TimeBucket), + Entry { + entry: HistoryEntry, + format: EntryTimeFormat, + }, + SearchResult { + entry: HistoryEntry, + positions: Vec, + }, +} + +impl ListItemType { + fn history_entry(&self) -> Option<&HistoryEntry> { + match self { + ListItemType::Entry { entry, .. } => Some(entry), + ListItemType::SearchResult { entry, .. } => Some(entry), + _ => None, + } + } +} + +#[allow(dead_code)] +pub enum ThreadHistoryEvent { + Open(HistoryEntry), +} + +impl EventEmitter for AcpThreadHistory {} + +impl AcpThreadHistory { + pub fn new( + history_store: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let search_editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_placeholder_text("Search threads...", window, cx); + editor + }); + + let search_editor_subscription = + cx.subscribe(&search_editor, |this, search_editor, event, cx| { + if let EditorEvent::BufferEdited = event { + let query = search_editor.read(cx).text(cx); + if this.search_query != query { + this.search_query = query.into(); + this.update_visible_items(false, cx); + } + } + }); + + let history_store_subscription = cx.observe(&history_store, |this, _, cx| { + this.update_visible_items(true, cx); + }); + + let scroll_handle = UniformListScrollHandle::default(); + + let mut this = Self { + history_store, + scroll_handle, + selected_index: 0, + hovered_index: None, + visible_items: Default::default(), + search_editor, + local_timezone: UtcOffset::from_whole_seconds( + chrono::Local::now().offset().local_minus_utc(), + ) + .unwrap(), + search_query: SharedString::default(), + confirming_delete_history: false, + _subscriptions: vec![search_editor_subscription, history_store_subscription], + _update_task: Task::ready(()), + }; + this.update_visible_items(false, cx); + this + } + + fn update_visible_items(&mut self, preserve_selected_item: bool, cx: &mut Context) { + let entries = self + .history_store + .update(cx, |store, _| store.entries().collect()); + let new_list_items = if self.search_query.is_empty() { + self.add_list_separators(entries, cx) + } else { + self.filter_search_results(entries, cx) + }; + let selected_history_entry = if preserve_selected_item { + self.selected_history_entry().cloned() + } else { + None + }; + + self._update_task = cx.spawn(async move |this, cx| { + let new_visible_items = new_list_items.await; + this.update(cx, |this, cx| { + let new_selected_index = if let Some(history_entry) = selected_history_entry { + let history_entry_id = history_entry.id(); + new_visible_items + .iter() + .position(|visible_entry| { + visible_entry + .history_entry() + .is_some_and(|entry| entry.id() == history_entry_id) + }) + .unwrap_or(0) + } else { + 0 + }; + + this.visible_items = new_visible_items; + this.set_selected_index(new_selected_index, Bias::Right, cx); + cx.notify(); + }) + .ok(); + }); + } + + fn add_list_separators(&self, entries: Vec, cx: &App) -> Task> { + cx.background_spawn(async move { + let mut items = Vec::with_capacity(entries.len() + 1); + let mut bucket = None; + let today = Local::now().naive_local().date(); + + for entry in entries.into_iter() { + let entry_date = entry + .updated_at() + .with_timezone(&Local) + .naive_local() + .date(); + let entry_bucket = TimeBucket::from_dates(today, entry_date); + + if Some(entry_bucket) != bucket { + bucket = Some(entry_bucket); + items.push(ListItemType::BucketSeparator(entry_bucket)); + } + + items.push(ListItemType::Entry { + entry, + format: entry_bucket.into(), + }); + } + items + }) + } + + fn filter_search_results( + &self, + entries: Vec, + cx: &App, + ) -> Task> { + let query = self.search_query.clone(); + cx.background_spawn({ + let executor = cx.background_executor().clone(); + async move { + let mut candidates = Vec::with_capacity(entries.len()); + + for (idx, entry) in entries.iter().enumerate() { + candidates.push(StringMatchCandidate::new(idx, entry.title())); + } + + const MAX_MATCHES: usize = 100; + + let matches = fuzzy::match_strings( + &candidates, + &query, + false, + true, + MAX_MATCHES, + &Default::default(), + executor, + ) + .await; + + matches + .into_iter() + .map(|search_match| ListItemType::SearchResult { + entry: entries[search_match.candidate_id].clone(), + positions: search_match.positions, + }) + .collect() + } + }) + } + + fn search_produced_no_matches(&self) -> bool { + self.visible_items.is_empty() && !self.search_query.is_empty() + } + + fn selected_history_entry(&self) -> Option<&HistoryEntry> { + self.get_history_entry(self.selected_index) + } + + fn get_history_entry(&self, visible_items_ix: usize) -> Option<&HistoryEntry> { + self.visible_items.get(visible_items_ix)?.history_entry() + } + + fn set_selected_index(&mut self, mut index: usize, bias: Bias, cx: &mut Context) { + if self.visible_items.is_empty() { + self.selected_index = 0; + return; + } + while matches!( + self.visible_items.get(index), + None | Some(ListItemType::BucketSeparator(..)) + ) { + index = match bias { + Bias::Left => { + if index == 0 { + self.visible_items.len() - 1 + } else { + index - 1 + } + } + Bias::Right => { + if index >= self.visible_items.len() - 1 { + 0 + } else { + index + 1 + } + } + }; + } + self.selected_index = index; + self.scroll_handle + .scroll_to_item(index, ScrollStrategy::Top); + cx.notify() + } + + pub fn select_previous( + &mut self, + _: &menu::SelectPrevious, + _window: &mut Window, + cx: &mut Context, + ) { + if self.selected_index == 0 { + self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx); + } else { + self.set_selected_index(self.selected_index - 1, Bias::Left, cx); + } + } + + pub fn select_next( + &mut self, + _: &menu::SelectNext, + _window: &mut Window, + cx: &mut Context, + ) { + if self.selected_index == self.visible_items.len() - 1 { + self.set_selected_index(0, Bias::Right, cx); + } else { + self.set_selected_index(self.selected_index + 1, Bias::Right, cx); + } + } + + fn select_first( + &mut self, + _: &menu::SelectFirst, + _window: &mut Window, + cx: &mut Context, + ) { + self.set_selected_index(0, Bias::Right, cx); + } + + fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context) { + self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx); + } + + fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context) { + self.confirm_entry(self.selected_index, cx); + } + + fn confirm_entry(&mut self, ix: usize, cx: &mut Context) { + let Some(entry) = self.get_history_entry(ix) else { + return; + }; + cx.emit(ThreadHistoryEvent::Open(entry.clone())); + } + + fn remove_selected_thread( + &mut self, + _: &RemoveSelectedThread, + _window: &mut Window, + cx: &mut Context, + ) { + self.remove_thread(self.selected_index, cx) + } + + fn remove_thread(&mut self, visible_item_ix: usize, cx: &mut Context) { + let Some(entry) = self.get_history_entry(visible_item_ix) else { + return; + }; + + let task = match entry { + HistoryEntry::AcpThread(thread) => self + .history_store + .update(cx, |this, cx| this.delete_thread(thread.id.clone(), cx)), + HistoryEntry::TextThread(text_thread) => self.history_store.update(cx, |this, cx| { + this.delete_text_thread(text_thread.path.clone(), cx) + }), + }; + task.detach_and_log_err(cx); + } + + fn remove_history(&mut self, _window: &mut Window, cx: &mut Context) { + self.history_store.update(cx, |store, cx| { + store.delete_threads(cx).detach_and_log_err(cx) + }); + self.confirming_delete_history = false; + cx.notify(); + } + + fn prompt_delete_history(&mut self, _window: &mut Window, cx: &mut Context) { + self.confirming_delete_history = true; + cx.notify(); + } + + fn cancel_delete_history(&mut self, _window: &mut Window, cx: &mut Context) { + self.confirming_delete_history = false; + cx.notify(); + } + + fn render_list_items( + &mut self, + range: Range, + _window: &mut Window, + cx: &mut Context, + ) -> Vec { + self.visible_items + .get(range.clone()) + .into_iter() + .flatten() + .enumerate() + .map(|(ix, item)| self.render_list_item(item, range.start + ix, cx)) + .collect() + } + + fn render_list_item(&self, item: &ListItemType, ix: usize, cx: &Context) -> AnyElement { + match item { + ListItemType::Entry { entry, format } => self + .render_history_entry(entry, *format, ix, Vec::default(), cx) + .into_any(), + ListItemType::SearchResult { entry, positions } => self.render_history_entry( + entry, + EntryTimeFormat::DateAndTime, + ix, + positions.clone(), + cx, + ), + ListItemType::BucketSeparator(bucket) => div() + .px(DynamicSpacing::Base06.rems(cx)) + .pt_2() + .pb_1() + .child( + Label::new(bucket.to_string()) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + .into_any_element(), + } + } + + fn render_history_entry( + &self, + entry: &HistoryEntry, + format: EntryTimeFormat, + ix: usize, + highlight_positions: Vec, + cx: &Context, + ) -> AnyElement { + let selected = ix == self.selected_index; + let hovered = Some(ix) == self.hovered_index; + let timestamp = entry.updated_at().timestamp(); + let thread_timestamp = format.format_timestamp(timestamp, self.local_timezone); + + h_flex() + .w_full() + .pb_1() + .child( + ListItem::new(ix) + .rounded() + .toggle_state(selected) + .spacing(ListItemSpacing::Sparse) + .start_slot( + h_flex() + .w_full() + .gap_2() + .justify_between() + .child( + HighlightedLabel::new(entry.title(), highlight_positions) + .size(LabelSize::Small) + .truncate(), + ) + .child( + Label::new(thread_timestamp) + .color(Color::Muted) + .size(LabelSize::XSmall), + ), + ) + .on_hover(cx.listener(move |this, is_hovered, _window, cx| { + if *is_hovered { + this.hovered_index = Some(ix); + } else if this.hovered_index == Some(ix) { + this.hovered_index = None; + } + + cx.notify(); + })) + .end_slot::(if hovered { + Some( + IconButton::new("delete", IconName::Trash) + .shape(IconButtonShape::Square) + .icon_size(IconSize::XSmall) + .icon_color(Color::Muted) + .tooltip(move |_window, cx| { + Tooltip::for_action("Delete", &RemoveSelectedThread, cx) + }) + .on_click(cx.listener(move |this, _, _, cx| { + this.remove_thread(ix, cx); + cx.stop_propagation() + })), + ) + } else { + None + }) + .on_click(cx.listener(move |this, _, _, cx| this.confirm_entry(ix, cx))), + ) + .into_any_element() + } +} + +impl Focusable for AcpThreadHistory { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.search_editor.focus_handle(cx) + } +} + +impl Render for AcpThreadHistory { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let has_no_history = self.history_store.read(cx).is_empty(cx); + + v_flex() + .key_context("ThreadHistory") + .size_full() + .bg(cx.theme().colors().panel_background) + .on_action(cx.listener(Self::select_previous)) + .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::select_first)) + .on_action(cx.listener(Self::select_last)) + .on_action(cx.listener(Self::confirm)) + .on_action(cx.listener(Self::remove_selected_thread)) + .on_action(cx.listener(|this, _: &RemoveHistory, window, cx| { + this.remove_history(window, cx); + })) + .child( + h_flex() + .h(Tab::container_height(cx)) + .w_full() + .py_1() + .px_2() + .gap_2() + .justify_between() + .border_b_1() + .border_color(cx.theme().colors().border) + .child( + Icon::new(IconName::MagnifyingGlass) + .color(Color::Muted) + .size(IconSize::Small), + ) + .child(self.search_editor.clone()), + ) + .child({ + let view = v_flex() + .id("list-container") + .relative() + .overflow_hidden() + .flex_grow(); + + if has_no_history { + view.justify_center().items_center().child( + Label::new("You don't have any past threads yet.") + .size(LabelSize::Small) + .color(Color::Muted), + ) + } else if self.search_produced_no_matches() { + view.justify_center() + .items_center() + .child(Label::new("No threads match your search.").size(LabelSize::Small)) + } else { + view.child( + uniform_list( + "thread-history", + self.visible_items.len(), + cx.processor(|this, range: Range, window, cx| { + this.render_list_items(range, window, cx) + }), + ) + .p_1() + .pr_4() + .track_scroll(&self.scroll_handle) + .flex_grow(), + ) + .vertical_scrollbar_for(&self.scroll_handle, window, cx) + } + }) + .when(!has_no_history, |this| { + this.child( + h_flex() + .p_2() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .when(!self.confirming_delete_history, |this| { + this.child( + Button::new("delete_history", "Delete All History") + .full_width() + .style(ButtonStyle::Outlined) + .label_size(LabelSize::Small) + .on_click(cx.listener(|this, _, window, cx| { + this.prompt_delete_history(window, cx); + })), + ) + }) + .when(self.confirming_delete_history, |this| { + this.w_full() + .gap_2() + .flex_wrap() + .justify_between() + .child( + h_flex() + .flex_wrap() + .gap_1() + .child( + Label::new("Delete all threads?") + .size(LabelSize::Small), + ) + .child( + Label::new("You won't be able to recover them later.") + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .child( + h_flex() + .gap_1() + .child( + Button::new("cancel_delete", "Cancel") + .label_size(LabelSize::Small) + .on_click(cx.listener(|this, _, window, cx| { + this.cancel_delete_history(window, cx); + })), + ) + .child( + Button::new("confirm_delete", "Delete") + .style(ButtonStyle::Tinted(ui::TintColor::Error)) + .color(Color::Error) + .label_size(LabelSize::Small) + .on_click(cx.listener(|_, _, window, cx| { + window.dispatch_action( + Box::new(RemoveHistory), + cx, + ); + })), + ), + ) + }), + ) + }) + } +} + +#[derive(Clone, Copy)] +pub enum EntryTimeFormat { + DateAndTime, + TimeOnly, +} + +impl EntryTimeFormat { + fn format_timestamp(&self, timestamp: i64, timezone: UtcOffset) -> String { + let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap(); + + match self { + EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp( + timestamp, + OffsetDateTime::now_utc(), + timezone, + time_format::TimestampFormat::EnhancedAbsolute, + ), + EntryTimeFormat::TimeOnly => time_format::format_time(timestamp.to_offset(timezone)), + } + } +} + +impl From for EntryTimeFormat { + fn from(bucket: TimeBucket) -> Self { + match bucket { + TimeBucket::Today => EntryTimeFormat::TimeOnly, + TimeBucket::Yesterday => EntryTimeFormat::TimeOnly, + TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime, + TimeBucket::PastWeek => EntryTimeFormat::DateAndTime, + TimeBucket::All => EntryTimeFormat::DateAndTime, + } + } +} + +#[derive(PartialEq, Eq, Clone, Copy, Debug)] +enum TimeBucket { + Today, + Yesterday, + ThisWeek, + PastWeek, + All, +} + +impl TimeBucket { + fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self { + if date == reference { + return TimeBucket::Today; + } + + if date == reference - TimeDelta::days(1) { + return TimeBucket::Yesterday; + } + + let week = date.iso_week(); + + if reference.iso_week() == week { + return TimeBucket::ThisWeek; + } + + let last_week = (reference - TimeDelta::days(7)).iso_week(); + + if week == last_week { + return TimeBucket::PastWeek; + } + + TimeBucket::All + } +} + +impl Display for TimeBucket { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TimeBucket::Today => write!(f, "Today"), + TimeBucket::Yesterday => write!(f, "Yesterday"), + TimeBucket::ThisWeek => write!(f, "This Week"), + TimeBucket::PastWeek => write!(f, "Past Week"), + TimeBucket::All => write!(f, "All"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::NaiveDate; + + #[test] + fn test_time_bucket_from_dates() { + let today = NaiveDate::from_ymd_opt(2023, 1, 15).unwrap(); + + let date = today; + assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Today); + + let date = NaiveDate::from_ymd_opt(2023, 1, 14).unwrap(); + assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Yesterday); + + let date = NaiveDate::from_ymd_opt(2023, 1, 13).unwrap(); + assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek); + + let date = NaiveDate::from_ymd_opt(2023, 1, 11).unwrap(); + assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek); + + let date = NaiveDate::from_ymd_opt(2023, 1, 8).unwrap(); + assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek); + + let date = NaiveDate::from_ymd_opt(2023, 1, 5).unwrap(); + assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek); + + // All: not in this week or last week + let date = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(); + assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::All); + + // Test year boundary cases + let new_year = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(); + + let date = NaiveDate::from_ymd_opt(2022, 12, 31).unwrap(); + assert_eq!( + TimeBucket::from_dates(new_year, date), + TimeBucket::Yesterday + ); + + let date = NaiveDate::from_ymd_opt(2022, 12, 28).unwrap(); + assert_eq!(TimeBucket::from_dates(new_year, date), TimeBucket::ThisWeek); + } +} diff --git a/crates/anthropic/src/anthropic.rs b/crates/anthropic/src/anthropic.rs index 09b293b122624274b7484026f35d1bcc8e265ece..f0dde3eedea657ea2d2ebe9ede457e329bd8b9a5 100644 --- a/crates/anthropic/src/anthropic.rs +++ b/crates/anthropic/src/anthropic.rs @@ -429,10 +429,24 @@ impl Model { let mut headers = vec![]; match self { + Self::ClaudeOpus4 + | Self::ClaudeOpus4_1 + | Self::ClaudeOpus4_5 + | Self::ClaudeSonnet4 + | Self::ClaudeSonnet4_5 + | Self::ClaudeOpus4Thinking + | Self::ClaudeOpus4_1Thinking + | Self::ClaudeOpus4_5Thinking + | Self::ClaudeSonnet4Thinking + | Self::ClaudeSonnet4_5Thinking => { + // Fine-grained tool streaming for newer models + headers.push("fine-grained-tool-streaming-2025-05-14".to_string()); + } Self::Claude3_7Sonnet | Self::Claude3_7SonnetThinking => { // Try beta token-efficient tool use (supported in Claude 3.7 Sonnet only) // https://docs.anthropic.com/en/docs/build-with-claude/tool-use/token-efficient-tool-use headers.push("token-efficient-tools-2025-02-19".to_string()); + headers.push("fine-grained-tool-streaming-2025-05-14".to_string()); } Self::Custom { extra_beta_headers, .. @@ -1038,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/assistant_slash_commands/Cargo.toml b/crates/assistant_slash_commands/Cargo.toml index 85dd92501f93fb79ba1d3f70b3a06f1077356cfa..b2a70449f449f73c7d0017c5d2ba3707e271559a 100644 --- a/crates/assistant_slash_commands/Cargo.toml +++ b/crates/assistant_slash_commands/Cargo.toml @@ -22,7 +22,6 @@ feature_flags.workspace = true fs.workspace = true futures.workspace = true fuzzy.workspace = true -globset.workspace = true gpui.workspace = true html_to_markdown.workspace = true http_client.workspace = true diff --git a/crates/assistant_slash_commands/src/file_command.rs b/crates/assistant_slash_commands/src/file_command.rs index a17e198ed300f00f70d35149cbe0286af3a65a57..ae4e8363b40d520b9ea33e5cba5ffa68d783ab04 100644 --- a/crates/assistant_slash_commands/src/file_command.rs +++ b/crates/assistant_slash_commands/src/file_command.rs @@ -226,10 +226,10 @@ fn collect_files( let Ok(matchers) = glob_inputs .iter() .map(|glob_input| { - custom_path_matcher::PathMatcher::new(&[glob_input.to_owned()]) + util::paths::PathMatcher::new(&[glob_input.to_owned()], project.read(cx).path_style(cx)) .with_context(|| format!("invalid path {glob_input}")) }) - .collect::>>() + .collect::>>() else { return futures::stream::once(async { anyhow::bail!("invalid path"); @@ -250,6 +250,7 @@ fn collect_files( let worktree_id = snapshot.id(); let path_style = snapshot.path_style(); let mut directory_stack: Vec> = Vec::new(); + let mut folded_directory_path: Option> = None; let mut folded_directory_names: Arc = RelPath::empty().into(); let mut is_top_level_directory = true; @@ -277,6 +278,16 @@ fn collect_files( )))?; } + if let Some(folded_path) = &folded_directory_path { + if !entry.path.starts_with(folded_path) { + folded_directory_names = RelPath::empty().into(); + folded_directory_path = None; + if directory_stack.is_empty() { + is_top_level_directory = true; + } + } + } + let filename = entry.path.file_name().unwrap_or_default().to_string(); if entry.is_dir() { @@ -292,13 +303,17 @@ fn collect_files( folded_directory_names = folded_directory_names.join(RelPath::unix(&filename).unwrap()); } + folded_directory_path = Some(entry.path.clone()); continue; } } else { // Skip empty directories folded_directory_names = RelPath::empty().into(); + folded_directory_path = None; continue; } + + // Render the directory (either folded or normal) if folded_directory_names.is_empty() { let label = if is_top_level_directory { is_top_level_directory = false; @@ -334,6 +349,8 @@ fn collect_files( }, )))?; directory_stack.push(entry.path.clone()); + folded_directory_names = RelPath::empty().into(); + folded_directory_path = None; } events_tx.unbounded_send(Ok(SlashCommandEvent::Content( SlashCommandContent::Text { @@ -447,87 +464,6 @@ pub fn build_entry_output_section( } } -/// This contains a small fork of the util::paths::PathMatcher, that is stricter about the prefix -/// check. Only subpaths pass the prefix check, rather than any prefix. -mod custom_path_matcher { - use globset::{Glob, GlobSet, GlobSetBuilder}; - use std::fmt::Debug as _; - use util::{paths::SanitizedPath, rel_path::RelPath}; - - #[derive(Clone, Debug, Default)] - pub struct PathMatcher { - sources: Vec, - sources_with_trailing_slash: Vec, - glob: GlobSet, - } - - impl std::fmt::Display for PathMatcher { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.sources.fmt(f) - } - } - - impl PartialEq for PathMatcher { - fn eq(&self, other: &Self) -> bool { - self.sources.eq(&other.sources) - } - } - - impl Eq for PathMatcher {} - - impl PathMatcher { - pub fn new(globs: &[String]) -> Result { - let globs = globs - .iter() - .map(|glob| Glob::new(&SanitizedPath::new(glob).to_string())) - .collect::, _>>()?; - let sources = globs.iter().map(|glob| glob.glob().to_owned()).collect(); - let sources_with_trailing_slash = globs - .iter() - .map(|glob| glob.glob().to_string() + "/") - .collect(); - let mut glob_builder = GlobSetBuilder::new(); - for single_glob in globs { - glob_builder.add(single_glob); - } - let glob = glob_builder.build()?; - Ok(PathMatcher { - glob, - sources, - sources_with_trailing_slash, - }) - } - - pub fn is_match(&self, other: &RelPath) -> bool { - self.sources - .iter() - .zip(self.sources_with_trailing_slash.iter()) - .any(|(source, with_slash)| { - let as_bytes = other.as_unix_str().as_bytes(); - let with_slash = if source.ends_with('/') { - source.as_bytes() - } else { - with_slash.as_bytes() - }; - - as_bytes.starts_with(with_slash) || as_bytes.ends_with(source.as_bytes()) - }) - || self.glob.is_match(other.as_std_path()) - || self.check_with_end_separator(other) - } - - fn check_with_end_separator(&self, path: &RelPath) -> bool { - let path_str = path.as_unix_str(); - let separator = "/"; - if path_str.ends_with(separator) { - false - } else { - self.glob.is_match(path_str.to_string() + separator) - } - } - } -} - pub fn append_buffer_to_output( buffer: &BufferSnapshot, path: Option<&str>, diff --git a/crates/assistant_text_thread/Cargo.toml b/crates/assistant_text_thread/Cargo.toml index 7c8fcca3bfa81f6f2de570fa68ecc795cb81b257..5ad429758ea1785ecb4fcecb2f3ad83a71afda0d 100644 --- a/crates/assistant_text_thread/Cargo.toml +++ b/crates/assistant_text_thread/Cargo.toml @@ -46,7 +46,7 @@ serde_json.workspace = true settings.workspace = true smallvec.workspace = true smol.workspace = true -telemetry_events.workspace = true +telemetry.workspace = true text.workspace = true ui.workspace = true util.workspace = true diff --git a/crates/assistant_text_thread/src/assistant_text_thread_tests.rs b/crates/assistant_text_thread/src/assistant_text_thread_tests.rs index 0743641bf5ce33850f28987d834b2e79771cff6f..7232a03c212a9dfc4bfe9bcce4a78667d9210ad8 100644 --- a/crates/assistant_text_thread/src/assistant_text_thread_tests.rs +++ b/crates/assistant_text_thread/src/assistant_text_thread_tests.rs @@ -50,7 +50,6 @@ fn test_inserting_and_removing_messages(cx: &mut App) { TextThread::local( registry, None, - None, prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), cx, @@ -189,7 +188,6 @@ fn test_message_splitting(cx: &mut App) { TextThread::local( registry.clone(), None, - None, prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), cx, @@ -294,7 +292,6 @@ fn test_messages_for_offsets(cx: &mut App) { TextThread::local( registry, None, - None, prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), cx, @@ -405,7 +402,6 @@ async fn test_slash_commands(cx: &mut TestAppContext) { TextThread::local( registry.clone(), None, - None, prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), cx, @@ -677,7 +673,6 @@ async fn test_serialization(cx: &mut TestAppContext) { TextThread::local( registry.clone(), None, - None, prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), cx, @@ -724,7 +719,6 @@ async fn test_serialization(cx: &mut TestAppContext) { prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), None, - None, cx, ) }); @@ -780,7 +774,6 @@ async fn test_random_context_collaboration(cx: &mut TestAppContext, mut rng: Std prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), None, - None, cx, ) }); @@ -1041,7 +1034,6 @@ fn test_mark_cache_anchors(cx: &mut App) { TextThread::local( registry, None, - None, prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), cx, @@ -1368,7 +1360,6 @@ fn setup_context_editor_with_fake_model( TextThread::local( registry, None, - None, prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), cx, diff --git a/crates/assistant_text_thread/src/text_thread.rs b/crates/assistant_text_thread/src/text_thread.rs index b808d9fb0019ccad25366d9ae60cc1f765126c74..5ec72eb0814f9ac09aba36f52d6f011af5b47249 100644 --- a/crates/assistant_text_thread/src/text_thread.rs +++ b/crates/assistant_text_thread/src/text_thread.rs @@ -5,7 +5,7 @@ use assistant_slash_command::{ SlashCommandResult, SlashCommandWorkingSet, }; use assistant_slash_commands::FileCommandMetadata; -use client::{self, ModelRequestUsage, RequestUsage, proto, telemetry::Telemetry}; +use client::{self, ModelRequestUsage, RequestUsage, proto}; use clock::ReplicaId; use cloud_llm_client::{CompletionIntent, UsageLimit}; use collections::{HashMap, HashSet}; @@ -19,10 +19,11 @@ use gpui::{ use itertools::Itertools as _; use language::{AnchorRangeExt, Bias, Buffer, LanguageRegistry, OffsetRangeExt, Point, ToOffset}; use language_model::{ - LanguageModel, LanguageModelCacheConfiguration, LanguageModelCompletionEvent, - LanguageModelImage, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, + AnthropicCompletionType, AnthropicEventData, AnthropicEventType, LanguageModel, + LanguageModelCacheConfiguration, LanguageModelCompletionEvent, LanguageModelImage, + LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, LanguageModelToolUseId, MessageContent, PaymentRequiredError, Role, StopReason, - report_assistant_event, + report_anthropic_event, }; use open_ai::Model as OpenAiModel; use paths::text_threads_dir; @@ -40,7 +41,7 @@ use std::{ sync::Arc, time::{Duration, Instant}, }; -use telemetry_events::{AssistantEventData, AssistantKind, AssistantPhase}; + use text::{BufferSnapshot, ToPoint}; use ui::IconName; use util::{ResultExt, TryFutureExt, post_inc}; @@ -686,7 +687,6 @@ pub struct TextThread { pending_cache_warming_task: Task>, path: Option>, _subscriptions: Vec, - telemetry: Option>, language_registry: Arc, project: Option>, prompt_builder: Arc, @@ -709,7 +709,6 @@ impl TextThread { pub fn local( language_registry: Arc, project: Option>, - telemetry: Option>, prompt_builder: Arc, slash_commands: Arc, cx: &mut Context, @@ -722,7 +721,6 @@ impl TextThread { prompt_builder, slash_commands, project, - telemetry, cx, ) } @@ -743,7 +741,6 @@ impl TextThread { prompt_builder: Arc, slash_commands: Arc, project: Option>, - telemetry: Option>, cx: &mut Context, ) -> Self { let buffer = cx.new(|_cx| { @@ -784,7 +781,6 @@ impl TextThread { completion_mode: AgentSettings::get_global(cx).preferred_completion_mode, path: None, buffer, - telemetry, project, language_registry, slash_commands, @@ -874,7 +870,6 @@ impl TextThread { prompt_builder: Arc, slash_commands: Arc, project: Option>, - telemetry: Option>, cx: &mut Context, ) -> Self { let id = saved_context.id.clone().unwrap_or_else(TextThreadId::new); @@ -886,7 +881,6 @@ impl TextThread { prompt_builder, slash_commands, project, - telemetry, cx, ); this.path = Some(path); @@ -2212,24 +2206,26 @@ impl TextThread { .read(cx) .language() .map(|language| language.name()); - report_assistant_event( - AssistantEventData { - conversation_id: Some(this.id.0.clone()), - kind: AssistantKind::Panel, - phase: AssistantPhase::Response, - message_id: None, - model: model.telemetry_id(), - model_provider: model.provider_id().to_string(), - response_latency, - error_message, - language_name: language_name.map(|name| name.to_proto()), - }, - this.telemetry.clone(), - cx.http_client(), - model.api_key(cx), - cx.background_executor(), + + telemetry::event!( + "Assistant Responded", + conversation_id = this.id.0.clone(), + kind = "panel", + phase = "response", + model = model.telemetry_id(), + model_provider = model.provider_id().to_string(), + response_latency, + error_message, + language_name = language_name.as_ref().map(|name| name.to_proto()), ); + report_anthropic_event(&model, AnthropicEventData { + completion_type: AnthropicCompletionType::Panel, + event: AnthropicEventType::Response, + language_name: language_name.map(|name| name.to_proto()), + message_id: None, + }, cx); + if let Ok(stop_reason) = result { match stop_reason { StopReason::ToolUse => {} diff --git a/crates/assistant_text_thread/src/text_thread_store.rs b/crates/assistant_text_thread/src/text_thread_store.rs index 71fabed503e8c04a8865bed72c28ae5b30e75574..483baa73134334162ea30d269a1f955dd8fe023a 100644 --- a/crates/assistant_text_thread/src/text_thread_store.rs +++ b/crates/assistant_text_thread/src/text_thread_store.rs @@ -4,7 +4,7 @@ use crate::{ }; use anyhow::{Context as _, Result}; use assistant_slash_command::{SlashCommandId, SlashCommandWorkingSet}; -use client::{Client, TypedEnvelope, proto, telemetry::Telemetry}; +use client::{Client, TypedEnvelope, proto}; use clock::ReplicaId; use collections::HashMap; use context_server::ContextServerId; @@ -48,7 +48,6 @@ pub struct TextThreadStore { fs: Arc, languages: Arc, slash_commands: Arc, - telemetry: Arc, _watch_updates: Task>, client: Arc, project: WeakEntity, @@ -88,7 +87,6 @@ impl TextThreadStore { ) -> Task>> { let fs = project.read(cx).fs().clone(); let languages = project.read(cx).languages().clone(); - let telemetry = project.read(cx).client().telemetry().clone(); cx.spawn(async move |cx| { const CONTEXT_WATCH_DURATION: Duration = Duration::from_millis(100); let (mut events, _) = fs.watch(text_threads_dir(), CONTEXT_WATCH_DURATION).await; @@ -102,7 +100,6 @@ impl TextThreadStore { fs, languages, slash_commands, - telemetry, _watch_updates: cx.spawn(async move |this, cx| { async move { while events.next().await.is_some() { @@ -143,7 +140,6 @@ impl TextThreadStore { fs: project.read(cx).fs().clone(), languages: project.read(cx).languages().clone(), slash_commands: Arc::default(), - telemetry: project.read(cx).client().telemetry().clone(), _watch_updates: Task::ready(None), client: project.read(cx).client(), project: project.downgrade(), @@ -379,7 +375,6 @@ impl TextThreadStore { TextThread::local( self.languages.clone(), Some(self.project.clone()), - Some(self.telemetry.clone()), self.prompt_builder.clone(), self.slash_commands.clone(), cx, @@ -402,7 +397,7 @@ impl TextThreadStore { let capability = project.capability(); let language_registry = self.languages.clone(); let project = self.project.clone(); - let telemetry = self.telemetry.clone(); + let prompt_builder = self.prompt_builder.clone(); let slash_commands = self.slash_commands.clone(); let request = self.client.request(proto::CreateContext { project_id }); @@ -419,7 +414,6 @@ impl TextThreadStore { prompt_builder, slash_commands, Some(project), - Some(telemetry), cx, ) })?; @@ -457,7 +451,6 @@ impl TextThreadStore { let fs = self.fs.clone(); let languages = self.languages.clone(); let project = self.project.clone(); - let telemetry = self.telemetry.clone(); let load = cx.background_spawn({ let path = path.clone(); async move { @@ -478,7 +471,6 @@ impl TextThreadStore { prompt_builder, slash_commands, Some(project), - Some(telemetry), cx, ) })?; @@ -568,7 +560,6 @@ impl TextThreadStore { let capability = project.capability(); let language_registry = self.languages.clone(); let project = self.project.clone(); - let telemetry = self.telemetry.clone(); let request = self.client.request(proto::OpenContext { project_id, context_id: text_thread_id.to_proto(), @@ -587,7 +578,6 @@ impl TextThreadStore { prompt_builder, slash_commands, Some(project), - Some(telemetry), cx, ) })?; 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/call/src/call_impl/room.rs b/crates/call/src/call_impl/room.rs index fc15b4e4395ae7aa3100a165d942a6906cf1976d..ccc8c067c25a91aa44c01911be89c21f0ea9367c 100644 --- a/crates/call/src/call_impl/room.rs +++ b/crates/call/src/call_impl/room.rs @@ -305,6 +305,7 @@ impl Room { pub(crate) fn leave(&mut self, cx: &mut Context) -> Task> { cx.notify(); + self.emit_video_track_unsubscribed_events(cx); self.leave_internal(cx) } @@ -352,6 +353,14 @@ impl Room { self.maintain_connection.take(); } + fn emit_video_track_unsubscribed_events(&self, cx: &mut Context) { + for participant in self.remote_participants.values() { + for sid in participant.video_tracks.keys() { + cx.emit(Event::RemoteVideoTrackUnsubscribed { sid: sid.clone() }); + } + } + } + async fn maintain_connection( this: WeakEntity, client: Arc, @@ -882,6 +891,9 @@ impl Room { project_id: project.id, }); } + for sid in participant.video_tracks.keys() { + cx.emit(Event::RemoteVideoTrackUnsubscribed { sid: sid.clone() }); + } false } }); diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 7988f001dab37858d36f791fa8a184fe329c4be5..e1a7a1481b56633364cb011f46cd55e616244f2c 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -32,7 +32,7 @@ struct Detect; trait InstalledApp { fn zed_version_string(&self) -> String; - fn launch(&self, ipc_url: String) -> anyhow::Result<()>; + fn launch(&self, ipc_url: String, user_data_dir: Option<&str>) -> anyhow::Result<()>; fn run_foreground( &self, ipc_url: String, @@ -61,6 +61,8 @@ Examples: )] struct Args { /// Wait for all of the given paths to be opened/closed before exiting. + /// + /// When opening a directory, waits until the created window is closed. #[arg(short, long)] wait: bool, /// Add files to the currently open workspace @@ -588,7 +590,7 @@ fn main() -> Result<()> { if args.foreground { app.run_foreground(url, user_data_dir.as_deref())?; } else { - app.launch(url)?; + app.launch(url, user_data_dir.as_deref())?; sender.join().unwrap()?; if let Some(handle) = stdin_pipe_handle { handle.join().unwrap()?; @@ -709,14 +711,18 @@ mod linux { ) } - fn launch(&self, ipc_url: String) -> anyhow::Result<()> { - let sock_path = paths::data_dir().join(format!( + fn launch(&self, ipc_url: String, user_data_dir: Option<&str>) -> anyhow::Result<()> { + let data_dir = user_data_dir + .map(PathBuf::from) + .unwrap_or_else(|| paths::data_dir().clone()); + + let sock_path = data_dir.join(format!( "zed-{}.sock", *release_channel::RELEASE_CHANNEL_NAME )); let sock = UnixDatagram::unbound()?; if sock.connect(&sock_path).is_err() { - self.boot_background(ipc_url)?; + self.boot_background(ipc_url, user_data_dir)?; } else { sock.send(ipc_url.as_bytes())?; } @@ -742,7 +748,11 @@ mod linux { } impl App { - fn boot_background(&self, ipc_url: String) -> anyhow::Result<()> { + fn boot_background( + &self, + ipc_url: String, + user_data_dir: Option<&str>, + ) -> anyhow::Result<()> { let path = &self.0; match fork::fork() { @@ -756,8 +766,13 @@ mod linux { if fork::close_fd().is_err() { eprintln!("failed to close_fd: {}", std::io::Error::last_os_error()); } - let error = - exec::execvp(path.clone(), &[path.as_os_str(), &OsString::from(ipc_url)]); + let mut args: Vec = + vec![path.as_os_str().to_owned(), OsString::from(ipc_url)]; + if let Some(dir) = user_data_dir { + args.push(OsString::from("--user-data-dir")); + args.push(OsString::from(dir)); + } + let error = exec::execvp(path.clone(), &args); // if exec succeeded, we never get here. eprintln!("failed to exec {:?}: {}", path, error); process::exit(1) @@ -943,11 +958,14 @@ mod windows { ) } - fn launch(&self, ipc_url: String) -> anyhow::Result<()> { + fn launch(&self, ipc_url: String, user_data_dir: Option<&str>) -> anyhow::Result<()> { if check_single_instance() { - std::process::Command::new(self.0.clone()) - .arg(ipc_url) - .spawn()?; + let mut cmd = std::process::Command::new(self.0.clone()); + cmd.arg(ipc_url); + if let Some(dir) = user_data_dir { + cmd.arg("--user-data-dir").arg(dir); + } + cmd.spawn()?; } else { unsafe { let pipe = CreateFileW( @@ -1096,7 +1114,7 @@ mod mac_os { format!("Zed {} – {}", self.version(), self.path().display(),) } - fn launch(&self, url: String) -> anyhow::Result<()> { + fn launch(&self, url: String, user_data_dir: Option<&str>) -> anyhow::Result<()> { match self { Self::App { app_bundle, .. } => { let app_path = app_bundle; @@ -1146,8 +1164,11 @@ mod mac_os { format!("Cloning descriptor for file {subprocess_stdout_file:?}") })?; let mut command = std::process::Command::new(executable); - let command = command - .env(FORCE_CLI_MODE_ENV_VAR_NAME, "") + command.env(FORCE_CLI_MODE_ENV_VAR_NAME, ""); + if let Some(dir) = user_data_dir { + command.arg("--user-data-dir").arg(dir); + } + command .stderr(subprocess_stdout_file) .stdout(subprocess_stdin_file) .arg(url); diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index 7149ad4f55feaae5b596a39a3dd460d71cc5daa5..50cf12b977a62d56bf9d4a036165917a5dfff2fc 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -53,7 +53,7 @@ text.workspace = true thiserror.workspace = true time.workspace = true tiny_http.workspace = true -tokio-socks = { version = "0.5.2", default-features = false, features = ["futures-io"] } +tokio-socks.workspace = true tokio.workspace = true url.workspace = true util.workspace = true diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 6d6d229b940433ceac4c80f11891319550d269a2..801c8c3de8d3f02e3d73809df2c651c6973f231a 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -150,9 +150,8 @@ pub fn init(client: &Arc, cx: &mut App) { .detach_and_log_err(cx); } } - }); - - cx.on_action({ + }) + .on_action({ let client = client.clone(); move |_: &SignOut, cx| { if let Some(client) = client.upgrade() { @@ -162,9 +161,8 @@ pub fn init(client: &Arc, cx: &mut App) { .detach(); } } - }); - - cx.on_action({ + }) + .on_action({ let client = client; move |_: &Reconnect, cx| { if let Some(client) = client.upgrade() { @@ -1732,23 +1730,59 @@ impl ProtoClient for Client { /// prefix for the zed:// url scheme pub const ZED_URL_SCHEME: &str = "zed"; +/// A parsed Zed link that can be handled internally by the application. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ZedLink { + /// Join a channel: `zed.dev/channel/channel-name-123` or `zed://channel/channel-name-123` + Channel { channel_id: u64 }, + /// Open channel notes: `zed.dev/channel/channel-name-123/notes` or with heading `notes#heading` + ChannelNotes { + channel_id: u64, + heading: Option, + }, +} + /// Parses the given link into a Zed link. /// -/// Returns a [`Some`] containing the unprefixed link if the link is a Zed link. -/// Returns [`None`] otherwise. -pub fn parse_zed_link<'a>(link: &'a str, cx: &App) -> Option<&'a str> { +/// Returns a [`Some`] containing the parsed link if the link is a recognized Zed link +/// that should be handled internally by the application. +/// Returns [`None`] for links that should be opened in the browser. +pub fn parse_zed_link(link: &str, cx: &App) -> Option { let server_url = &ClientSettings::get_global(cx).server_url; - if let Some(stripped) = link + let path = link .strip_prefix(server_url) .and_then(|result| result.strip_prefix('/')) - { - return Some(stripped); + .or_else(|| { + link.strip_prefix(ZED_URL_SCHEME) + .and_then(|result| result.strip_prefix("://")) + })?; + + let mut parts = path.split('/'); + + if parts.next() != Some("channel") { + return None; } - if let Some(stripped) = link - .strip_prefix(ZED_URL_SCHEME) - .and_then(|result| result.strip_prefix("://")) - { - return Some(stripped); + + let slug = parts.next()?; + let id_str = slug.split('-').next_back()?; + let channel_id = id_str.parse::().ok()?; + + let Some(next) = parts.next() else { + return Some(ZedLink::Channel { channel_id }); + }; + + if let Some(heading) = next.strip_prefix("notes#") { + return Some(ZedLink::ChannelNotes { + channel_id, + heading: Some(heading.to_string()), + }); + } + + if next == "notes" { + return Some(ZedLink::ChannelNotes { + channel_id, + heading: None, + }); } None diff --git a/crates/cloud_llm_client/src/cloud_llm_client.rs b/crates/cloud_llm_client/src/cloud_llm_client.rs index 917929a985c85610b907e682792e132cb84d8403..2c5b2649000bb071b9d206d9d2c204f1eea9bda1 100644 --- a/crates/cloud_llm_client/src/cloud_llm_client.rs +++ b/crates/cloud_llm_client/src/cloud_llm_client.rs @@ -371,6 +371,8 @@ pub struct LanguageModel { pub supports_images: bool, pub supports_thinking: bool, pub supports_max_mode: bool, + #[serde(default)] + pub supports_streaming_tools: bool, // only used by OpenAI and xAI #[serde(default)] pub supports_parallel_tool_calls: bool, diff --git a/crates/cloud_zeta2_prompt/Cargo.toml b/crates/cloud_zeta2_prompt/Cargo.toml deleted file mode 100644 index a15e3fe43c28349920433272c4040ccc58ff4cb4..0000000000000000000000000000000000000000 --- a/crates/cloud_zeta2_prompt/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "cloud_zeta2_prompt" -version = "0.1.0" -publish.workspace = true -edition.workspace = true -license = "GPL-3.0-or-later" - -[lints] -workspace = true - -[lib] -path = "src/cloud_zeta2_prompt.rs" - -[dependencies] -anyhow.workspace = true -cloud_llm_client.workspace = true -indoc.workspace = true -serde.workspace = true diff --git a/crates/cloud_zeta2_prompt/src/cloud_zeta2_prompt.rs b/crates/cloud_zeta2_prompt/src/cloud_zeta2_prompt.rs deleted file mode 100644 index 62bfa45f47d0fdfefa9fbd72320c0ddee71cbc47..0000000000000000000000000000000000000000 --- a/crates/cloud_zeta2_prompt/src/cloud_zeta2_prompt.rs +++ /dev/null @@ -1,485 +0,0 @@ -use anyhow::Result; -use cloud_llm_client::predict_edits_v3::{ - self, DiffPathFmt, Event, Excerpt, Line, Point, PromptFormat, RelatedFile, -}; -use indoc::indoc; -use std::cmp; -use std::fmt::Write; -use std::path::Path; -use std::sync::Arc; - -pub const DEFAULT_MAX_PROMPT_BYTES: usize = 10 * 1024; - -pub const CURSOR_MARKER: &str = "<|user_cursor|>"; -/// NOTE: Differs from zed version of constant - includes a newline -pub const EDITABLE_REGION_START_MARKER_WITH_NEWLINE: &str = "<|editable_region_start|>\n"; -/// NOTE: Differs from zed version of constant - includes a newline -pub const EDITABLE_REGION_END_MARKER_WITH_NEWLINE: &str = "<|editable_region_end|>\n"; - -const STUDENT_MODEL_INSTRUCTIONS: &str = indoc! {r#" - You are a code completion assistant that analyzes edit history to identify and systematically complete incomplete refactorings or patterns across the entire codebase. - - ## Edit History - - "#}; - -const MINIMAL_PROMPT_REMINDER: &str = indoc! {" - --- - - Please analyze the edit history and the files, then provide the unified diff for your predicted edits. - Do not include the cursor marker in your output. - If you're editing multiple files, be sure to reflect filename in the hunk's header. - "}; - -const XML_TAGS_INSTRUCTIONS: &str = indoc! {r#" - # Instructions - - You are an edit prediction agent in a code editor. - - Analyze the history of edits made by the user in order to infer what they are currently trying to accomplish. - Then complete the remainder of the current change if it is incomplete, or predict the next edit the user intends to make. - Always continue along the user's current trajectory, rather than changing course. - - ## Output Format - - You should briefly explain your understanding of the user's overall goal in one sentence, then explain what the next change - along the users current trajectory will be in another, and finally specify the next edit using the following XML-like format: - - - - OLD TEXT 1 HERE - - - NEW TEXT 1 HERE - - - - OLD TEXT 1 HERE - - - NEW TEXT 1 HERE - - - - - Specify the file to edit using the `path` attribute. - - Use `` and `` tags to replace content - - `` must exactly match existing file content, including indentation - - `` cannot be empty - - Do not escape quotes, newlines, or other characters within tags - - Always close all tags properly - - Don't include the <|user_cursor|> marker in your output. - - ## Edit History - -"#}; - -const OLD_TEXT_NEW_TEXT_REMINDER: &str = indoc! {r#" - --- - - Remember that the edits in the edit history have already been applied. -"#}; - -pub fn build_prompt(request: &predict_edits_v3::PredictEditsRequest) -> Result { - let prompt_data = PromptData { - events: request.events.clone(), - cursor_point: request.cursor_point, - cursor_path: request.excerpt_path.clone(), - included_files: request.related_files.clone(), - }; - match request.prompt_format { - PromptFormat::MinimalQwen => { - return Ok(MinimalQwenPrompt.render(&prompt_data)); - } - PromptFormat::SeedCoder1120 => { - return Ok(SeedCoder1120Prompt.render(&prompt_data)); - } - _ => (), - }; - - let insertions = match request.prompt_format { - PromptFormat::Minimal | PromptFormat::OldTextNewText => { - vec![(request.cursor_point, CURSOR_MARKER)] - } - PromptFormat::OnlySnippets => vec![], - PromptFormat::MinimalQwen => unreachable!(), - PromptFormat::SeedCoder1120 => unreachable!(), - }; - - let mut prompt = match request.prompt_format { - PromptFormat::OldTextNewText => XML_TAGS_INSTRUCTIONS.to_string(), - PromptFormat::OnlySnippets => String::new(), - PromptFormat::Minimal => STUDENT_MODEL_INSTRUCTIONS.to_string(), - PromptFormat::MinimalQwen => unreachable!(), - PromptFormat::SeedCoder1120 => unreachable!(), - }; - - if request.events.is_empty() { - prompt.push_str("(No edit history)\n\n"); - } else { - let edit_preamble = if request.prompt_format == PromptFormat::Minimal { - "The following are the latest edits made by the user, from earlier to later.\n\n" - } else { - "Here are the latest edits made by the user, from earlier to later.\n\n" - }; - prompt.push_str(edit_preamble); - push_events(&mut prompt, &request.events); - } - - let excerpts_preamble = match request.prompt_format { - PromptFormat::Minimal => indoc! {" - ## Part of the file under the cursor - - (The cursor marker <|user_cursor|> indicates the current user cursor position. - The file is in current state, edits from edit history has been applied. - We only show part of the file around the cursor. - You can only edit exactly this part of the file. - We prepend line numbers (e.g., `123|`); they are not part of the file.) - "}, - PromptFormat::OldTextNewText => indoc! {" - ## Code Excerpts - - Here is some excerpts of code that you should take into account to predict the next edit. - - The cursor position is marked by `<|user_cursor|>` as it stands after the last edit in the history. - - In addition other excerpts are included to better understand what the edit will be, including the declaration - or references of symbols around the cursor, or other similar code snippets that may need to be updated - following patterns that appear in the edit history. - - Consider each of them carefully in relation to the edit history, and that the user may not have navigated - to the next place they want to edit yet. - - Lines starting with `…` indicate omitted line ranges. These may appear inside multi-line code constructs. - "}, - PromptFormat::OnlySnippets | PromptFormat::MinimalQwen | PromptFormat::SeedCoder1120 => { - indoc! {" - ## Code Excerpts - - The cursor marker <|user_cursor|> indicates the current user cursor position. - The file is in current state, edits from edit history have been applied. - "} - } - }; - - prompt.push_str(excerpts_preamble); - prompt.push('\n'); - - let include_line_numbers = matches!(request.prompt_format, PromptFormat::Minimal); - for related_file in &request.related_files { - if request.prompt_format == PromptFormat::Minimal { - write_codeblock_with_filename( - &related_file.path, - &related_file.excerpts, - if related_file.path == request.excerpt_path { - &insertions - } else { - &[] - }, - related_file.max_row, - include_line_numbers, - &mut prompt, - ); - } else { - write_codeblock( - &related_file.path, - &related_file.excerpts, - if related_file.path == request.excerpt_path { - &insertions - } else { - &[] - }, - related_file.max_row, - include_line_numbers, - &mut prompt, - ); - } - } - - match request.prompt_format { - PromptFormat::OldTextNewText => { - prompt.push_str(OLD_TEXT_NEW_TEXT_REMINDER); - } - PromptFormat::Minimal => { - prompt.push_str(MINIMAL_PROMPT_REMINDER); - } - _ => {} - } - - Ok(prompt) -} - -pub fn generation_params(prompt_format: PromptFormat) -> GenerationParams { - match prompt_format { - PromptFormat::SeedCoder1120 => SeedCoder1120Prompt::generation_params(), - _ => GenerationParams::default(), - } -} - -pub fn write_codeblock<'a>( - path: &Path, - excerpts: impl IntoIterator, - sorted_insertions: &[(Point, &str)], - file_line_count: Line, - include_line_numbers: bool, - output: &'a mut String, -) { - writeln!(output, "`````{}", DiffPathFmt(path)).unwrap(); - - write_excerpts( - excerpts, - sorted_insertions, - file_line_count, - include_line_numbers, - output, - ); - write!(output, "`````\n\n").unwrap(); -} - -fn write_codeblock_with_filename<'a>( - path: &Path, - excerpts: impl IntoIterator, - sorted_insertions: &[(Point, &str)], - file_line_count: Line, - include_line_numbers: bool, - output: &'a mut String, -) { - writeln!(output, "`````filename={}", DiffPathFmt(path)).unwrap(); - - write_excerpts( - excerpts, - sorted_insertions, - file_line_count, - include_line_numbers, - output, - ); - write!(output, "`````\n\n").unwrap(); -} - -pub fn write_excerpts<'a>( - excerpts: impl IntoIterator, - sorted_insertions: &[(Point, &str)], - file_line_count: Line, - include_line_numbers: bool, - output: &mut String, -) { - let mut current_row = Line(0); - let mut sorted_insertions = sorted_insertions.iter().peekable(); - - for excerpt in excerpts { - if excerpt.start_line > current_row { - writeln!(output, "…").unwrap(); - } - if excerpt.text.is_empty() { - return; - } - - current_row = excerpt.start_line; - - for mut line in excerpt.text.lines() { - if include_line_numbers { - write!(output, "{}|", current_row.0 + 1).unwrap(); - } - - while let Some((insertion_location, insertion_marker)) = sorted_insertions.peek() { - match current_row.cmp(&insertion_location.line) { - cmp::Ordering::Equal => { - let (prefix, suffix) = line.split_at(insertion_location.column as usize); - output.push_str(prefix); - output.push_str(insertion_marker); - line = suffix; - sorted_insertions.next(); - } - cmp::Ordering::Less => break, - cmp::Ordering::Greater => { - sorted_insertions.next(); - break; - } - } - } - output.push_str(line); - output.push('\n'); - current_row.0 += 1; - } - } - - if current_row < file_line_count { - writeln!(output, "…").unwrap(); - } -} - -pub fn push_events(output: &mut String, events: &[Arc]) { - if events.is_empty() { - return; - }; - - writeln!(output, "`````diff").unwrap(); - for event in events { - writeln!(output, "{}", event).unwrap(); - } - writeln!(output, "`````\n").unwrap(); -} - -struct PromptData { - events: Vec>, - cursor_point: Point, - cursor_path: Arc, // TODO: make a common struct with cursor_point - included_files: Vec, -} - -#[derive(Default)] -pub struct GenerationParams { - pub temperature: Option, - pub top_p: Option, - pub stop: Option>, -} - -trait PromptFormatter { - fn render(&self, data: &PromptData) -> String; - - fn generation_params() -> GenerationParams { - return GenerationParams::default(); - } -} - -struct MinimalQwenPrompt; - -impl PromptFormatter for MinimalQwenPrompt { - fn render(&self, data: &PromptData) -> String { - let edit_history = self.fmt_edit_history(data); - let context = self.fmt_context(data); - - format!( - "{instructions}\n\n{edit_history}\n\n{context}", - instructions = MinimalQwenPrompt::INSTRUCTIONS, - edit_history = edit_history, - context = context - ) - } -} - -impl MinimalQwenPrompt { - const INSTRUCTIONS: &str = "You are a code completion assistant that analyzes edit history to identify and systematically complete incomplete refactorings or patterns across the entire codebase.\n"; - - fn fmt_edit_history(&self, data: &PromptData) -> String { - if data.events.is_empty() { - "(No edit history)\n\n".to_string() - } else { - let mut events_str = String::new(); - push_events(&mut events_str, &data.events); - format!( - "The following are the latest edits made by the user, from earlier to later.\n\n{}", - events_str - ) - } - } - - fn fmt_context(&self, data: &PromptData) -> String { - let mut context = String::new(); - let include_line_numbers = true; - - for related_file in &data.included_files { - writeln!(context, "<|file_sep|>{}", DiffPathFmt(&related_file.path)).unwrap(); - - if related_file.path == data.cursor_path { - write!(context, "<|fim_prefix|>").unwrap(); - write_excerpts( - &related_file.excerpts, - &[(data.cursor_point, "<|fim_suffix|>")], - related_file.max_row, - include_line_numbers, - &mut context, - ); - writeln!(context, "<|fim_middle|>").unwrap(); - } else { - write_excerpts( - &related_file.excerpts, - &[], - related_file.max_row, - include_line_numbers, - &mut context, - ); - } - } - context - } -} - -struct SeedCoder1120Prompt; - -impl PromptFormatter for SeedCoder1120Prompt { - fn render(&self, data: &PromptData) -> String { - let edit_history = self.fmt_edit_history(data); - let context = self.fmt_context(data); - - format!( - "# Edit History:\n{edit_history}\n\n{context}", - edit_history = edit_history, - context = context - ) - } - - fn generation_params() -> GenerationParams { - GenerationParams { - temperature: Some(0.2), - top_p: Some(0.9), - stop: Some(vec!["<[end_of_sentence]>".into()]), - } - } -} - -impl SeedCoder1120Prompt { - fn fmt_edit_history(&self, data: &PromptData) -> String { - if data.events.is_empty() { - "(No edit history)\n\n".to_string() - } else { - let mut events_str = String::new(); - push_events(&mut events_str, &data.events); - events_str - } - } - - fn fmt_context(&self, data: &PromptData) -> String { - let mut context = String::new(); - let include_line_numbers = true; - - for related_file in &data.included_files { - writeln!(context, "# Path: {}\n", DiffPathFmt(&related_file.path)).unwrap(); - - if related_file.path == data.cursor_path { - let fim_prompt = self.fmt_fim(&related_file, data.cursor_point); - context.push_str(&fim_prompt); - } else { - write_excerpts( - &related_file.excerpts, - &[], - related_file.max_row, - include_line_numbers, - &mut context, - ); - } - } - context - } - - fn fmt_fim(&self, file: &RelatedFile, cursor_point: Point) -> String { - let mut buf = String::new(); - const FIM_SUFFIX: &str = "<[fim-suffix]>"; - const FIM_PREFIX: &str = "<[fim-prefix]>"; - const FIM_MIDDLE: &str = "<[fim-middle]>"; - write!(buf, "{}", FIM_PREFIX).unwrap(); - write_excerpts( - &file.excerpts, - &[(cursor_point, FIM_SUFFIX)], - file.max_row, - true, - &mut buf, - ); - - // Swap prefix and suffix parts - let index = buf.find(FIM_SUFFIX).unwrap(); - let prefix = &buf[..index]; - let suffix = &buf[index..]; - - format!("{}{}{}", suffix, prefix, FIM_MIDDLE) - } -} diff --git a/crates/codestral/src/codestral.rs b/crates/codestral/src/codestral.rs index 9bf0296ac357937cd1ad1470dba9a98864911de9..9cf2fab80b78ba06c6a2523013e2f73934f50052 100644 --- a/crates/codestral/src/codestral.rs +++ b/crates/codestral/src/codestral.rs @@ -1,6 +1,6 @@ use anyhow::{Context as _, Result}; use edit_prediction_context::{EditPredictionExcerpt, EditPredictionExcerptOptions}; -use edit_prediction_types::{Direction, EditPrediction, EditPredictionDelegate}; +use edit_prediction_types::{EditPrediction, EditPredictionDelegate}; use futures::AsyncReadExt; use gpui::{App, Context, Entity, Task}; use http_client::HttpClient; @@ -300,16 +300,6 @@ impl EditPredictionDelegate for CodestralEditPredictionDelegate { })); } - fn cycle( - &mut self, - _buffer: Entity, - _cursor_position: Anchor, - _direction: Direction, - _cx: &mut Context, - ) { - // Codestral doesn't support multiple completions, so cycling does nothing - } - fn accept(&mut self, _cx: &mut Context) { log::debug!("Codestral: Completion accepted"); self.pending_request = None; diff --git a/crates/collab/src/api/contributors.rs b/crates/collab/src/api/contributors.rs index 574667c723dce62b905e3d2a0b34de1ca4c88c8e..ce318b15295ebe5c777597a6d3c6106e57af8e05 100644 --- a/crates/collab/src/api/contributors.rs +++ b/crates/collab/src/api/contributors.rs @@ -54,6 +54,26 @@ async fn check_is_contributor( ) -> Result> { let params = params.into_contributor_selector()?; + if CopilotSweAgentBot::is_copilot_bot(¶ms) { + return Ok(Json(CheckIsContributorResponse { + signed_at: Some( + CopilotSweAgentBot::created_at() + .and_utc() + .to_rfc3339_opts(SecondsFormat::Millis, true), + ), + })); + } + + if Dependabot::is_dependabot(¶ms) { + return Ok(Json(CheckIsContributorResponse { + signed_at: Some( + Dependabot::created_at() + .and_utc() + .to_rfc3339_opts(SecondsFormat::Millis, true), + ), + })); + } + if RenovateBot::is_renovate_bot(¶ms) { return Ok(Json(CheckIsContributorResponse { signed_at: Some( @@ -83,6 +103,71 @@ async fn check_is_contributor( })) } +/// The Copilot bot GitHub user (`copilot-swe-agent[bot]`). +/// +/// https://api.github.com/users/copilot-swe-agent[bot] +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 { + static CREATED_AT: OnceLock = OnceLock::new(); + CREATED_AT.get_or_init(|| { + chrono::DateTime::parse_from_rfc3339("2025-02-12T20:26:08Z") + .expect("failed to parse 'created_at' for 'copilot-swe-agent[bot]'") + .naive_utc() + }) + } + + /// 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 || github_login == Self::NAME_ALIAS + } + ContributorSelector::GitHubUserId { github_user_id } => { + github_user_id == &Self::USER_ID + } + } + } +} + +/// The Dependabot bot GitHub user (`dependabot[bot]`). +/// +/// https://api.github.com/users/dependabot[bot] +struct Dependabot; + +impl Dependabot { + const LOGIN: &'static str = "dependabot[bot]"; + const USER_ID: i32 = 49699333; + + /// Returns the `created_at` timestamp for the Dependabot bot user. + fn created_at() -> &'static NaiveDateTime { + static CREATED_AT: OnceLock = OnceLock::new(); + CREATED_AT.get_or_init(|| { + chrono::DateTime::parse_from_rfc3339("2019-04-16T22:34:25Z") + .expect("failed to parse 'created_at' for 'dependabot[bot]'") + .naive_utc() + }) + } + + /// Returns whether the given contributor selector corresponds to the Dependabot bot user. + fn is_dependabot(contributor: &ContributorSelector) -> bool { + match contributor { + ContributorSelector::GitHubLogin { github_login } => github_login == Self::LOGIN, + ContributorSelector::GitHubUserId { github_user_id } => { + github_user_id == &Self::USER_ID + } + } + } +} + /// The Renovate bot GitHub user (`renovate[bot]`). /// /// https://api.github.com/users/renovate[bot] diff --git a/crates/collab/src/tests/editor_tests.rs b/crates/collab/src/tests/editor_tests.rs index ba92e868126c7f27fb5051021fce44fe43c8d5e7..4e6cdb0e79aba494bd01137cc262a097a084217e 100644 --- a/crates/collab/src/tests/editor_tests.rs +++ b/crates/collab/src/tests/editor_tests.rs @@ -312,6 +312,49 @@ async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mu "Rust", FakeLspAdapter { capabilities: capabilities.clone(), + initializer: Some(Box::new(|fake_server| { + fake_server.set_request_handler::( + |params, _| async move { + assert_eq!( + params.text_document_position.text_document.uri, + lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(), + ); + assert_eq!( + params.text_document_position.position, + lsp::Position::new(0, 14), + ); + + Ok(Some(lsp::CompletionResponse::Array(vec![ + lsp::CompletionItem { + label: "first_method(…)".into(), + detail: Some("fn(&mut self, B) -> C".into()), + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + new_text: "first_method($1)".to_string(), + range: lsp::Range::new( + lsp::Position::new(0, 14), + lsp::Position::new(0, 14), + ), + })), + insert_text_format: Some(lsp::InsertTextFormat::SNIPPET), + ..Default::default() + }, + lsp::CompletionItem { + label: "second_method(…)".into(), + detail: Some("fn(&mut self, C) -> D".into()), + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + new_text: "second_method()".to_string(), + range: lsp::Range::new( + lsp::Position::new(0, 14), + lsp::Position::new(0, 14), + ), + })), + insert_text_format: Some(lsp::InsertTextFormat::SNIPPET), + ..Default::default() + }, + ]))) + }, + ); + })), ..FakeLspAdapter::default() }, ), @@ -320,6 +363,11 @@ async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mu FakeLspAdapter { name: "fake-analyzer", capabilities: capabilities.clone(), + initializer: Some(Box::new(|fake_server| { + fake_server.set_request_handler::( + |_, _| async move { Ok(None) }, + ); + })), ..FakeLspAdapter::default() }, ), @@ -373,6 +421,7 @@ async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mu let fake_language_server = fake_language_servers[0].next().await.unwrap(); let second_fake_language_server = fake_language_servers[1].next().await.unwrap(); cx_a.background_executor.run_until_parked(); + cx_b.background_executor.run_until_parked(); buffer_b.read_with(cx_b, |buffer, _| { assert!(!buffer.completion_triggers().is_empty()) @@ -387,58 +436,9 @@ async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mu }); cx_b.focus(&editor_b); - // Receive a completion request as the host's language server. - // Return some completions from the host's language server. - cx_a.executor().start_waiting(); - fake_language_server - .set_request_handler::(|params, _| async move { - assert_eq!( - params.text_document_position.text_document.uri, - lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(), - ); - assert_eq!( - params.text_document_position.position, - lsp::Position::new(0, 14), - ); - - Ok(Some(lsp::CompletionResponse::Array(vec![ - lsp::CompletionItem { - label: "first_method(…)".into(), - detail: Some("fn(&mut self, B) -> C".into()), - text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { - new_text: "first_method($1)".to_string(), - range: lsp::Range::new( - lsp::Position::new(0, 14), - lsp::Position::new(0, 14), - ), - })), - insert_text_format: Some(lsp::InsertTextFormat::SNIPPET), - ..Default::default() - }, - lsp::CompletionItem { - label: "second_method(…)".into(), - detail: Some("fn(&mut self, C) -> D".into()), - text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { - new_text: "second_method()".to_string(), - range: lsp::Range::new( - lsp::Position::new(0, 14), - lsp::Position::new(0, 14), - ), - })), - insert_text_format: Some(lsp::InsertTextFormat::SNIPPET), - ..Default::default() - }, - ]))) - }) - .next() - .await - .unwrap(); - second_fake_language_server - .set_request_handler::(|_, _| async move { Ok(None) }) - .next() - .await - .unwrap(); - cx_a.executor().finish_waiting(); + // Allow the completion request to propagate from guest to host to LSP. + cx_b.background_executor.run_until_parked(); + cx_a.background_executor.run_until_parked(); // Open the buffer on the host. let buffer_a = project_a @@ -484,6 +484,7 @@ async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mu // The additional edit is applied. cx_a.executor().run_until_parked(); + cx_b.executor().run_until_parked(); buffer_a.read_with(cx_a, |buffer, _| { assert_eq!( @@ -641,13 +642,11 @@ async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mu ), })), insert_text_format: Some(lsp::InsertTextFormat::SNIPPET), - ..Default::default() + ..lsp::CompletionItem::default() }, ]))) }); - cx_b.executor().run_until_parked(); - // Await both language server responses first_lsp_completion.next().await.unwrap(); second_lsp_completion.next().await.unwrap(); 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 d971bca1f01e878d7517c1d13f525dfbf8e47afa..038b58ac5f4e90544232ccc8da55d0ca71ec28df 100644 --- a/crates/command_palette/src/command_palette.rs +++ b/crates/command_palette/src/command_palette.rs @@ -2,7 +2,7 @@ mod persistence; use std::{ cmp::{self, Reverse}, - collections::HashMap, + collections::{HashMap, VecDeque}, sync::Arc, time::Duration, }; @@ -19,6 +19,7 @@ use gpui::{ ParentElement, Render, Styled, Task, WeakEntity, Window, }; use persistence::COMMAND_PALETTE_HISTORY; +use picker::Direction; use picker::{Picker, PickerDelegate}; use postage::{sink::Sink, stream::Stream}; use settings::Settings; @@ -163,6 +164,7 @@ pub struct CommandPaletteDelegate { Task<()>, postage::dispatch::Receiver<(Vec, Vec, CommandInterceptResult)>, )>, + query_history: QueryHistory, } struct Command { @@ -170,6 +172,91 @@ struct Command { action: Box, } +#[derive(Default)] +struct QueryHistory { + history: Option>, + cursor: Option, + prefix: Option, +} + +impl QueryHistory { + fn history(&mut self) -> &mut VecDeque { + self.history.get_or_insert_with(|| { + COMMAND_PALETTE_HISTORY + .list_recent_queries() + .unwrap_or_default() + .into_iter() + .collect() + }) + } + + fn add(&mut self, query: String) { + if let Some(pos) = self.history().iter().position(|h| h == &query) { + self.history().remove(pos); + } + self.history().push_back(query); + self.cursor = None; + self.prefix = None; + } + + fn validate_cursor(&mut self, current_query: &str) -> Option { + if let Some(pos) = self.cursor { + if self.history().get(pos).map(|s| s.as_str()) != Some(current_query) { + self.cursor = None; + self.prefix = None; + } + } + self.cursor + } + + fn previous(&mut self, current_query: &str) -> Option<&str> { + if self.validate_cursor(current_query).is_none() { + self.prefix = Some(current_query.to_string()); + } + + let prefix = self.prefix.clone().unwrap_or_default(); + let start_index = self.cursor.unwrap_or(self.history().len()); + + for i in (0..start_index).rev() { + if self + .history() + .get(i) + .is_some_and(|e| e.starts_with(&prefix)) + { + self.cursor = Some(i); + return self.history().get(i).map(|s| s.as_str()); + } + } + None + } + + fn next(&mut self, current_query: &str) -> Option<&str> { + let selected = self.validate_cursor(current_query)?; + let prefix = self.prefix.clone().unwrap_or_default(); + + for i in (selected + 1)..self.history().len() { + if self + .history() + .get(i) + .is_some_and(|e| e.starts_with(&prefix)) + { + self.cursor = Some(i); + return self.history().get(i).map(|s| s.as_str()); + } + } + None + } + + fn reset_cursor(&mut self) { + self.cursor = None; + self.prefix = None; + } + + fn is_navigating(&self) -> bool { + self.cursor.is_some() + } +} + impl Clone for Command { fn clone(&self) -> Self { Self { @@ -196,6 +283,7 @@ impl CommandPaletteDelegate { previous_focus_handle, latest_query: String::new(), updating_matches: None, + query_history: Default::default(), } } @@ -271,6 +359,11 @@ impl CommandPaletteDelegate { // so we need to return an Option here self.commands.get(action_ix) } + + #[cfg(any(test, feature = "test-support"))] + pub fn seed_history(&mut self, queries: &[&str]) { + self.query_history.history = Some(queries.iter().map(|s| s.to_string()).collect()); + } } impl PickerDelegate for CommandPaletteDelegate { @@ -280,6 +373,38 @@ impl PickerDelegate for CommandPaletteDelegate { "Execute a command...".into() } + fn select_history( + &mut self, + direction: Direction, + query: &str, + _window: &mut Window, + _cx: &mut App, + ) -> Option { + match direction { + Direction::Up => { + let should_use_history = + self.selected_ix == 0 || self.query_history.is_navigating(); + if should_use_history { + if let Some(query) = self.query_history.previous(query).map(|s| s.to_string()) { + return Some(query); + } + } + } + Direction::Down => { + if self.query_history.is_navigating() { + if let Some(query) = self.query_history.next(query).map(|s| s.to_string()) { + return Some(query); + } else { + let prefix = self.query_history.prefix.take().unwrap_or_default(); + self.query_history.reset_cursor(); + return Some(prefix); + } + } + } + } + None + } + fn match_count(&self) -> usize { self.matches.len() } @@ -439,6 +564,12 @@ impl PickerDelegate for CommandPaletteDelegate { self.dismissed(window, cx); return; } + + if !self.latest_query.is_empty() { + self.query_history.add(self.latest_query.clone()); + self.query_history.reset_cursor(); + } + let action_ix = self.matches[self.selected_ix].candidate_id; let command = self.commands.swap_remove(action_ix); telemetry::event!( @@ -457,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); } @@ -588,7 +719,7 @@ mod tests { use super::*; use editor::Editor; use go_to_line::GoToLine; - use gpui::TestAppContext; + use gpui::{TestAppContext, VisualTestContext}; use language::Point; use project::Project; use settings::KeymapFile; @@ -653,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"); @@ -724,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) @@ -799,7 +930,9 @@ mod tests { "bindings": { "cmd-n": "workspace::NewFile", "enter": "menu::Confirm", - "cmd-shift-p": "command_palette::Toggle" + "cmd-shift-p": "command_palette::Toggle", + "up": "menu::SelectPrevious", + "down": "menu::SelectNext" } } ]"#, @@ -808,4 +941,264 @@ mod tests { app_state }) } + + fn open_palette_with_history( + workspace: &Entity, + history: &[&str], + cx: &mut VisualTestContext, + ) -> Entity> { + cx.simulate_keystrokes("cmd-shift-p"); + cx.run_until_parked(); + + let palette = workspace.update(cx, |workspace, cx| { + workspace + .active_modal::(cx) + .unwrap() + .read(cx) + .picker + .clone() + }); + + palette.update(cx, |palette, _cx| { + palette.delegate.seed_history(history); + }); + + palette + } + + #[gpui::test] + async fn test_history_navigation_basic(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let project = Project::test(app_state.fs.clone(), [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let palette = open_palette_with_history(&workspace, &["backspace", "select all"], cx); + + // Query should be empty initially + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), ""); + }); + + // Press up - should load most recent query "select all" + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "select all"); + }); + + // Press up again - should load "backspace" + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "backspace"); + }); + + // Press down - should go back to "select all" + cx.simulate_keystrokes("down"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "select all"); + }); + + // Press down again - should clear query (exit history mode) + cx.simulate_keystrokes("down"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), ""); + }); + } + + #[gpui::test] + async fn test_history_mode_exit_on_typing(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let project = Project::test(app_state.fs.clone(), [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let palette = open_palette_with_history(&workspace, &["backspace"], cx); + + // Press up to enter history mode + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "backspace"); + }); + + // Type something - should append to the history query + cx.simulate_input("x"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "backspacex"); + }); + } + + #[gpui::test] + async fn test_history_navigation_with_suggestions(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let project = Project::test(app_state.fs.clone(), [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let palette = open_palette_with_history(&workspace, &["editor: close", "editor: open"], cx); + + // Open palette with a query that has multiple matches + cx.simulate_input("editor"); + cx.background_executor.run_until_parked(); + + // Should have multiple matches, selected_ix should be 0 + palette.read_with(cx, |palette, _| { + assert!(palette.delegate.matches.len() > 1); + assert_eq!(palette.delegate.selected_ix, 0); + }); + + // Press down - should navigate to next suggestion (not history) + cx.simulate_keystrokes("down"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, _| { + assert_eq!(palette.delegate.selected_ix, 1); + }); + + // Press up - should go back to first suggestion + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, _| { + assert_eq!(palette.delegate.selected_ix, 0); + }); + + // Press up again at top - should enter history mode and show previous query + // that matches the "editor" prefix + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "editor: open"); + }); + } + + #[gpui::test] + async fn test_history_prefix_search(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let project = Project::test(app_state.fs.clone(), [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let palette = open_palette_with_history( + &workspace, + &["open file", "select all", "select line", "backspace"], + cx, + ); + + // Type "sel" as a prefix + cx.simulate_input("sel"); + cx.background_executor.run_until_parked(); + + // Press up - should get "select line" (most recent matching "sel") + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "select line"); + }); + + // Press up again - should get "select all" (next matching "sel") + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "select all"); + }); + + // Press up again - should stay at "select all" (no more matches for "sel") + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "select all"); + }); + + // Press down - should go back to "select line" + cx.simulate_keystrokes("down"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "select line"); + }); + + // Press down again - should return to original prefix "sel" + cx.simulate_keystrokes("down"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "sel"); + }); + } + + #[gpui::test] + async fn test_history_prefix_search_no_matches(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let project = Project::test(app_state.fs.clone(), [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let palette = + open_palette_with_history(&workspace, &["open file", "backspace", "select all"], cx); + + // Type "xyz" as a prefix that doesn't match anything + cx.simulate_input("xyz"); + cx.background_executor.run_until_parked(); + + // Press up - should stay at "xyz" (no matches) + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "xyz"); + }); + } + + #[gpui::test] + async fn test_history_empty_prefix_searches_all(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let project = Project::test(app_state.fs.clone(), [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let palette = open_palette_with_history(&workspace, &["alpha", "beta", "gamma"], cx); + + // With empty query, press up - should get "gamma" (most recent) + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "gamma"); + }); + + // Press up - should get "beta" + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "beta"); + }); + + // Press up - should get "alpha" + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "alpha"); + }); + + // Press down - should get "beta" + cx.simulate_keystrokes("down"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "beta"); + }); + + // Press down - should get "gamma" + cx.simulate_keystrokes("down"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "gamma"); + }); + + // Press down - should return to empty string (exit history mode) + cx.simulate_keystrokes("down"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), ""); + }); + } } diff --git a/crates/command_palette/src/persistence.rs b/crates/command_palette/src/persistence.rs index feaed72570d56f4895ff05eef891fc81c2e5e0b6..4556079b4f9c8e7a989f3e32eac6f7d084e67a4e 100644 --- a/crates/command_palette/src/persistence.rs +++ b/crates/command_palette/src/persistence.rs @@ -123,6 +123,16 @@ impl CommandPaletteDB { ORDER BY COUNT(1) DESC } } + + query! { + pub fn list_recent_queries() -> Result> { + SELECT user_query + FROM command_invocations + WHERE user_query != "" + GROUP BY user_query + ORDER BY MAX(last_invoked) ASC + } + } } #[cfg(test)] diff --git a/crates/context_server/Cargo.toml b/crates/context_server/Cargo.toml index f73e6a9bab011c5d675040d1ee3a05dfa708dc45..539b873c3527b5a01f1dfcf7b768f0758dc869b5 100644 --- a/crates/context_server/Cargo.toml +++ b/crates/context_server/Cargo.toml @@ -29,10 +29,12 @@ 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"] } util.workspace = true +terminal.workspace = true [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } 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/transport/stdio_transport.rs b/crates/context_server/src/transport/stdio_transport.rs index 83908b46829c4cfe3b536ecca1155c909ee424dd..e675770e9ee50df9993076e6d71c70befa118c4b 100644 --- a/crates/context_server/src/transport/stdio_transport.rs +++ b/crates/context_server/src/transport/stdio_transport.rs @@ -8,9 +8,12 @@ use futures::{ AsyncBufReadExt as _, AsyncRead, AsyncWrite, AsyncWriteExt as _, Stream, StreamExt as _, }; use gpui::AsyncApp; +use settings::Settings as _; use smol::channel; use smol::process::Child; +use terminal::terminal_settings::TerminalSettings; use util::TryFutureExt as _; +use util::shell_builder::ShellBuilder; use crate::client::ModelContextServerBinary; use crate::transport::Transport; @@ -28,9 +31,12 @@ impl StdioTransport { working_directory: &Option, cx: &AsyncApp, ) -> Result { - let mut command = util::command::new_smol_command(&binary.executable); + let shell = cx.update(|cx| TerminalSettings::get(None, cx).shell.clone())?; + let builder = ShellBuilder::new(&shell, cfg!(windows)).non_interactive(); + let mut command = + builder.build_command(Some(binary.executable.display().to_string()), &binary.args); + command - .args(&binary.args) .envs(binary.env.unwrap_or_default()) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) 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/Cargo.toml b/crates/copilot/Cargo.toml index 459abda17573d66287e2c8ca0b995292acaf163b..3a1706a7a679fbc14eafbeac953d842cda9f65c8 100644 --- a/crates/copilot/Cargo.toml +++ b/crates/copilot/Cargo.toml @@ -52,6 +52,7 @@ ui.workspace = true util.workspace = true workspace.workspace = true itertools.workspace = true +url.workspace = true [target.'cfg(windows)'.dependencies] async-std = { version = "1.12.0", features = ["unstable"] } diff --git a/crates/copilot/src/copilot.rs b/crates/copilot/src/copilot.rs index 4e6520906074c1384a4e500d89be43659c162718..a6963296f5c0ce0395698d2952618123c103ff55 100644 --- a/crates/copilot/src/copilot.rs +++ b/crates/copilot/src/copilot.rs @@ -4,7 +4,8 @@ pub mod copilot_responses; pub mod request; mod sign_in; -use crate::sign_in::initiate_sign_in_within_workspace; +use crate::request::NextEditSuggestions; +use crate::sign_in::initiate_sign_out; use ::fs::Fs; use anyhow::{Context as _, Result, anyhow}; use collections::{HashMap, HashSet}; @@ -18,7 +19,7 @@ use http_client::HttpClient; use language::language_settings::CopilotSettings; use language::{ Anchor, Bias, Buffer, BufferSnapshot, Language, PointUtf16, ToPointUtf16, - language_settings::{EditPredictionProvider, all_language_settings, language_settings}, + language_settings::{EditPredictionProvider, all_language_settings}, point_from_lsp, point_to_lsp, }; use lsp::{LanguageServer, LanguageServerBinary, LanguageServerId, LanguageServerName}; @@ -28,12 +29,10 @@ use project::DisableAiSettings; use request::StatusNotification; use semver::Version; use serde_json::json; -use settings::Settings; -use settings::SettingsStore; -use sign_in::{reinstall_and_sign_in_within_workspace, sign_out_within_workspace}; -use std::collections::hash_map::Entry; +use settings::{Settings, SettingsStore}; use std::{ any::TypeId, + collections::hash_map::Entry, env, ffi::OsString, mem, @@ -42,12 +41,14 @@ use std::{ sync::Arc, }; use sum_tree::Dimensions; -use util::rel_path::RelPath; use util::{ResultExt, fs::remove_matching}; use workspace::Workspace; pub use crate::copilot_edit_prediction_delegate::CopilotEditPredictionDelegate; -pub use crate::sign_in::{CopilotCodeVerification, initiate_sign_in, reinstall_and_sign_in}; +pub use crate::sign_in::{ + ConfigurationMode, ConfigurationView, CopilotCodeVerification, initiate_sign_in, + reinstall_and_sign_in, +}; actions!( copilot, @@ -98,21 +99,14 @@ pub fn init( .detach(); cx.observe_new(|workspace: &mut Workspace, _window, _cx| { - workspace.register_action(|workspace, _: &SignIn, window, cx| { - if let Some(copilot) = Copilot::global(cx) { - let is_reinstall = false; - initiate_sign_in_within_workspace(workspace, copilot, is_reinstall, window, cx); - } + workspace.register_action(|_, _: &SignIn, window, cx| { + initiate_sign_in(window, cx); }); - workspace.register_action(|workspace, _: &Reinstall, window, cx| { - if let Some(copilot) = Copilot::global(cx) { - reinstall_and_sign_in_within_workspace(workspace, copilot, window, cx); - } + workspace.register_action(|_, _: &Reinstall, window, cx| { + reinstall_and_sign_in(window, cx); }); - workspace.register_action(|workspace, _: &SignOut, _window, cx| { - if let Some(copilot) = Copilot::global(cx) { - sign_out_within_workspace(workspace, copilot, cx); - } + workspace.register_action(|_, _: &SignOut, window, cx| { + initiate_sign_out(window, cx); }); }) .detach(); @@ -322,6 +316,15 @@ struct GlobalCopilot(Entity); impl Global for GlobalCopilot {} +/// Copilot's NextEditSuggestion response, with coordinates converted to Anchors. +struct CopilotEditPrediction { + buffer: Entity, + range: Range, + text: String, + command: Option, + snapshot: BufferSnapshot, +} + impl Copilot { pub fn global(cx: &App) -> Option> { cx.try_global::() @@ -375,7 +378,7 @@ impl Copilot { } } - fn start_copilot( + pub fn start_copilot( &mut self, check_edit_prediction_provider: bool, awaiting_sign_in_after_start: bool, @@ -563,6 +566,14 @@ impl Copilot { let server = start_language_server.await; this.update(cx, |this, cx| { cx.notify(); + + if env::var("ZED_FORCE_COPILOT_ERROR").is_ok() { + this.server = CopilotServer::Error( + "Forced error for testing (ZED_FORCE_COPILOT_ERROR)".into(), + ); + return; + } + match server { Ok((server, status)) => { this.server = CopilotServer::Running(RunningCopilotServer { @@ -584,7 +595,17 @@ impl Copilot { .ok(); } - pub(crate) fn sign_in(&mut self, cx: &mut Context) -> Task> { + pub fn is_authenticated(&self) -> bool { + return matches!( + self.server, + CopilotServer::Running(RunningCopilotServer { + sign_in_status: SignInStatus::Authorized, + .. + }) + ); + } + + pub fn sign_in(&mut self, cx: &mut Context) -> Task> { if let CopilotServer::Running(server) = &mut self.server { let task = match &server.sign_in_status { SignInStatus::Authorized => Task::ready(Ok(())).shared(), @@ -862,101 +883,19 @@ impl Copilot { } } - pub fn completions( - &mut self, - buffer: &Entity, - position: T, - cx: &mut Context, - ) -> Task>> - where - T: ToPointUtf16, - { - self.request_completions::(buffer, position, cx) - } - - pub fn completions_cycling( + pub(crate) fn completions( &mut self, buffer: &Entity, - position: T, - cx: &mut Context, - ) -> Task>> - where - T: ToPointUtf16, - { - self.request_completions::(buffer, position, cx) - } - - pub fn accept_completion( - &mut self, - completion: &Completion, + position: Anchor, cx: &mut Context, - ) -> Task> { - let server = match self.server.as_authenticated() { - Ok(server) => server, - Err(error) => return Task::ready(Err(error)), - }; - let request = - server - .lsp - .request::(request::NotifyAcceptedParams { - uuid: completion.uuid.clone(), - }); - cx.background_spawn(async move { - request - .await - .into_response() - .context("copilot: notify accepted")?; - Ok(()) - }) - } - - pub fn discard_completions( - &mut self, - completions: &[Completion], - cx: &mut Context, - ) -> Task> { - let server = match self.server.as_authenticated() { - Ok(server) => server, - Err(_) => return Task::ready(Ok(())), - }; - let request = - server - .lsp - .request::(request::NotifyRejectedParams { - uuids: completions - .iter() - .map(|completion| completion.uuid.clone()) - .collect(), - }); - cx.background_spawn(async move { - request - .await - .into_response() - .context("copilot: notify rejected")?; - Ok(()) - }) - } - - fn request_completions( - &mut self, - buffer: &Entity, - position: T, - cx: &mut Context, - ) -> Task>> - where - R: 'static - + lsp::request::Request< - Params = request::GetCompletionsParams, - Result = request::GetCompletionsResult, - >, - T: ToPointUtf16, - { + ) -> Task>> { self.register_buffer(buffer, cx); let server = match self.server.as_authenticated() { Ok(server) => server, Err(error) => return Task::ready(Err(error)), }; + let buffer_entity = buffer.clone(); let lsp = server.lsp.clone(); let registered_buffer = server .registered_buffers @@ -966,46 +905,31 @@ impl Copilot { let buffer = buffer.read(cx); let uri = registered_buffer.uri.clone(); let position = position.to_point_utf16(buffer); - let settings = language_settings( - buffer.language_at(position).map(|l| l.name()), - buffer.file(), - cx, - ); - let tab_size = settings.tab_size; - let hard_tabs = settings.hard_tabs; - let relative_path = buffer - .file() - .map_or(RelPath::empty().into(), |file| file.path().clone()); cx.background_spawn(async move { let (version, snapshot) = snapshot.await?; let result = lsp - .request::(request::GetCompletionsParams { - doc: request::GetCompletionsDocument { - uri, - tab_size: tab_size.into(), - indent_size: 1, - insert_spaces: !hard_tabs, - relative_path: relative_path.to_proto(), - position: point_to_lsp(position), - version: version.try_into().unwrap(), - }, + .request::(request::NextEditSuggestionsParams { + text_document: lsp::VersionedTextDocumentIdentifier { uri, version }, + position: point_to_lsp(position), }) .await .into_response() .context("copilot: get completions")?; let completions = result - .completions + .edits .into_iter() .map(|completion| { let start = snapshot .clip_point_utf16(point_from_lsp(completion.range.start), Bias::Left); let end = snapshot.clip_point_utf16(point_from_lsp(completion.range.end), Bias::Left); - Completion { - uuid: completion.uuid, + CopilotEditPrediction { + buffer: buffer_entity.clone(), range: snapshot.anchor_before(start)..snapshot.anchor_after(end), text: completion.text, + command: completion.command, + snapshot: snapshot.clone(), } }) .collect(); @@ -1013,6 +937,35 @@ impl Copilot { }) } + pub(crate) fn accept_completion( + &mut self, + completion: &CopilotEditPrediction, + cx: &mut Context, + ) -> Task> { + let server = match self.server.as_authenticated() { + Ok(server) => server, + Err(error) => return Task::ready(Err(error)), + }; + if let Some(command) = &completion.command { + let request = server + .lsp + .request::(lsp::ExecuteCommandParams { + command: command.command.clone(), + arguments: command.arguments.clone().unwrap_or_default(), + ..Default::default() + }); + cx.background_spawn(async move { + request + .await + .into_response() + .context("copilot: notify accepted")?; + Ok(()) + }) + } else { + Task::ready(Ok(())) + } + } + pub fn status(&self) -> Status { match &self.server { CopilotServer::Starting { task } => Status::Starting { task: task.clone() }, @@ -1235,7 +1188,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?; } @@ -1246,7 +1202,11 @@ async fn get_copilot_lsp(fs: Arc, node_runtime: NodeRuntime) -> anyhow:: mod tests { use super::*; use gpui::TestAppContext; - use util::{path, paths::PathStyle, rel_path::rel_path}; + use util::{ + path, + paths::PathStyle, + rel_path::{RelPath, rel_path}, + }; #[gpui::test(iterations = 10)] async fn test_buffer_management(cx: &mut TestAppContext) { diff --git a/crates/copilot/src/copilot_edit_prediction_delegate.rs b/crates/copilot/src/copilot_edit_prediction_delegate.rs index 961154dbeecad007f026f25eeac25de95d751d9e..514e135cb4c34f6a1f49687fcd413113f78f9eae 100644 --- a/crates/copilot/src/copilot_edit_prediction_delegate.rs +++ b/crates/copilot/src/copilot_edit_prediction_delegate.rs @@ -1,49 +1,29 @@ -use crate::{Completion, Copilot}; +use crate::{Copilot, CopilotEditPrediction}; use anyhow::Result; -use edit_prediction_types::{Direction, EditPrediction, EditPredictionDelegate}; -use gpui::{App, Context, Entity, EntityId, Task}; -use language::{Buffer, OffsetRangeExt, ToOffset, language_settings::AllLanguageSettings}; -use settings::Settings; -use std::{path::Path, time::Duration}; +use edit_prediction_types::{EditPrediction, EditPredictionDelegate, interpolate_edits}; +use gpui::{App, Context, Entity, Task}; +use language::{Anchor, Buffer, EditPreview, OffsetRangeExt}; +use std::{ops::Range, sync::Arc, time::Duration}; pub const COPILOT_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(75); pub struct CopilotEditPredictionDelegate { - cycled: bool, - buffer_id: Option, - completions: Vec, - active_completion_index: usize, - file_extension: Option, + completion: Option<(CopilotEditPrediction, EditPreview)>, pending_refresh: Option>>, - pending_cycling_refresh: Option>>, copilot: Entity, } impl CopilotEditPredictionDelegate { pub fn new(copilot: Entity) -> Self { Self { - cycled: false, - buffer_id: None, - completions: Vec::new(), - active_completion_index: 0, - file_extension: None, + completion: None, pending_refresh: None, - pending_cycling_refresh: None, copilot, } } - fn active_completion(&self) -> Option<&Completion> { - self.completions.get(self.active_completion_index) - } - - fn push_completion(&mut self, new_completion: Completion) { - for completion in &self.completions { - if completion.text == new_completion.text && completion.range == new_completion.range { - return; - } - } - self.completions.push(new_completion); + fn active_completion(&self) -> Option<&(CopilotEditPrediction, EditPreview)> { + self.completion.as_ref() } } @@ -64,12 +44,8 @@ impl EditPredictionDelegate for CopilotEditPredictionDelegate { true } - fn supports_jump_to_edit() -> bool { - false - } - fn is_refreshing(&self, _cx: &App) -> bool { - self.pending_refresh.is_some() && self.completions.is_empty() + self.pending_refresh.is_some() && self.completion.is_none() } fn is_enabled( @@ -102,160 +78,96 @@ impl EditPredictionDelegate for CopilotEditPredictionDelegate { })? .await?; - this.update(cx, |this, cx| { - if !completions.is_empty() { - this.cycled = false; + if let Some(mut completion) = completions.into_iter().next() + && let Some(trimmed_completion) = cx + .update(|cx| trim_completion(&completion, cx)) + .ok() + .flatten() + { + let preview = buffer + .update(cx, |this, cx| { + this.preview_edits(Arc::from(std::slice::from_ref(&trimmed_completion)), cx) + })? + .await; + this.update(cx, |this, cx| { this.pending_refresh = None; - this.pending_cycling_refresh = None; - this.completions.clear(); - this.active_completion_index = 0; - this.buffer_id = Some(buffer.entity_id()); - this.file_extension = buffer.read(cx).file().and_then(|file| { - Some( - Path::new(file.file_name(cx)) - .extension()? - .to_str()? - .to_string(), - ) - }); - - for completion in completions { - this.push_completion(completion); - } + completion.range = trimmed_completion.0; + completion.text = trimmed_completion.1.to_string(); + this.completion = Some((completion, preview)); + cx.notify(); - } - })?; + })?; + } Ok(()) })); } - fn cycle( - &mut self, - buffer: Entity, - cursor_position: language::Anchor, - direction: Direction, - cx: &mut Context, - ) { - if self.cycled { - match direction { - Direction::Prev => { - self.active_completion_index = if self.active_completion_index == 0 { - self.completions.len().saturating_sub(1) - } else { - self.active_completion_index - 1 - }; - } - Direction::Next => { - if self.completions.is_empty() { - self.active_completion_index = 0 - } else { - self.active_completion_index = - (self.active_completion_index + 1) % self.completions.len(); - } - } - } - - cx.notify(); - } else { - let copilot = self.copilot.clone(); - self.pending_cycling_refresh = Some(cx.spawn(async move |this, cx| { - let completions = copilot - .update(cx, |copilot, cx| { - copilot.completions_cycling(&buffer, cursor_position, cx) - })? - .await?; - - this.update(cx, |this, cx| { - this.cycled = true; - this.file_extension = buffer.read(cx).file().and_then(|file| { - Some( - Path::new(file.file_name(cx)) - .extension()? - .to_str()? - .to_string(), - ) - }); - for completion in completions { - this.push_completion(completion); - } - this.cycle(buffer, cursor_position, direction, cx); - })?; - - Ok(()) - })); - } - } - fn accept(&mut self, cx: &mut Context) { - if let Some(completion) = self.active_completion() { + if let Some((completion, _)) = self.active_completion() { self.copilot .update(cx, |copilot, cx| copilot.accept_completion(completion, cx)) .detach_and_log_err(cx); } } - fn discard(&mut self, cx: &mut Context) { - let settings = AllLanguageSettings::get_global(cx); - - let copilot_enabled = settings.show_edit_predictions(None, cx); - - if !copilot_enabled { - return; - } - - self.copilot - .update(cx, |copilot, cx| { - copilot.discard_completions(&self.completions, cx) - }) - .detach_and_log_err(cx); - } + fn discard(&mut self, _: &mut Context) {} fn suggest( &mut self, buffer: &Entity, - cursor_position: language::Anchor, + _: language::Anchor, cx: &mut Context, ) -> Option { let buffer_id = buffer.entity_id(); let buffer = buffer.read(cx); - let completion = self.active_completion()?; - if Some(buffer_id) != self.buffer_id + let (completion, edit_preview) = self.active_completion()?; + + if Some(buffer_id) != Some(completion.buffer.entity_id()) || !completion.range.start.is_valid(buffer) || !completion.range.end.is_valid(buffer) { return None; } + let edits = vec![( + completion.range.clone(), + Arc::from(completion.text.as_ref()), + )]; + let edits = interpolate_edits(&completion.snapshot, &buffer.snapshot(), &edits) + .filter(|edits| !edits.is_empty())?; + + Some(EditPrediction::Local { + id: None, + edits, + edit_preview: Some(edit_preview.clone()), + }) + } +} - let mut completion_range = completion.range.to_offset(buffer); - let prefix_len = common_prefix( - buffer.chars_for_range(completion_range.clone()), - completion.text.chars(), - ); - completion_range.start += prefix_len; - let suffix_len = common_prefix( - buffer.reversed_chars_for_range(completion_range.clone()), - completion.text[prefix_len..].chars().rev(), - ); - completion_range.end = completion_range.end.saturating_sub(suffix_len); - - if completion_range.is_empty() - && completion_range.start == cursor_position.to_offset(buffer) - { - let completion_text = &completion.text[prefix_len..completion.text.len() - suffix_len]; - if completion_text.trim().is_empty() { - None - } else { - let position = cursor_position.bias_right(buffer); - Some(EditPrediction::Local { - id: None, - edits: vec![(position..position, completion_text.into())], - edit_preview: None, - }) - } - } else { - None - } +fn trim_completion( + completion: &CopilotEditPrediction, + cx: &mut App, +) -> Option<(Range, Arc)> { + let buffer = completion.buffer.read(cx); + let mut completion_range = completion.range.to_offset(buffer); + let prefix_len = common_prefix( + buffer.chars_for_range(completion_range.clone()), + completion.text.chars(), + ); + completion_range.start += prefix_len; + let suffix_len = common_prefix( + buffer.reversed_chars_for_range(completion_range.clone()), + completion.text[prefix_len..].chars().rev(), + ); + completion_range.end = completion_range.end.saturating_sub(suffix_len); + let completion_text = &completion.text[prefix_len..completion.text.len() - suffix_len]; + if completion_text.trim().is_empty() { + None + } else { + let completion_range = + buffer.anchor_after(completion_range.start)..buffer.anchor_after(completion_range.end); + + Some((completion_range, Arc::from(completion_text))) } } @@ -269,6 +181,7 @@ fn common_prefix, T2: Iterator>(a: T1, b: #[cfg(test)] mod tests { use super::*; + use edit_prediction_types::EditPredictionGranularity; use editor::{ Editor, ExcerptRange, MultiBuffer, MultiBufferOffset, SelectionEffects, test::editor_lsp_test_context::EditorLspTestContext, @@ -281,6 +194,7 @@ mod tests { Point, language_settings::{CompletionSettingsContent, LspInsertMode, WordsCompletionMode}, }; + use lsp::Uri; use project::Project; use serde_json::json; use settings::{AllLanguageSettingsContent, SettingsStore}; @@ -336,12 +250,15 @@ mod tests { )); handle_copilot_completion_request( &copilot_lsp, - vec![crate::request::Completion { + vec![crate::request::NextEditSuggestion { text: "one.copilot1".into(), range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 4)), - ..Default::default() + command: None, + text_document: lsp::VersionedTextDocumentIdentifier { + uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(), + version: 0, + }, }], - vec![], ); executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); cx.update_editor(|editor, window, cx| { @@ -382,12 +299,15 @@ mod tests { )); handle_copilot_completion_request( &copilot_lsp, - vec![crate::request::Completion { + vec![crate::request::NextEditSuggestion { text: "one.copilot1".into(), range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 4)), - ..Default::default() + command: None, + text_document: lsp::VersionedTextDocumentIdentifier { + uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(), + version: 0, + }, }], - vec![], ); executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); cx.update_editor(|editor, _, cx| { @@ -411,12 +331,15 @@ mod tests { // After debouncing, new Copilot completions should be requested. handle_copilot_completion_request( &copilot_lsp, - vec![crate::request::Completion { + vec![crate::request::NextEditSuggestion { text: "one.copilot2".into(), range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 5)), - ..Default::default() + command: None, + text_document: lsp::VersionedTextDocumentIdentifier { + uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(), + version: 0, + }, }], - vec![], ); executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); cx.update_editor(|editor, window, cx| { @@ -478,45 +401,6 @@ mod tests { assert_eq!(editor.display_text(cx), "one.cop\ntwo\nthree\n"); assert_eq!(editor.text(cx), "one.cop\ntwo\nthree\n"); }); - - // Reset the editor to verify how suggestions behave when tabbing on leading indentation. - cx.update_editor(|editor, window, cx| { - editor.set_text("fn foo() {\n \n}", window, cx); - editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([Point::new(1, 2)..Point::new(1, 2)]) - }); - }); - handle_copilot_completion_request( - &copilot_lsp, - vec![crate::request::Completion { - text: " let x = 4;".into(), - range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 2)), - ..Default::default() - }], - vec![], - ); - - cx.update_editor(|editor, window, cx| { - editor.next_edit_prediction(&Default::default(), window, cx) - }); - executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); - cx.update_editor(|editor, window, cx| { - assert!(editor.has_active_edit_prediction()); - assert_eq!(editor.display_text(cx), "fn foo() {\n let x = 4;\n}"); - assert_eq!(editor.text(cx), "fn foo() {\n \n}"); - - // Tabbing inside of leading whitespace inserts indentation without accepting the suggestion. - editor.tab(&Default::default(), window, cx); - assert!(editor.has_active_edit_prediction()); - assert_eq!(editor.text(cx), "fn foo() {\n \n}"); - assert_eq!(editor.display_text(cx), "fn foo() {\n let x = 4;\n}"); - - // Using AcceptEditPrediction again accepts the suggestion. - editor.accept_edit_prediction(&Default::default(), window, cx); - assert!(!editor.has_active_edit_prediction()); - assert_eq!(editor.text(cx), "fn foo() {\n let x = 4;\n}"); - assert_eq!(editor.display_text(cx), "fn foo() {\n let x = 4;\n}"); - }); } #[gpui::test(iterations = 10)] @@ -569,25 +453,30 @@ mod tests { )); handle_copilot_completion_request( &copilot_lsp, - vec![crate::request::Completion { + vec![crate::request::NextEditSuggestion { text: "one.copilot1".into(), range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 4)), - ..Default::default() + command: None, + text_document: lsp::VersionedTextDocumentIdentifier { + uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(), + version: 0, + }, }], - vec![], ); executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); cx.update_editor(|editor, window, cx| { assert!(editor.has_active_edit_prediction()); // Accepting the first word of the suggestion should only accept the first word and still show the rest. - editor.accept_partial_edit_prediction(&Default::default(), window, cx); + editor.accept_partial_edit_prediction(EditPredictionGranularity::Word, window, cx); + assert!(editor.has_active_edit_prediction()); assert_eq!(editor.text(cx), "one.copilot\ntwo\nthree\n"); assert_eq!(editor.display_text(cx), "one.copilot1\ntwo\nthree\n"); // Accepting next word should accept the non-word and copilot suggestion should be gone - editor.accept_partial_edit_prediction(&Default::default(), window, cx); + editor.accept_partial_edit_prediction(EditPredictionGranularity::Word, window, cx); + assert!(!editor.has_active_edit_prediction()); assert_eq!(editor.text(cx), "one.copilot1\ntwo\nthree\n"); assert_eq!(editor.display_text(cx), "one.copilot1\ntwo\nthree\n"); @@ -611,19 +500,22 @@ mod tests { )); handle_copilot_completion_request( &copilot_lsp, - vec![crate::request::Completion { + vec![crate::request::NextEditSuggestion { text: "one.123. copilot\n 456".into(), range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 4)), - ..Default::default() + command: None, + text_document: lsp::VersionedTextDocumentIdentifier { + uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(), + version: 0, + }, }], - vec![], ); executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); cx.update_editor(|editor, window, cx| { assert!(editor.has_active_edit_prediction()); // Accepting the first word (non-word) of the suggestion should only accept the first word and still show the rest. - editor.accept_partial_edit_prediction(&Default::default(), window, cx); + editor.accept_partial_edit_prediction(EditPredictionGranularity::Word, window, cx); assert!(editor.has_active_edit_prediction()); assert_eq!(editor.text(cx), "one.123. \ntwo\nthree\n"); assert_eq!( @@ -632,7 +524,7 @@ mod tests { ); // Accepting next word should accept the next word and copilot suggestion should still exist - editor.accept_partial_edit_prediction(&Default::default(), window, cx); + editor.accept_partial_edit_prediction(EditPredictionGranularity::Word, window, cx); assert!(editor.has_active_edit_prediction()); assert_eq!(editor.text(cx), "one.123. copilot\ntwo\nthree\n"); assert_eq!( @@ -641,7 +533,7 @@ mod tests { ); // Accepting the whitespace should accept the non-word/whitespaces with newline and copilot suggestion should be gone - editor.accept_partial_edit_prediction(&Default::default(), window, cx); + editor.accept_partial_edit_prediction(EditPredictionGranularity::Word, window, cx); assert!(!editor.has_active_edit_prediction()); assert_eq!(editor.text(cx), "one.123. copilot\n 456\ntwo\nthree\n"); assert_eq!( @@ -683,15 +575,18 @@ mod tests { handle_copilot_completion_request( &copilot_lsp, - vec![crate::request::Completion { + vec![crate::request::NextEditSuggestion { text: "two.foo()".into(), range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 2)), - ..Default::default() + command: None, + text_document: lsp::VersionedTextDocumentIdentifier { + uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(), + version: 0, + }, }], - vec![], ); cx.update_editor(|editor, window, cx| { - editor.next_edit_prediction(&Default::default(), window, cx) + editor.show_edit_prediction(&Default::default(), window, cx) }); executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); cx.update_editor(|editor, window, cx| { @@ -700,15 +595,22 @@ mod tests { assert_eq!(editor.text(cx), "one\ntw\nthree\n"); editor.backspace(&Default::default(), window, cx); + }); + executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); + cx.run_until_parked(); + cx.update_editor(|editor, window, cx| { assert!(editor.has_active_edit_prediction()); assert_eq!(editor.display_text(cx), "one\ntwo.foo()\nthree\n"); assert_eq!(editor.text(cx), "one\nt\nthree\n"); editor.backspace(&Default::default(), window, cx); + }); + executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); + cx.run_until_parked(); + cx.update_editor(|editor, window, cx| { assert!(editor.has_active_edit_prediction()); assert_eq!(editor.display_text(cx), "one\ntwo.foo()\nthree\n"); assert_eq!(editor.text(cx), "one\n\nthree\n"); - // Deleting across the original suggestion range invalidates it. editor.backspace(&Default::default(), window, cx); assert!(!editor.has_active_edit_prediction()); @@ -750,7 +652,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)); @@ -762,19 +664,22 @@ mod tests { handle_copilot_completion_request( &copilot_lsp, - vec![crate::request::Completion { + vec![crate::request::NextEditSuggestion { text: "b = 2 + a".into(), range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 5)), - ..Default::default() + command: None, + text_document: lsp::VersionedTextDocumentIdentifier { + uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(), + version: 0, + }, }], - vec![], ); _ = editor.update(cx, |editor, window, cx| { // Ensure copilot suggestions are shown for the first excerpt. editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([Point::new(1, 5)..Point::new(1, 5)]) }); - editor.next_edit_prediction(&Default::default(), window, cx); + editor.show_edit_prediction(&Default::default(), window, cx); }); executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); _ = editor.update(cx, |editor, _, cx| { @@ -788,12 +693,15 @@ mod tests { handle_copilot_completion_request( &copilot_lsp, - vec![crate::request::Completion { + vec![crate::request::NextEditSuggestion { text: "d = 4 + c".into(), range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 6)), - ..Default::default() + command: None, + text_document: lsp::VersionedTextDocumentIdentifier { + uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(), + version: 0, + }, }], - vec![], ); _ = editor.update(cx, |editor, window, cx| { // Move to another excerpt, ensuring the suggestion gets cleared. @@ -870,15 +778,18 @@ mod tests { )); handle_copilot_completion_request( &copilot_lsp, - vec![crate::request::Completion { + vec![crate::request::NextEditSuggestion { text: "two.foo()".into(), range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 2)), - ..Default::default() + command: None, + text_document: lsp::VersionedTextDocumentIdentifier { + uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(), + version: 0, + }, }], - vec![], ); cx.update_editor(|editor, window, cx| { - editor.next_edit_prediction(&Default::default(), window, cx) + editor.show_edit_prediction(&Default::default(), window, cx) }); executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); cx.update_editor(|editor, _, cx| { @@ -900,12 +811,15 @@ mod tests { )); handle_copilot_completion_request( &copilot_lsp, - vec![crate::request::Completion { + vec![crate::request::NextEditSuggestion { text: "two.foo()".into(), range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 3)), - ..Default::default() + command: None, + text_document: lsp::VersionedTextDocumentIdentifier { + uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(), + version: 0, + }, }], - vec![], ); executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); cx.update_editor(|editor, _, cx| { @@ -927,12 +841,15 @@ mod tests { )); handle_copilot_completion_request( &copilot_lsp, - vec![crate::request::Completion { + vec![crate::request::NextEditSuggestion { text: "two.foo()".into(), range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 4)), - ..Default::default() + command: None, + text_document: lsp::VersionedTextDocumentIdentifier { + uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(), + version: 0, + }, }], - vec![], ); executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); cx.update_editor(|editor, _, cx| { @@ -997,7 +914,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)); @@ -1008,16 +925,20 @@ mod tests { .unwrap(); let mut copilot_requests = copilot_lsp - .set_request_handler::( + .set_request_handler::( move |_params, _cx| async move { - Ok(crate::request::GetCompletionsResult { - completions: vec![crate::request::Completion { + Ok(crate::request::NextEditSuggestionsResult { + edits: vec![crate::request::NextEditSuggestion { text: "next line".into(), range: lsp::Range::new( lsp::Position::new(1, 0), lsp::Position::new(1, 0), ), - ..Default::default() + command: None, + text_document: lsp::VersionedTextDocumentIdentifier { + uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(), + version: 0, + }, }], }) }, @@ -1046,23 +967,14 @@ mod tests { fn handle_copilot_completion_request( lsp: &lsp::FakeLanguageServer, - completions: Vec, - completions_cycling: Vec, + completions: Vec, ) { - lsp.set_request_handler::(move |_params, _cx| { - let completions = completions.clone(); - async move { - Ok(crate::request::GetCompletionsResult { - completions: completions.clone(), - }) - } - }); - lsp.set_request_handler::( + lsp.set_request_handler::( move |_params, _cx| { - let completions_cycling = completions_cycling.clone(); + let completions = completions.clone(); async move { - Ok(crate::request::GetCompletionsResult { - completions: completions_cycling.clone(), + Ok(crate::request::NextEditSuggestionsResult { + edits: completions.clone(), }) } }, diff --git a/crates/copilot/src/request.rs b/crates/copilot/src/request.rs index 85d6254dc060824a9b2686e8f53090fccb39980e..2f97fb72a42904b1fefdd3999f680fca12559ecd 100644 --- a/crates/copilot/src/request.rs +++ b/crates/copilot/src/request.rs @@ -1,3 +1,4 @@ +use lsp::VersionedTextDocumentIdentifier; use serde::{Deserialize, Serialize}; pub enum CheckStatus {} @@ -88,72 +89,6 @@ impl lsp::request::Request for SignOut { const METHOD: &'static str = "signOut"; } -pub enum GetCompletions {} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GetCompletionsParams { - pub doc: GetCompletionsDocument, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GetCompletionsDocument { - pub tab_size: u32, - pub indent_size: u32, - pub insert_spaces: bool, - pub uri: lsp::Uri, - pub relative_path: String, - pub position: lsp::Position, - pub version: usize, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GetCompletionsResult { - pub completions: Vec, -} - -#[derive(Clone, Debug, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Completion { - pub text: String, - pub position: lsp::Position, - pub uuid: String, - pub range: lsp::Range, - pub display_text: String, -} - -impl lsp::request::Request for GetCompletions { - type Params = GetCompletionsParams; - type Result = GetCompletionsResult; - const METHOD: &'static str = "getCompletions"; -} - -pub enum GetCompletionsCycling {} - -impl lsp::request::Request for GetCompletionsCycling { - type Params = GetCompletionsParams; - type Result = GetCompletionsResult; - const METHOD: &'static str = "getCompletionsCycling"; -} - -pub enum LogMessage {} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct LogMessageParams { - pub level: u8, - pub message: String, - pub metadata_str: String, - pub extra: Vec, -} - -impl lsp::notification::Notification for LogMessage { - type Params = LogMessageParams; - const METHOD: &'static str = "LogMessage"; -} - pub enum StatusNotification {} #[derive(Debug, Serialize, Deserialize)] @@ -223,3 +158,36 @@ impl lsp::request::Request for NotifyRejected { type Result = String; const METHOD: &'static str = "notifyRejected"; } + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NextEditSuggestions; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NextEditSuggestionsParams { + pub(crate) text_document: VersionedTextDocumentIdentifier, + pub(crate) position: lsp::Position, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NextEditSuggestion { + pub text: String, + pub text_document: VersionedTextDocumentIdentifier, + pub range: lsp::Range, + pub command: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NextEditSuggestionsResult { + pub edits: Vec, +} + +impl lsp::request::Request for NextEditSuggestions { + type Params = NextEditSuggestionsParams; + type Result = NextEditSuggestionsResult; + + const METHOD: &'static str = "textDocument/copilotInlineEdit"; +} diff --git a/crates/copilot/src/sign_in.rs b/crates/copilot/src/sign_in.rs index 464a114d4ea11bca5597a6a91fd831ade050baaa..4f71a34408e23f099d4d3c145d86af24e607e3c3 100644 --- a/crates/copilot/src/sign_in.rs +++ b/crates/copilot/src/sign_in.rs @@ -1,166 +1,159 @@ use crate::{Copilot, Status, request::PromptUserDeviceFlow}; +use anyhow::Context as _; use gpui::{ - Animation, AnimationExt, App, ClipboardItem, Context, DismissEvent, Element, Entity, - EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, MouseDownEvent, - ParentElement, Render, Styled, Subscription, Transformation, Window, div, percentage, svg, + App, ClipboardItem, Context, DismissEvent, Element, Entity, EventEmitter, FocusHandle, + Focusable, InteractiveElement, IntoElement, MouseDownEvent, ParentElement, Render, Styled, + Subscription, Window, WindowBounds, WindowOptions, div, point, }; -use std::time::Duration; -use ui::{Button, Label, Vector, VectorName, prelude::*}; +use ui::{ButtonLike, CommonAnimationExt, ConfiguredApiCard, Vector, VectorName, prelude::*}; +use url::Url; use util::ResultExt as _; -use workspace::notifications::NotificationId; -use workspace::{ModalView, Toast, Workspace}; +use workspace::{Toast, Workspace, notifications::NotificationId}; const COPILOT_SIGN_UP_URL: &str = "https://github.com/features/copilot"; +const ERROR_LABEL: &str = + "Copilot had issues starting. You can try reinstalling it and signing in again."; struct CopilotStatusToast; pub fn initiate_sign_in(window: &mut Window, cx: &mut App) { + let is_reinstall = false; + initiate_sign_in_impl(is_reinstall, window, cx) +} + +pub fn initiate_sign_out(window: &mut Window, cx: &mut App) { let Some(copilot) = Copilot::global(cx) else { return; }; - let Some(workspace) = window.root::().flatten() else { - return; - }; - workspace.update(cx, |workspace, cx| { - let is_reinstall = false; - initiate_sign_in_within_workspace(workspace, copilot, is_reinstall, window, cx) - }); + + copilot_toast(Some("Signing out of Copilot…"), window, cx); + + let sign_out_task = copilot.update(cx, |copilot, cx| copilot.sign_out(cx)); + window + .spawn(cx, async move |cx| match sign_out_task.await { + Ok(()) => { + cx.update(|window, cx| copilot_toast(Some("Signed out of Copilot"), window, cx)) + } + Err(err) => cx.update(|window, cx| { + if let Some(workspace) = window.root::().flatten() { + workspace.update(cx, |workspace, cx| { + workspace.show_error(&err, cx); + }) + } else { + log::error!("{:?}", err); + } + }), + }) + .detach(); } pub fn reinstall_and_sign_in(window: &mut Window, cx: &mut App) { let Some(copilot) = Copilot::global(cx) else { return; }; + let _ = copilot.update(cx, |copilot, cx| copilot.reinstall(cx)); + let is_reinstall = true; + initiate_sign_in_impl(is_reinstall, window, cx); +} + +fn open_copilot_code_verification_window(copilot: &Entity, window: &Window, cx: &mut App) { + let current_window_center = window.bounds().center(); + let height = px(450.); + let width = px(350.); + let window_bounds = WindowBounds::Windowed(gpui::bounds( + current_window_center - point(height / 2.0, width / 2.0), + gpui::size(height, width), + )); + cx.open_window( + WindowOptions { + kind: gpui::WindowKind::PopUp, + window_bounds: Some(window_bounds), + is_resizable: false, + is_movable: true, + titlebar: Some(gpui::TitlebarOptions { + appears_transparent: true, + ..Default::default() + }), + ..Default::default() + }, + |window, cx| cx.new(|cx| CopilotCodeVerification::new(&copilot, window, cx)), + ) + .context("Failed to open Copilot code verification window") + .log_err(); +} + +fn copilot_toast(message: Option<&'static str>, window: &Window, cx: &mut App) { + const NOTIFICATION_ID: NotificationId = NotificationId::unique::(); + let Some(workspace) = window.root::().flatten() else { return; }; - workspace.update(cx, |workspace, cx| { - reinstall_and_sign_in_within_workspace(workspace, copilot, window, cx); - }); -} -pub fn reinstall_and_sign_in_within_workspace( - workspace: &mut Workspace, - copilot: Entity, - window: &mut Window, - cx: &mut Context, -) { - let _ = copilot.update(cx, |copilot, cx| copilot.reinstall(cx)); - let is_reinstall = true; - initiate_sign_in_within_workspace(workspace, copilot, is_reinstall, window, cx); + workspace.update(cx, |workspace, cx| match message { + Some(message) => workspace.show_toast(Toast::new(NOTIFICATION_ID, message), cx), + None => workspace.dismiss_toast(&NOTIFICATION_ID, cx), + }); } -pub fn initiate_sign_in_within_workspace( - workspace: &mut Workspace, - copilot: Entity, - is_reinstall: bool, - window: &mut Window, - cx: &mut Context, -) { +pub fn initiate_sign_in_impl(is_reinstall: bool, window: &mut Window, cx: &mut App) { + let Some(copilot) = Copilot::global(cx) else { + return; + }; if matches!(copilot.read(cx).status(), Status::Disabled) { copilot.update(cx, |copilot, cx| copilot.start_copilot(false, true, cx)); } match copilot.read(cx).status() { Status::Starting { task } => { - workspace.show_toast( - Toast::new( - NotificationId::unique::(), - if is_reinstall { - "Copilot is reinstalling..." - } else { - "Copilot is starting..." - }, - ), + copilot_toast( + Some(if is_reinstall { + "Copilot is reinstalling…" + } else { + "Copilot is starting…" + }), + window, cx, ); - cx.spawn_in(window, async move |workspace, cx| { - task.await; - if let Some(copilot) = cx.update(|_window, cx| Copilot::global(cx)).ok().flatten() { - workspace - .update_in(cx, |workspace, window, cx| { - match copilot.read(cx).status() { - Status::Authorized => workspace.show_toast( - Toast::new( - NotificationId::unique::(), - "Copilot has started.", - ), - cx, - ), - _ => { - workspace.dismiss_toast( - &NotificationId::unique::(), - cx, - ); - copilot - .update(cx, |copilot, cx| copilot.sign_in(cx)) - .detach_and_log_err(cx); - workspace.toggle_modal(window, cx, |_, cx| { - CopilotCodeVerification::new(&copilot, cx) - }); - } + window + .spawn(cx, async move |cx| { + task.await; + cx.update(|window, cx| { + let Some(copilot) = Copilot::global(cx) else { + return; + }; + match copilot.read(cx).status() { + Status::Authorized => { + copilot_toast(Some("Copilot has started."), window, cx) } - }) - .log_err(); - } - }) - .detach(); + _ => { + copilot_toast(None, window, cx); + copilot + .update(cx, |copilot, cx| copilot.sign_in(cx)) + .detach_and_log_err(cx); + open_copilot_code_verification_window(&copilot, window, cx); + } + } + }) + .log_err(); + }) + .detach(); } _ => { copilot .update(cx, |copilot, cx| copilot.sign_in(cx)) .detach(); - workspace.toggle_modal(window, cx, |_, cx| { - CopilotCodeVerification::new(&copilot, cx) - }); + open_copilot_code_verification_window(&copilot, window, cx); } } } -pub fn sign_out_within_workspace( - workspace: &mut Workspace, - copilot: Entity, - cx: &mut Context, -) { - workspace.show_toast( - Toast::new( - NotificationId::unique::(), - "Signing out of Copilot...", - ), - cx, - ); - let sign_out_task = copilot.update(cx, |copilot, cx| copilot.sign_out(cx)); - cx.spawn(async move |workspace, cx| match sign_out_task.await { - Ok(()) => { - workspace - .update(cx, |workspace, cx| { - workspace.show_toast( - Toast::new( - NotificationId::unique::(), - "Signed out of Copilot.", - ), - cx, - ) - }) - .ok(); - } - Err(err) => { - workspace - .update(cx, |workspace, cx| { - workspace.show_error(&err, cx); - }) - .ok(); - } - }) - .detach(); -} - pub struct CopilotCodeVerification { status: Status, connect_clicked: bool, focus_handle: FocusHandle, copilot: Entity, _subscription: Subscription, + sign_up_url: Option, } impl Focusable for CopilotCodeVerification { @@ -170,29 +163,44 @@ impl Focusable for CopilotCodeVerification { } impl EventEmitter for CopilotCodeVerification {} -impl ModalView for CopilotCodeVerification { - fn on_before_dismiss( - &mut self, - _: &mut Window, - cx: &mut Context, - ) -> workspace::DismissDecision { - self.copilot.update(cx, |copilot, cx| { - if matches!(copilot.status(), Status::SigningIn { .. }) { - copilot.sign_out(cx).detach_and_log_err(cx); + +impl CopilotCodeVerification { + pub fn new(copilot: &Entity, window: &mut Window, cx: &mut Context) -> Self { + window.on_window_should_close(cx, |window, cx| { + if let Some(this) = window.root::().flatten() { + this.update(cx, |this, cx| { + this.before_dismiss(cx); + }); } + true }); - workspace::DismissDecision::Dismiss(true) - } -} + cx.subscribe_in( + &cx.entity(), + window, + |this, _, _: &DismissEvent, window, cx| { + window.remove_window(); + this.before_dismiss(cx); + }, + ) + .detach(); -impl CopilotCodeVerification { - pub fn new(copilot: &Entity, cx: &mut Context) -> Self { let status = copilot.read(cx).status(); + // Determine sign-up URL based on verification_uri domain if available + let sign_up_url = if let Status::SigningIn { + prompt: Some(ref prompt), + } = status + { + // Extract domain from verification_uri to construct sign-up URL + Self::get_sign_up_url_from_verification(&prompt.verification_uri) + } else { + None + }; Self { status, connect_clicked: false, focus_handle: cx.focus_handle(), copilot: copilot.clone(), + sign_up_url, _subscription: cx.observe(copilot, |this, copilot, cx| { let status = copilot.read(cx).status(); match status { @@ -206,54 +214,74 @@ impl CopilotCodeVerification { } pub fn set_status(&mut self, status: Status, cx: &mut Context) { + // Update sign-up URL if we have a new verification URI + if let Status::SigningIn { + prompt: Some(ref prompt), + } = status + { + self.sign_up_url = Self::get_sign_up_url_from_verification(&prompt.verification_uri); + } self.status = status; cx.notify(); } + fn get_sign_up_url_from_verification(verification_uri: &str) -> Option { + // Extract domain from verification URI using url crate + if let Ok(url) = Url::parse(verification_uri) + && let Some(host) = url.host_str() + && !host.contains("github.com") + { + // For GHE, construct URL from domain + Some(format!("https://{}/features/copilot", host)) + } else { + None + } + } + fn render_device_code(data: &PromptUserDeviceFlow, cx: &mut Context) -> impl IntoElement { let copied = cx .read_from_clipboard() .map(|item| item.text().as_ref() == Some(&data.user_code)) .unwrap_or(false); - h_flex() - .w_full() - .p_1() - .border_1() - .border_muted(cx) - .rounded_sm() - .cursor_pointer() - .justify_between() - .on_mouse_down(gpui::MouseButton::Left, { + + ButtonLike::new("copy-button") + .full_width() + .style(ButtonStyle::Tinted(ui::TintColor::Accent)) + .size(ButtonSize::Medium) + .child( + h_flex() + .w_full() + .p_1() + .justify_between() + .child(Label::new(data.user_code.clone())) + .child(Label::new(if copied { "Copied!" } else { "Copy" })), + ) + .on_click({ let user_code = data.user_code.clone(); move |_, window, cx| { cx.write_to_clipboard(ClipboardItem::new_string(user_code.clone())); window.refresh(); } }) - .child(div().flex_1().child(Label::new(data.user_code.clone()))) - .child(div().flex_none().px_1().child(Label::new(if copied { - "Copied!" - } else { - "Copy" - }))) } fn render_prompting_modal( connect_clicked: bool, data: &PromptUserDeviceFlow, - cx: &mut Context, ) -> impl Element { let connect_button_label = if connect_clicked { - "Waiting for connection..." + "Waiting for connection…" } else { "Connect to GitHub" }; + v_flex() .flex_1() - .gap_2() + .gap_2p5() .items_center() - .child(Headline::new("Use GitHub Copilot in Zed.").size(HeadlineSize::Large)) + .text_center() + .child(Headline::new("Use GitHub Copilot in Zed").size(HeadlineSize::Large)) .child( Label::new("Using Copilot requires an active subscription on GitHub.") .color(Color::Muted), @@ -261,110 +289,154 @@ impl CopilotCodeVerification { .child(Self::render_device_code(data, cx)) .child( Label::new("Paste this code into GitHub after clicking the button below.") - .size(ui::LabelSize::Small), - ) - .child( - Button::new("connect-button", connect_button_label) - .on_click({ - let verification_uri = data.verification_uri.clone(); - cx.listener(move |this, _, _window, cx| { - cx.open_url(&verification_uri); - this.connect_clicked = true; - }) - }) - .full_width() - .style(ButtonStyle::Filled), + .color(Color::Muted), ) .child( - Button::new("copilot-enable-cancel-button", "Cancel") - .full_width() - .on_click(cx.listener(|_, _, _, cx| { - cx.emit(DismissEvent); - })), + v_flex() + .w_full() + .gap_1() + .child( + Button::new("connect-button", connect_button_label) + .full_width() + .style(ButtonStyle::Outlined) + .size(ButtonSize::Medium) + .on_click({ + let verification_uri = data.verification_uri.clone(); + cx.listener(move |this, _, _window, cx| { + cx.open_url(&verification_uri); + this.connect_clicked = true; + }) + }), + ) + .child( + Button::new("copilot-enable-cancel-button", "Cancel") + .full_width() + .size(ButtonSize::Medium) + .on_click(cx.listener(|_, _, _, cx| { + cx.emit(DismissEvent); + })), + ), ) } fn render_enabled_modal(cx: &mut Context) -> impl Element { v_flex() .gap_2() + .text_center() + .justify_center() .child(Headline::new("Copilot Enabled!").size(HeadlineSize::Large)) - .child(Label::new( - "You can update your settings or sign out from the Copilot menu in the status bar.", - )) + .child(Label::new("You're all set to use GitHub Copilot.").color(Color::Muted)) .child( Button::new("copilot-enabled-done-button", "Done") .full_width() + .style(ButtonStyle::Outlined) + .size(ButtonSize::Medium) .on_click(cx.listener(|_, _, _, cx| cx.emit(DismissEvent))), ) } - fn render_unauthorized_modal(cx: &mut Context) -> impl Element { - v_flex() - .child(Headline::new("You must have an active GitHub Copilot subscription.").size(HeadlineSize::Large)) + fn render_unauthorized_modal(&self, cx: &mut Context) -> impl Element { + let sign_up_url = self + .sign_up_url + .as_deref() + .unwrap_or(COPILOT_SIGN_UP_URL) + .to_owned(); + let description = "Enable Copilot by connecting your existing license once you have subscribed or renewed your subscription."; - .child(Label::new( - "You can enable Copilot by connecting your existing license once you have subscribed or renewed your subscription.", - ).color(Color::Warning)) + v_flex() + .gap_2() + .text_center() + .justify_center() + .child( + Headline::new("You must have an active GitHub Copilot subscription.") + .size(HeadlineSize::Large), + ) + .child(Label::new(description).color(Color::Warning)) .child( Button::new("copilot-subscribe-button", "Subscribe on GitHub") .full_width() - .on_click(|_, _, cx| cx.open_url(COPILOT_SIGN_UP_URL)), + .style(ButtonStyle::Outlined) + .size(ButtonSize::Medium) + .on_click(move |_, _, cx| cx.open_url(&sign_up_url)), ) .child( Button::new("copilot-subscribe-cancel-button", "Cancel") .full_width() + .size(ButtonSize::Medium) .on_click(cx.listener(|_, _, _, cx| cx.emit(DismissEvent))), ) } - fn render_loading(window: &mut Window, _: &mut Context) -> impl Element { - let loading_icon = svg() - .size_8() - .path(IconName::ArrowCircle.path()) - .text_color(window.text_style().color) - .with_animation( - "icon_circle_arrow", - Animation::new(Duration::from_secs(2)).repeat(), - |svg, delta| svg.with_transformation(Transformation::rotate(percentage(delta))), - ); + fn render_error_modal(_cx: &mut Context) -> impl Element { + v_flex() + .gap_2() + .text_center() + .justify_center() + .child(Headline::new("An Error Happened").size(HeadlineSize::Large)) + .child(Label::new(ERROR_LABEL).color(Color::Muted)) + .child( + Button::new("copilot-subscribe-button", "Reinstall Copilot and Sign In") + .full_width() + .style(ButtonStyle::Outlined) + .size(ButtonSize::Medium) + .icon(IconName::Download) + .icon_color(Color::Muted) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .on_click(|_, window, cx| reinstall_and_sign_in(window, cx)), + ) + } - h_flex().justify_center().child(loading_icon) + fn before_dismiss( + &mut self, + cx: &mut Context<'_, CopilotCodeVerification>, + ) -> workspace::DismissDecision { + self.copilot.update(cx, |copilot, cx| { + if matches!(copilot.status(), Status::SigningIn { .. }) { + copilot.sign_out(cx).detach_and_log_err(cx); + } + }); + workspace::DismissDecision::Dismiss(true) } } impl Render for CopilotCodeVerification { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let prompt = match &self.status { - Status::SigningIn { prompt: None } => { - Self::render_loading(window, cx).into_any_element() - } + Status::SigningIn { prompt: None } => Icon::new(IconName::ArrowCircle) + .color(Color::Muted) + .with_rotate_animation(2) + .into_any_element(), Status::SigningIn { prompt: Some(prompt), } => Self::render_prompting_modal(self.connect_clicked, prompt, cx).into_any_element(), Status::Unauthorized => { self.connect_clicked = false; - Self::render_unauthorized_modal(cx).into_any_element() + self.render_unauthorized_modal(cx).into_any_element() } Status::Authorized => { self.connect_clicked = false; Self::render_enabled_modal(cx).into_any_element() } + Status::Error(..) => Self::render_error_modal(cx).into_any_element(), _ => div().into_any_element(), }; v_flex() - .id("copilot code verification") + .id("copilot_code_verification") .track_focus(&self.focus_handle(cx)) - .elevation_3(cx) - .w_96() - .items_center() - .p_4() + .size_full() + .px_4() + .py_8() .gap_2() + .items_center() + .justify_center() + .elevation_3(cx) .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.)) @@ -373,3 +445,243 @@ impl Render for CopilotCodeVerification { .child(prompt) } } + +pub struct ConfigurationView { + copilot_status: Option, + is_authenticated: fn(cx: &App) -> bool, + edit_prediction: bool, + _subscription: Option, +} + +pub enum ConfigurationMode { + Chat, + EditPrediction, +} + +impl ConfigurationView { + pub fn new( + is_authenticated: fn(cx: &App) -> bool, + mode: ConfigurationMode, + cx: &mut Context, + ) -> Self { + let copilot = Copilot::global(cx); + + Self { + copilot_status: copilot.as_ref().map(|copilot| copilot.read(cx).status()), + is_authenticated, + edit_prediction: matches!(mode, ConfigurationMode::EditPrediction), + _subscription: copilot.as_ref().map(|copilot| { + cx.observe(copilot, |this, model, cx| { + this.copilot_status = Some(model.read(cx).status()); + cx.notify(); + }) + }), + } + } +} + +impl ConfigurationView { + fn is_starting(&self) -> bool { + matches!(&self.copilot_status, Some(Status::Starting { .. })) + } + + fn is_signing_in(&self) -> bool { + matches!( + &self.copilot_status, + Some(Status::SigningIn { .. }) + | Some(Status::SignedOut { + awaiting_signing_in: true + }) + ) + } + + fn is_error(&self) -> bool { + matches!(&self.copilot_status, Some(Status::Error(_))) + } + + fn has_no_status(&self) -> bool { + self.copilot_status.is_none() + } + + fn loading_message(&self) -> Option { + if self.is_starting() { + Some("Starting Copilot…".into()) + } else if self.is_signing_in() { + Some("Signing into Copilot…".into()) + } else { + None + } + } + + fn render_loading_button( + &self, + label: impl Into, + edit_prediction: bool, + ) -> impl IntoElement { + ButtonLike::new("loading_button") + .disabled(true) + .style(ButtonStyle::Outlined) + .when(edit_prediction, |this| this.size(ButtonSize::Medium)) + .child( + h_flex() + .w_full() + .gap_1() + .justify_center() + .child( + Icon::new(IconName::ArrowCircle) + .size(IconSize::Small) + .color(Color::Muted) + .with_rotate_animation(4), + ) + .child(Label::new(label)), + ) + } + + fn render_sign_in_button(&self, edit_prediction: bool) -> impl IntoElement { + let label = if edit_prediction { + "Sign in to GitHub" + } else { + "Sign in to use GitHub Copilot" + }; + + Button::new("sign_in", label) + .map(|this| { + if edit_prediction { + this.size(ButtonSize::Medium) + } else { + this.full_width() + } + }) + .style(ButtonStyle::Outlined) + .icon(IconName::Github) + .icon_color(Color::Muted) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .on_click(|_, window, cx| initiate_sign_in(window, cx)) + } + + fn render_reinstall_button(&self, edit_prediction: bool) -> impl IntoElement { + let label = if edit_prediction { + "Reinstall and Sign in" + } else { + "Reinstall Copilot and Sign in" + }; + + Button::new("reinstall_and_sign_in", label) + .map(|this| { + if edit_prediction { + this.size(ButtonSize::Medium) + } else { + this.full_width() + } + }) + .style(ButtonStyle::Outlined) + .icon(IconName::Download) + .icon_color(Color::Muted) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .on_click(|_, window, cx| reinstall_and_sign_in(window, cx)) + } + + fn render_for_edit_prediction(&self) -> impl IntoElement { + let container = |description: SharedString, action: AnyElement| { + h_flex() + .pt_2p5() + .w_full() + .justify_between() + .child( + v_flex() + .w_full() + .max_w_1_2() + .child(Label::new("Authenticate To Use")) + .child( + Label::new(description) + .color(Color::Muted) + .size(LabelSize::Small), + ), + ) + .child(action) + }; + + let start_label = "To use Copilot for edit predictions, you need to be logged in to GitHub. Note that your GitHub account must have an active Copilot subscription.".into(); + let no_status_label = "Copilot requires an active GitHub Copilot subscription. Please ensure Copilot is configured and try again, or use a different edit predictions provider.".into(); + + if let Some(msg) = self.loading_message() { + container( + start_label, + self.render_loading_button(msg, true).into_any_element(), + ) + .into_any_element() + } else if self.is_error() { + container( + ERROR_LABEL.into(), + self.render_reinstall_button(true).into_any_element(), + ) + .into_any_element() + } else if self.has_no_status() { + container( + no_status_label, + self.render_sign_in_button(true).into_any_element(), + ) + .into_any_element() + } else { + container( + start_label, + self.render_sign_in_button(true).into_any_element(), + ) + .into_any_element() + } + } + + fn render_for_chat(&self) -> impl IntoElement { + let start_label = "To use Zed's agent with GitHub Copilot, you need to be logged in to GitHub. Note that your GitHub account must have an active Copilot Chat subscription."; + let no_status_label = "Copilot Chat requires an active GitHub Copilot subscription. Please ensure Copilot is configured and try again, or use a different LLM provider."; + + if let Some(msg) = self.loading_message() { + v_flex() + .gap_2() + .child(Label::new(start_label)) + .child(self.render_loading_button(msg, false)) + .into_any_element() + } else if self.is_error() { + v_flex() + .gap_2() + .child(Label::new(ERROR_LABEL)) + .child(self.render_reinstall_button(false)) + .into_any_element() + } else if self.has_no_status() { + v_flex() + .gap_2() + .child(Label::new(no_status_label)) + .child(self.render_sign_in_button(false)) + .into_any_element() + } else { + v_flex() + .gap_2() + .child(Label::new(start_label)) + .child(self.render_sign_in_button(false)) + .into_any_element() + } + } +} + +impl Render for ConfigurationView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let is_authenticated = self.is_authenticated; + + if is_authenticated(cx) { + return ConfiguredApiCard::new("Authorized") + .button_label("Sign Out") + .on_click(|_, window, cx| { + initiate_sign_out(window, cx); + }) + .into_any_element(); + } + + if self.edit_prediction { + self.render_for_edit_prediction().into_any_element() + } else { + self.render_for_chat().into_any_element() + } + } +} diff --git a/crates/debugger_ui/src/debugger_panel.rs b/crates/debugger_ui/src/debugger_panel.rs index bdb308aafd0d2899f17bef732ac38239c4df6dda..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); @@ -1557,7 +1557,7 @@ impl Panel for DebugPanel { self.sessions_with_children.keys().for_each(|session_item| { session_item.update(cx, |item, cx| { item.running_state() - .update(cx, |state, _| state.invert_axies()) + .update(cx, |state, cx| state.invert_axies(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 66e9dd7b434e628898add7056b15c1789e32519c..422207d3cbf4880e0c8e3c02e01dbe373800ea62 100644 --- a/crates/debugger_ui/src/session/running.rs +++ b/crates/debugger_ui/src/session/running.rs @@ -348,7 +348,7 @@ pub(crate) fn new_debugger_pane( debug_assert!(_previous_subscription.is_none()); running .panes - .split(&this_pane, &new_pane, split_direction)?; + .split(&this_pane, &new_pane, split_direction, cx)?; anyhow::Ok(new_pane) }) }) @@ -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); } }); @@ -1462,7 +1462,7 @@ impl RunningState { this.serialize_layout(window, cx); match event { Event::Remove { .. } => { - let _did_find_pane = this.panes.remove(source_pane).is_ok(); + let _did_find_pane = this.panes.remove(source_pane, cx).is_ok(); debug_assert!(_did_find_pane); cx.notify(); } @@ -1889,9 +1889,9 @@ impl RunningState { Member::Axis(group_root) } - pub(crate) fn invert_axies(&mut self) { + pub(crate) fn invert_axies(&mut self, cx: &mut App) { self.dock_axis = self.dock_axis.invert(); - self.panes.invert_axies(); + self.panes.invert_axies(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/deepseek/src/deepseek.rs b/crates/deepseek/src/deepseek.rs index e978aa08048bfa4c7b7b203ce6b405ba8a0a7d0c..636258a5a132ce79cb5d15b1aaa25d6e4d3af643 100644 --- a/crates/deepseek/src/deepseek.rs +++ b/crates/deepseek/src/deepseek.rs @@ -103,8 +103,9 @@ impl Model { pub fn max_output_tokens(&self) -> Option { match self { - Self::Chat => Some(8_192), - Self::Reasoner => Some(64_000), + // Their API treats this max against the context window, which means we hit the limit a lot + // Using the default value of None in the API instead + Self::Chat | Self::Reasoner => None, Self::Custom { max_output_tokens, .. } => *max_output_tokens, diff --git a/crates/diagnostics/src/buffer_diagnostics.rs b/crates/diagnostics/src/buffer_diagnostics.rs index 0fd8783dd514f8da3c53d41dcb6f8e9004ae501c..ba10f6fbdabf05a095a7fed7c6ae682d4dc177c7 100644 --- a/crates/diagnostics/src/buffer_diagnostics.rs +++ b/crates/diagnostics/src/buffer_diagnostics.rs @@ -6,7 +6,7 @@ use crate::{ use anyhow::Result; use collections::HashMap; use editor::{ - Editor, EditorEvent, ExcerptRange, MultiBuffer, PathKey, + Editor, EditorEvent, EditorSettings, ExcerptRange, MultiBuffer, PathKey, display_map::{BlockPlacement, BlockProperties, BlockStyle, CustomBlockId}, multibuffer_context_lines, }; @@ -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) } } @@ -701,8 +701,12 @@ impl Item for BufferDiagnosticsEditor { }); } - fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation { - ToolbarItemLocation::PrimaryLeft + fn breadcrumb_location(&self, cx: &App) -> ToolbarItemLocation { + if EditorSettings::get_global(cx).toolbar.breadcrumbs { + ToolbarItemLocation::PrimaryLeft + } else { + ToolbarItemLocation::Hidden + } } fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option> { 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 58babbd251416118947362fae0a47a80cc277695..d85eb07f68619e15bfe44d26282db3a3e49df4f3 100644 --- a/crates/diagnostics/src/diagnostics.rs +++ b/crates/diagnostics/src/diagnostics.rs @@ -12,7 +12,7 @@ use buffer_diagnostics::BufferDiagnosticsEditor; use collections::{BTreeSet, HashMap, HashSet}; use diagnostic_renderer::DiagnosticBlock; use editor::{ - Editor, EditorEvent, ExcerptRange, MultiBuffer, PathKey, + Editor, EditorEvent, EditorSettings, ExcerptRange, MultiBuffer, PathKey, display_map::{BlockPlacement, BlockProperties, BlockStyle, CustomBlockId}, multibuffer_context_lines, }; @@ -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); } } @@ -894,8 +894,12 @@ impl Item for ProjectDiagnosticsEditor { Some(Box::new(self.editor.clone())) } - fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation { - ToolbarItemLocation::PrimaryLeft + fn breadcrumb_location(&self, cx: &App) -> ToolbarItemLocation { + if EditorSettings::get_global(cx).toolbar.breadcrumbs { + ToolbarItemLocation::PrimaryLeft + } else { + ToolbarItemLocation::Hidden + } } fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option> { @@ -1045,54 +1049,47 @@ async fn heuristic_syntactic_expand( let node_range = node_start..node_end; let row_count = node_end.row - node_start.row + 1; let mut ancestor_range = None; - let reached_outline_node = cx.background_executor().scoped({ - let node_range = node_range.clone(); - let outline_range = outline_range.clone(); - let ancestor_range = &mut ancestor_range; - |scope| { - scope.spawn(async move { - // Stop if we've exceeded the row count or reached an outline node. Then, find the interval - // of node children which contains the query range. For example, this allows just returning - // the header of a declaration rather than the entire declaration. - if row_count > max_row_count || outline_range == Some(node_range.clone()) { - let mut cursor = node.walk(); - let mut included_child_start = None; - let mut included_child_end = None; - let mut previous_end = node_start; - if cursor.goto_first_child() { - loop { - let child_node = cursor.node(); - let child_range = - previous_end..Point::from_ts_point(child_node.end_position()); - if included_child_start.is_none() - && child_range.contains(&input_range.start) - { - included_child_start = Some(child_range.start); - } - if child_range.contains(&input_range.end) { - included_child_end = Some(child_range.end); - } - previous_end = child_range.end; - if !cursor.goto_next_sibling() { - break; - } + cx.background_executor() + .await_on_background(async { + // Stop if we've exceeded the row count or reached an outline node. Then, find the interval + // of node children which contains the query range. For example, this allows just returning + // the header of a declaration rather than the entire declaration. + if row_count > max_row_count || outline_range == Some(node_range.clone()) { + let mut cursor = node.walk(); + let mut included_child_start = None; + let mut included_child_end = None; + let mut previous_end = node_start; + if cursor.goto_first_child() { + loop { + let child_node = cursor.node(); + let child_range = + previous_end..Point::from_ts_point(child_node.end_position()); + if included_child_start.is_none() + && child_range.contains(&input_range.start) + { + included_child_start = Some(child_range.start); } - } - let end = included_child_end.unwrap_or(node_range.end); - if let Some(start) = included_child_start { - let row_count = end.row - start.row; - if row_count < max_row_count { - *ancestor_range = - Some(Some(RangeInclusive::new(start.row, end.row))); - return; + if child_range.contains(&input_range.end) { + included_child_end = Some(child_range.end); + } + previous_end = child_range.end; + if !cursor.goto_next_sibling() { + break; } } - *ancestor_range = Some(None); } - }) - } - }); - reached_outline_node.await; + let end = included_child_end.unwrap_or(node_range.end); + if let Some(start) = included_child_start { + let row_count = end.row - start.row; + if row_count < max_row_count { + ancestor_range = Some(Some(RangeInclusive::new(start.row, end.row))); + return; + } + } + ancestor_range = Some(None); + } + }) + .await; if let Some(node) = ancestor_range { return node; } diff --git a/crates/edit_prediction/Cargo.toml b/crates/edit_prediction/Cargo.toml index 6e62cfa6f038671d595c5671de147cdc2125064d..2d5fb36a581f7bd17bb76f79791c276c86c9c631 100644 --- a/crates/edit_prediction/Cargo.toml +++ b/crates/edit_prediction/Cargo.toml @@ -12,7 +12,7 @@ workspace = true path = "src/edit_prediction.rs" [features] -eval-support = [] +cli-support = [] [dependencies] ai_onboarding.workspace = true @@ -21,10 +21,8 @@ arrayvec.workspace = true brotli.workspace = true client.workspace = true cloud_llm_client.workspace = true -cloud_zeta2_prompt.workspace = true collections.workspace = true copilot.workspace = true -credentials_provider.workspace = true db.workspace = true edit_prediction_types.workspace = true edit_prediction_context.workspace = true @@ -43,6 +41,7 @@ open_ai.workspace = true postage.workspace = true pretty_assertions.workspace = true project.workspace = true +pulldown-cmark.workspace = true rand.workspace = true regex.workspace = true release_channel.workspace = true @@ -50,8 +49,6 @@ semver.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true -smol.workspace = true -strsim.workspace = true strum.workspace = true telemetry.workspace = true telemetry_events.workspace = true @@ -62,6 +59,7 @@ uuid.workspace = true workspace.workspace = true worktree.workspace = true zed_actions.workspace = true +zeta_prompt.workspace = true [dev-dependencies] clock = { workspace = true, features = ["test-support"] } diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index 141fff3063b83d7e0003fddd6b4eba2d213d5fd5..f5ea7590fcba97ee916af985824e21cdf4ea725f 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -1,14 +1,13 @@ use anyhow::Result; use arrayvec::ArrayVec; use client::{Client, EditPredictionUsage, UserStore}; -use cloud_llm_client::predict_edits_v3::{self, Event, PromptFormat}; +use cloud_llm_client::predict_edits_v3::{self, PromptFormat}; use cloud_llm_client::{ AcceptEditPredictionBody, EXPIRED_LLM_TOKEN_HEADER_NAME, EditPredictionRejectReason, EditPredictionRejection, MAX_EDIT_PREDICTION_REJECTIONS_PER_REQUEST, MINIMUM_REQUIRED_VERSION_HEADER_NAME, PredictEditsRequestTrigger, RejectEditPredictionsBodyRef, ZED_VERSION_HEADER_NAME, }; -use cloud_zeta2_prompt::DEFAULT_MAX_PROMPT_BYTES; use collections::{HashMap, HashSet}; use db::kvp::{Dismissable, KEY_VALUE_STORE}; use edit_prediction_context::EditPredictionExcerptOptions; @@ -16,20 +15,18 @@ use edit_prediction_context::{RelatedExcerptStore, RelatedExcerptStoreEvent, Rel use feature_flags::{FeatureFlag, FeatureFlagAppExt as _}; use futures::{ AsyncReadExt as _, FutureExt as _, StreamExt as _, - channel::{ - mpsc::{self, UnboundedReceiver}, - oneshot, - }, + channel::mpsc::{self, UnboundedReceiver}, select_biased, }; use gpui::BackgroundExecutor; +use gpui::http_client::Url; use gpui::{ App, AsyncApp, Entity, EntityId, Global, SharedString, Subscription, Task, WeakEntity, actions, http_client::{self, AsyncBody, Method}, prelude::*, }; use language::language_settings::all_language_settings; -use language::{Anchor, Buffer, File, Point, ToPoint}; +use language::{Anchor, Buffer, File, Point, TextBufferSnapshot, ToPoint}; use language::{BufferSnapshot, OffsetRangeExt}; use language_model::{LlmApiToken, RefreshLlmTokenListener}; use project::{Project, ProjectPath, WorktreeId}; @@ -51,15 +48,18 @@ use thiserror::Error; use util::{RangeExt as _, ResultExt as _}; use workspace::notifications::{ErrorMessagePrompt, NotificationId, show_app_notification}; -mod cursor_excerpt; +pub mod cursor_excerpt; +pub mod example_spec; mod license_detection; pub mod mercury; mod onboarding_modal; pub mod open_ai_response; mod prediction; pub mod sweep_ai; + +#[cfg(any(test, feature = "test-support", feature = "cli-support"))] pub mod udiff; -mod xml_edits; + mod zed_edit_prediction_delegate; pub mod zeta1; pub mod zeta2; @@ -72,9 +72,9 @@ use crate::mercury::Mercury; use crate::onboarding_modal::ZedPredictModal; pub use crate::prediction::EditPrediction; pub use crate::prediction::EditPredictionId; -pub use crate::prediction::EditPredictionInputs; use crate::prediction::EditPredictionResult; pub use crate::sweep_ai::SweepAi; +pub use language_model::ApiKeyState; pub use telemetry_events::EditPredictionRating; pub use zed_edit_prediction_delegate::ZedEditPredictionDelegate; @@ -91,6 +91,7 @@ actions!( /// Maximum number of events to track. const EVENT_COUNT_MAX: usize = 6; const CHANGE_GROUPING_LINE_SPAN: u32 = 8; +const LAST_CHANGE_GROUPING_TIME: Duration = Duration::from_secs(1); const ZED_PREDICT_DATA_COLLECTION_CHOICE: &str = "zed_predict_data_collection_choice"; const REJECT_REQUEST_DEBOUNCE: Duration = Duration::from_secs(15); @@ -112,7 +113,6 @@ pub const DEFAULT_OPTIONS: ZetaOptions = ZetaOptions { min_bytes: 128, target_before_cursor_over_total_bytes: 0.5, }, - max_prompt_bytes: DEFAULT_MAX_PROMPT_BYTES, prompt_format: PromptFormat::DEFAULT, }; @@ -128,15 +128,6 @@ static EDIT_PREDICTIONS_MODEL_ID: LazyLock = LazyLock::new(|| { } .to_string() }); -static PREDICT_EDITS_URL: LazyLock> = LazyLock::new(|| { - env::var("ZED_PREDICT_EDITS_URL").ok().or_else(|| { - if *USE_OLLAMA { - Some("http://localhost:11434/v1/chat/completions".into()) - } else { - None - } - }) -}); pub struct Zeta2FeatureFlag; @@ -162,8 +153,7 @@ pub struct EditPredictionStore { use_context: bool, options: ZetaOptions, update_required: bool, - debug_tx: Option>, - #[cfg(feature = "eval-support")] + #[cfg(feature = "cli-support")] eval_cache: Option>, edit_prediction_model: EditPredictionModel, pub sweep_ai: SweepAi, @@ -172,6 +162,7 @@ pub struct EditPredictionStore { reject_predictions_tx: mpsc::UnboundedSender, shown_predictions: VecDeque, rated_predictions: HashSet, + custom_predict_edits_url: Option>, } #[derive(Copy, Clone, Default, PartialEq, Eq)] @@ -183,10 +174,22 @@ pub enum EditPredictionModel { Mercury, } +pub struct EditPredictionModelInput { + project: Entity, + buffer: Entity, + snapshot: BufferSnapshot, + position: Anchor, + events: Vec>, + related_files: Arc<[RelatedFile]>, + recent_paths: VecDeque, + trigger: PredictEditsRequestTrigger, + diagnostic_search_range: Range, + debug_tx: Option>, +} + #[derive(Debug, Clone, PartialEq)] pub struct ZetaOptions { pub context: EditPredictionExcerptOptions, - pub max_prompt_bytes: usize, pub prompt_format: predict_edits_v3::PromptFormat, } @@ -194,7 +197,8 @@ pub struct ZetaOptions { pub enum DebugEvent { ContextRetrievalStarted(ContextRetrievalStartedDebugEvent), ContextRetrievalFinished(ContextRetrievalFinishedDebugEvent), - EditPredictionRequested(EditPredictionRequestedDebugEvent), + EditPredictionStarted(EditPredictionStartedDebugEvent), + EditPredictionFinished(EditPredictionFinishedDebugEvent), } #[derive(Debug)] @@ -212,27 +216,30 @@ pub struct ContextRetrievalFinishedDebugEvent { } #[derive(Debug)] -pub struct EditPredictionRequestedDebugEvent { - pub inputs: EditPredictionInputs, - pub retrieval_time: Duration, +pub struct EditPredictionStartedDebugEvent { + pub buffer: WeakEntity, + pub position: Anchor, + pub prompt: Option, +} + +#[derive(Debug)] +pub struct EditPredictionFinishedDebugEvent { pub buffer: WeakEntity, pub position: Anchor, - pub local_prompt: Result, - pub response_rx: oneshot::Receiver<(Result, Duration)>, + pub model_output: Option, } pub type RequestDebugInfo = predict_edits_v3::DebugInfo; struct ProjectState { - events: VecDeque>, + events: VecDeque>, last_event: Option, recent_paths: VecDeque, registered_buffers: HashMap, current_prediction: Option, next_pending_prediction_id: usize, pending_predictions: ArrayVec, - context_updates_tx: smol::channel::Sender<()>, - context_updates_rx: smol::channel::Receiver<()>, + debug_tx: Option>, last_prediction_refresh: Option<(EntityId, Instant)>, cancelled_predictions: HashSet, context: Entity, @@ -241,7 +248,7 @@ struct ProjectState { } impl ProjectState { - pub fn events(&self, cx: &App) -> Vec> { + pub fn events(&self, cx: &App) -> Vec> { self.events .iter() .cloned() @@ -253,6 +260,19 @@ impl ProjectState { .collect() } + pub fn events_split_by_pause(&self, cx: &App) -> Vec> { + self.events + .iter() + .cloned() + .chain(self.last_event.as_ref().iter().flat_map(|event| { + let (one, two) = event.split_by_pause(); + let one = one.finalize(&self.license_detection_watchers, cx); + let two = two.and_then(|two| two.finalize(&self.license_detection_watchers, cx)); + one.into_iter().chain(two) + })) + .collect() + } + fn cancel_pending_prediction( &mut self, pending_prediction: PendingPrediction, @@ -272,6 +292,18 @@ impl ProjectState { }) .detach() } + + fn active_buffer( + &self, + project: &Entity, + cx: &App, + ) -> Option<(Entity, Option)> { + let project = project.read(cx); + let active_path = project.path_for_entry(project.active_entry()?, cx)?; + let active_buffer = project.buffer_store().read(cx).get_by_path(&active_path)?; + let registered_buffer = self.registered_buffers.get(&active_buffer.entity_id())?; + Some((active_buffer, registered_buffer.last_position)) + } } #[derive(Debug, Clone)] @@ -361,14 +393,21 @@ impl std::ops::Deref for BufferEditPrediction<'_> { } struct RegisteredBuffer { - snapshot: BufferSnapshot, + file: Option>, + snapshot: TextBufferSnapshot, + last_position: Option, _subscriptions: [gpui::Subscription; 2], } +#[derive(Clone)] struct LastEvent { - old_snapshot: BufferSnapshot, - new_snapshot: BufferSnapshot, + old_snapshot: TextBufferSnapshot, + new_snapshot: TextBufferSnapshot, + old_file: Option>, + new_file: Option>, end_edit_anchor: Option, + snapshot_after_last_editing_pause: Option, + last_edit_time: Option, } impl LastEvent { @@ -376,27 +415,27 @@ impl LastEvent { &self, license_detection_watchers: &HashMap>, cx: &App, - ) -> Option> { - let path = buffer_path_with_id_fallback(&self.new_snapshot, cx); - let old_path = buffer_path_with_id_fallback(&self.old_snapshot, cx); - - let file = self.new_snapshot.file(); - let old_file = self.old_snapshot.file(); - - let in_open_source_repo = [file, old_file].iter().all(|file| { - file.is_some_and(|file| { - license_detection_watchers - .get(&file.worktree_id(cx)) - .is_some_and(|watcher| watcher.is_project_open_source()) - }) - }); + ) -> Option> { + let path = buffer_path_with_id_fallback(self.new_file.as_ref(), &self.new_snapshot, cx); + let old_path = buffer_path_with_id_fallback(self.old_file.as_ref(), &self.old_snapshot, cx); + + let in_open_source_repo = + [self.new_file.as_ref(), self.old_file.as_ref()] + .iter() + .all(|file| { + file.is_some_and(|file| { + license_detection_watchers + .get(&file.worktree_id(cx)) + .is_some_and(|watcher| watcher.is_project_open_source()) + }) + }); let diff = language::unified_diff(&self.old_snapshot.text(), &self.new_snapshot.text()); if path == old_path && diff.is_empty() { None } else { - Some(Arc::new(predict_edits_v3::Event::BufferChange { + Some(Arc::new(zeta_prompt::Event::BufferChange { old_path, path, diff, @@ -406,10 +445,42 @@ impl LastEvent { })) } } + + pub fn split_by_pause(&self) -> (LastEvent, Option) { + let Some(boundary_snapshot) = self.snapshot_after_last_editing_pause.as_ref() else { + return (self.clone(), None); + }; + + let before = LastEvent { + old_snapshot: self.old_snapshot.clone(), + new_snapshot: boundary_snapshot.clone(), + old_file: self.old_file.clone(), + new_file: self.new_file.clone(), + end_edit_anchor: self.end_edit_anchor, + snapshot_after_last_editing_pause: None, + last_edit_time: self.last_edit_time, + }; + + let after = LastEvent { + old_snapshot: boundary_snapshot.clone(), + new_snapshot: self.new_snapshot.clone(), + old_file: self.old_file.clone(), + new_file: self.new_file.clone(), + end_edit_anchor: self.end_edit_anchor, + snapshot_after_last_editing_pause: None, + last_edit_time: self.last_edit_time, + }; + + (before, Some(after)) + } } -fn buffer_path_with_id_fallback(snapshot: &BufferSnapshot, cx: &App) -> Arc { - if let Some(file) = snapshot.file() { +fn buffer_path_with_id_fallback( + file: Option<&Arc>, + snapshot: &TextBufferSnapshot, + cx: &App, +) -> Arc { + if let Some(file) = file { file.full_path(cx).into() } else { Path::new(&format!("untitled-{}", snapshot.remote_id())).into() @@ -481,8 +552,7 @@ impl EditPredictionStore { }, ), update_required: false, - debug_tx: None, - #[cfg(feature = "eval-support")] + #[cfg(feature = "cli-support")] eval_cache: None, edit_prediction_model: EditPredictionModel::Zeta2, sweep_ai: SweepAi::new(cx), @@ -491,6 +561,20 @@ impl EditPredictionStore { reject_predictions_tx: reject_tx, rated_predictions: Default::default(), shown_predictions: Default::default(), + custom_predict_edits_url: match env::var("ZED_PREDICT_EDITS_URL") { + Ok(custom_url) => Url::parse(&custom_url).log_err().map(Into::into), + Err(_) => { + if *USE_OLLAMA { + Some( + Url::parse("http://localhost:11434/v1/chat/completions") + .unwrap() + .into(), + ) + } else { + None + } + } + }, }; this.configure_context_retrieval(cx); @@ -509,39 +593,28 @@ impl EditPredictionStore { this } + #[cfg(test)] + pub fn set_custom_predict_edits_url(&mut self, url: Url) { + self.custom_predict_edits_url = Some(url.into()); + } + pub fn set_edit_prediction_model(&mut self, model: EditPredictionModel) { self.edit_prediction_model = model; } - pub fn has_sweep_api_token(&self) -> bool { - self.sweep_ai - .api_token - .clone() - .now_or_never() - .flatten() - .is_some() + pub fn has_sweep_api_token(&self, cx: &App) -> bool { + self.sweep_ai.api_token.read(cx).has_key() } - pub fn has_mercury_api_token(&self) -> bool { - self.mercury - .api_token - .clone() - .now_or_never() - .flatten() - .is_some() + pub fn has_mercury_api_token(&self, cx: &App) -> bool { + self.mercury.api_token.read(cx).has_key() } - #[cfg(feature = "eval-support")] + #[cfg(feature = "cli-support")] pub fn with_eval_cache(&mut self, cache: Arc) { self.eval_cache = Some(cache); } - pub fn debug_info(&mut self) -> mpsc::UnboundedReceiver { - let (debug_watch_tx, debug_watch_rx) = mpsc::unbounded(); - self.debug_tx = Some(debug_watch_tx); - debug_watch_rx - } - pub fn options(&self) -> &ZetaOptions { &self.options } @@ -560,15 +633,53 @@ impl EditPredictionStore { } } + pub fn clear_history_for_project(&mut self, project: &Entity) { + if let Some(project_state) = self.projects.get_mut(&project.entity_id()) { + project_state.events.clear(); + } + } + + pub fn edit_history_for_project( + &self, + project: &Entity, + cx: &App, + ) -> Vec> { + self.projects + .get(&project.entity_id()) + .map(|project_state| project_state.events(cx)) + .unwrap_or_default() + } + + pub fn edit_history_for_project_with_pause_split_last_event( + &self, + project: &Entity, + cx: &App, + ) -> Vec> { + self.projects + .get(&project.entity_id()) + .map(|project_state| project_state.events_split_by_pause(cx)) + .unwrap_or_default() + } + pub fn context_for_project<'a>( &'a self, project: &Entity, cx: &'a App, - ) -> &'a [RelatedFile] { + ) -> Arc<[RelatedFile]> { self.projects .get(&project.entity_id()) .map(|project| project.context.read(cx).related_files()) - .unwrap_or(&[]) + .unwrap_or_else(|| vec![].into()) + } + + pub fn context_for_project_with_buffers<'a>( + &'a self, + project: &Entity, + cx: &'a App, + ) -> Option)>> { + self.projects + .get(&project.entity_id()) + .map(|project| project.context.read(cx).related_files_with_buffers()) } pub fn usage(&self, cx: &App) -> Option { @@ -599,85 +710,21 @@ impl EditPredictionStore { cx: &mut Context, ) -> &mut ProjectState { let entity_id = project.entity_id(); - let (context_updates_tx, context_updates_rx) = smol::channel::unbounded(); self.projects .entry(entity_id) .or_insert_with(|| ProjectState { context: { let related_excerpt_store = cx.new(|cx| RelatedExcerptStore::new(project, cx)); - cx.subscribe( - &related_excerpt_store, - move |this, _, event, _| match event { - RelatedExcerptStoreEvent::StartedRefresh => { - if let Some(debug_tx) = this.debug_tx.clone() { - debug_tx - .unbounded_send(DebugEvent::ContextRetrievalStarted( - ContextRetrievalStartedDebugEvent { - project_entity_id: entity_id, - timestamp: Instant::now(), - search_prompt: String::new(), - }, - )) - .ok(); - } - } - RelatedExcerptStoreEvent::FinishedRefresh { - cache_hit_count, - cache_miss_count, - mean_definition_latency, - max_definition_latency, - } => { - if let Some(debug_tx) = this.debug_tx.clone() { - debug_tx - .unbounded_send(DebugEvent::ContextRetrievalFinished( - ContextRetrievalFinishedDebugEvent { - project_entity_id: entity_id, - timestamp: Instant::now(), - metadata: vec![ - ( - "Cache Hits", - format!( - "{}/{}", - cache_hit_count, - cache_hit_count + cache_miss_count - ) - .into(), - ), - ( - "Max LSP Time", - format!( - "{} ms", - max_definition_latency.as_millis() - ) - .into(), - ), - ( - "Mean LSP Time", - format!( - "{} ms", - mean_definition_latency.as_millis() - ) - .into(), - ), - ], - }, - )) - .ok(); - } - if let Some(project_state) = this.projects.get(&entity_id) { - project_state.context_updates_tx.send_blocking(()).ok(); - } - } - }, - ) + cx.subscribe(&related_excerpt_store, move |this, _, event, _| { + this.handle_excerpt_store_event(entity_id, event); + }) .detach(); related_excerpt_store }, events: VecDeque::new(), last_event: None, recent_paths: VecDeque::new(), - context_updates_rx, - context_updates_tx, + debug_tx: None, registered_buffers: HashMap::default(), current_prediction: None, cancelled_predictions: HashSet::default(), @@ -689,12 +736,79 @@ impl EditPredictionStore { }) } - pub fn project_context_updates( - &self, + pub fn remove_project(&mut self, project: &Entity) { + self.projects.remove(&project.entity_id()); + } + + fn handle_excerpt_store_event( + &mut self, + project_entity_id: EntityId, + event: &RelatedExcerptStoreEvent, + ) { + if let Some(project_state) = self.projects.get(&project_entity_id) { + if let Some(debug_tx) = project_state.debug_tx.clone() { + match event { + RelatedExcerptStoreEvent::StartedRefresh => { + debug_tx + .unbounded_send(DebugEvent::ContextRetrievalStarted( + ContextRetrievalStartedDebugEvent { + project_entity_id: project_entity_id, + timestamp: Instant::now(), + search_prompt: String::new(), + }, + )) + .ok(); + } + RelatedExcerptStoreEvent::FinishedRefresh { + cache_hit_count, + cache_miss_count, + mean_definition_latency, + max_definition_latency, + } => { + debug_tx + .unbounded_send(DebugEvent::ContextRetrievalFinished( + ContextRetrievalFinishedDebugEvent { + project_entity_id: project_entity_id, + timestamp: Instant::now(), + metadata: vec![ + ( + "Cache Hits", + format!( + "{}/{}", + cache_hit_count, + cache_hit_count + cache_miss_count + ) + .into(), + ), + ( + "Max LSP Time", + format!("{} ms", max_definition_latency.as_millis()) + .into(), + ), + ( + "Mean LSP Time", + format!("{} ms", mean_definition_latency.as_millis()) + .into(), + ), + ], + }, + )) + .ok(); + } + } + } + } + } + + pub fn debug_info( + &mut self, project: &Entity, - ) -> Option> { - let project_state = self.projects.get(&project.entity_id())?; - Some(project_state.context_updates_rx.clone()) + cx: &mut Context, + ) -> mpsc::UnboundedReceiver { + let project_state = self.get_or_init_project(project, cx); + let (debug_watch_tx, debug_watch_rx) = mpsc::unbounded(); + project_state.debug_tx = Some(debug_watch_tx); + debug_watch_rx } fn handle_project_event( @@ -764,10 +878,14 @@ impl EditPredictionStore { match project_state.registered_buffers.entry(buffer_id) { hash_map::Entry::Occupied(entry) => entry.into_mut(), hash_map::Entry::Vacant(entry) => { - let snapshot = buffer.read(cx).snapshot(); + let buf = buffer.read(cx); + let snapshot = buf.text_snapshot(); + let file = buf.file().cloned(); let project_entity_id = project.entity_id(); entry.insert(RegisteredBuffer { snapshot, + file, + last_position: None, _subscriptions: [ cx.subscribe(buffer, { let project = project.downgrade(); @@ -801,11 +919,14 @@ impl EditPredictionStore { let project_state = self.get_or_init_project(project, cx); let registered_buffer = Self::register_buffer_impl(project_state, buffer, project, cx); - let new_snapshot = buffer.read(cx).snapshot(); + let buf = buffer.read(cx); + let new_file = buf.file().cloned(); + let new_snapshot = buf.text_snapshot(); if new_snapshot.version == registered_buffer.snapshot.version { return; } + let old_file = mem::replace(&mut registered_buffer.file, new_file.clone()); let old_snapshot = mem::replace(&mut registered_buffer.snapshot, new_snapshot.clone()); let end_edit_anchor = new_snapshot .anchored_edits_since::(&old_snapshot.version) @@ -813,20 +934,16 @@ impl EditPredictionStore { .map(|(_, range)| range.end); let events = &mut project_state.events; - if let Some(LastEvent { - new_snapshot: last_new_snapshot, - end_edit_anchor: last_end_edit_anchor, - .. - }) = project_state.last_event.as_mut() - { + let now = cx.background_executor().now(); + if let Some(last_event) = project_state.last_event.as_mut() { let is_next_snapshot_of_same_buffer = old_snapshot.remote_id() - == last_new_snapshot.remote_id() - && old_snapshot.version == last_new_snapshot.version; + == last_event.new_snapshot.remote_id() + && old_snapshot.version == last_event.new_snapshot.version; let should_coalesce = is_next_snapshot_of_same_buffer && end_edit_anchor .as_ref() - .zip(last_end_edit_anchor.as_ref()) + .zip(last_event.end_edit_anchor.as_ref()) .is_some_and(|(a, b)| { let a = a.to_point(&new_snapshot); let b = b.to_point(&new_snapshot); @@ -834,8 +951,18 @@ impl EditPredictionStore { }); if should_coalesce { - *last_end_edit_anchor = end_edit_anchor; - *last_new_snapshot = new_snapshot; + let pause_elapsed = last_event + .last_edit_time + .map(|t| now.duration_since(t) >= LAST_CHANGE_GROUPING_TIME) + .unwrap_or(false); + if pause_elapsed { + last_event.snapshot_after_last_editing_pause = + Some(last_event.new_snapshot.clone()); + } + + last_event.end_edit_anchor = end_edit_anchor; + last_event.new_snapshot = new_snapshot; + last_event.last_edit_time = Some(now); return; } } @@ -849,19 +976,31 @@ impl EditPredictionStore { } project_state.last_event = Some(LastEvent { + old_file, + new_file, old_snapshot, new_snapshot, end_edit_anchor, + snapshot_after_last_editing_pause: None, + last_edit_time: Some(now), }); } - fn current_prediction_for_buffer( - &self, + fn prediction_at( + &mut self, buffer: &Entity, + position: Option, project: &Entity, cx: &App, ) -> Option> { - let project_state = self.projects.get(&project.entity_id())?; + let project_state = self.projects.get_mut(&project.entity_id())?; + if let Some(position) = position + && let Some(buffer) = project_state + .registered_buffers + .get_mut(&buffer.entity_id()) + { + buffer.last_position = Some(position); + } let CurrentEditPrediction { requested_by, @@ -888,8 +1027,13 @@ impl EditPredictionStore { } fn accept_current_prediction(&mut self, project: &Entity, cx: &mut Context) { + let custom_accept_url = env::var("ZED_ACCEPT_PREDICTION_URL").ok(); match self.edit_prediction_model { - EditPredictionModel::Zeta1 | EditPredictionModel::Zeta2 => {} + EditPredictionModel::Zeta1 | EditPredictionModel::Zeta2 => { + if self.custom_predict_edits_url.is_some() && custom_accept_url.is_none() { + return; + } + } EditPredictionModel::Sweep | EditPredictionModel::Mercury => return, } @@ -909,12 +1053,15 @@ impl EditPredictionStore { let llm_token = self.llm_token.clone(); let app_version = AppVersion::global(cx); cx.spawn(async move |this, cx| { - let url = if let Ok(predict_edits_url) = env::var("ZED_ACCEPT_PREDICTION_URL") { - http_client::Url::parse(&predict_edits_url)? + let (url, require_auth) = if let Some(accept_edits_url) = custom_accept_url { + (http_client::Url::parse(&accept_edits_url)?, false) } else { - client - .http_client() - .build_zed_llm_url("/predict_edits/accept", &[])? + ( + client + .http_client() + .build_zed_llm_url("/predict_edits/accept", &[])?, + true, + ) }; let response = cx @@ -931,6 +1078,7 @@ impl EditPredictionStore { client, llm_token, app_version, + require_auth, )) .await; @@ -989,6 +1137,7 @@ impl EditPredictionStore { client.clone(), llm_token.clone(), app_version.clone(), + true, ) .await; @@ -1034,7 +1183,11 @@ impl EditPredictionStore { was_shown: bool, ) { match self.edit_prediction_model { - EditPredictionModel::Zeta1 | EditPredictionModel::Zeta2 => {} + EditPredictionModel::Zeta1 | EditPredictionModel::Zeta2 => { + if self.custom_predict_edits_url.is_some() { + return; + } + } EditPredictionModel::Sweep | EditPredictionModel::Mercury => return, } @@ -1104,12 +1257,21 @@ impl EditPredictionStore { }; self.queue_prediction_refresh(project.clone(), project.entity_id(), cx, move |this, cx| { - let Some(open_buffer_task) = project - .update(cx, |project, cx| { - project - .active_entry() - .and_then(|entry| project.path_for_entry(entry, cx)) - .map(|path| project.open_buffer(path, cx)) + let Some((active_buffer, snapshot, cursor_point)) = this + .read_with(cx, |this, cx| { + let project_state = this.projects.get(&project.entity_id())?; + let (buffer, position) = project_state.active_buffer(&project, cx)?; + let snapshot = buffer.read(cx).snapshot(); + + if !Self::predictions_enabled_at(&snapshot, position, cx) { + return None; + } + + let cursor_point = position + .map(|pos| pos.to_point(&snapshot)) + .unwrap_or_default(); + + Some((buffer, snapshot, cursor_point)) }) .log_err() .flatten() @@ -1118,14 +1280,11 @@ impl EditPredictionStore { }; cx.spawn(async move |cx| { - let active_buffer = open_buffer_task.await?; - let snapshot = active_buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?; - let Some((jump_buffer, jump_position)) = Self::next_diagnostic_location( active_buffer, &snapshot, Default::default(), - Default::default(), + cursor_point, &project, cx, ) @@ -1170,6 +1329,37 @@ impl EditPredictionStore { }); } + fn predictions_enabled_at( + snapshot: &BufferSnapshot, + position: Option, + cx: &App, + ) -> bool { + let file = snapshot.file(); + let all_settings = all_language_settings(file, cx); + if !all_settings.show_edit_predictions(snapshot.language(), cx) + || file.is_some_and(|file| !all_settings.edit_predictions_enabled_for_file(file, cx)) + { + return false; + } + + if let Some(last_position) = position { + let settings = snapshot.settings_at(last_position, cx); + + if !settings.edit_predictions_disabled_in.is_empty() + && let Some(scope) = snapshot.language_scope_at(last_position) + && let Some(scope_name) = scope.override_name() + && settings + .edit_predictions_disabled_in + .iter() + .any(|s| s == scope_name) + { + return false; + } + } + + true + } + #[cfg(not(test))] pub const THROTTLE_TIMEOUT: Duration = Duration::from_millis(300); #[cfg(test)] @@ -1348,6 +1538,7 @@ impl EditPredictionStore { let project_state = self.projects.get(&project.entity_id()).unwrap(); let events = project_state.events(cx); let has_events = !events.is_empty(); + let debug_tx = project_state.debug_tx.clone(); let snapshot = active_buffer.read(cx).snapshot(); let cursor_point = position.to_point(&snapshot); @@ -1357,55 +1548,29 @@ impl EditPredictionStore { Point::new(diagnostic_search_start, 0)..Point::new(diagnostic_search_end, 0); let related_files = if self.use_context { - self.context_for_project(&project, cx).to_vec() + self.context_for_project(&project, cx) } else { - Vec::new() + Vec::new().into() + }; + + let inputs = EditPredictionModelInput { + project: project.clone(), + buffer: active_buffer.clone(), + snapshot: snapshot.clone(), + position, + events, + related_files, + recent_paths: project_state.recent_paths.clone(), + trigger, + diagnostic_search_range: diagnostic_search_range.clone(), + debug_tx, }; let task = match self.edit_prediction_model { - EditPredictionModel::Zeta1 => zeta1::request_prediction_with_zeta1( - self, - &project, - &active_buffer, - snapshot.clone(), - position, - events, - trigger, - cx, - ), - EditPredictionModel::Zeta2 => zeta2::request_prediction_with_zeta2( - self, - &project, - &active_buffer, - snapshot.clone(), - position, - events, - related_files, - trigger, - cx, - ), - EditPredictionModel::Sweep => self.sweep_ai.request_prediction_with_sweep( - &project, - &active_buffer, - snapshot.clone(), - position, - events, - &project_state.recent_paths, - related_files, - diagnostic_search_range.clone(), - cx, - ), - EditPredictionModel::Mercury => self.mercury.request_prediction( - &project, - &active_buffer, - snapshot.clone(), - position, - events, - &project_state.recent_paths, - related_files, - diagnostic_search_range.clone(), - cx, - ), + EditPredictionModel::Zeta1 => zeta1::request_prediction_with_zeta1(self, inputs, cx), + EditPredictionModel::Zeta2 => zeta2::request_prediction_with_zeta2(self, inputs, cx), + EditPredictionModel::Sweep => self.sweep_ai.request_prediction_with_sweep(inputs, cx), + EditPredictionModel::Mercury => self.mercury.request_prediction(inputs, cx), }; cx.spawn(async move |this, cx| { @@ -1529,18 +1694,14 @@ impl EditPredictionStore { client: Arc, llm_token: LlmApiToken, app_version: Version, - #[cfg(feature = "eval-support")] eval_cache: Option>, - #[cfg(feature = "eval-support")] eval_cache_kind: EvalCacheEntryKind, + #[cfg(feature = "cli-support")] eval_cache: Option>, + #[cfg(feature = "cli-support")] eval_cache_kind: EvalCacheEntryKind, ) -> Result<(open_ai::Response, Option)> { - let url = if let Some(predict_edits_url) = PREDICT_EDITS_URL.as_ref() { - http_client::Url::parse(&predict_edits_url)? - } else { - client - .http_client() - .build_zed_llm_url("/predict_edits/raw", &[])? - }; + let url = client + .http_client() + .build_zed_llm_url("/predict_edits/raw", &[])?; - #[cfg(feature = "eval-support")] + #[cfg(feature = "cli-support")] let cache_key = if let Some(cache) = eval_cache { use collections::FxHasher; use std::hash::{Hash, Hasher}; @@ -1571,10 +1732,11 @@ impl EditPredictionStore { client, llm_token, app_version, + true, ) .await?; - #[cfg(feature = "eval-support")] + #[cfg(feature = "cli-support")] if let Some((cache, request, key)) = cache_key { cache.write(key, &request, &serde_json::to_string_pretty(&response)?); } @@ -1631,23 +1793,34 @@ impl EditPredictionStore { client: Arc, llm_token: LlmApiToken, app_version: Version, + require_auth: bool, ) -> Result<(Res, Option)> where Res: DeserializeOwned, { let http_client = client.http_client(); - let mut token = llm_token.acquire(&client).await?; + + let mut token = if require_auth { + Some(llm_token.acquire(&client).await?) + } else { + llm_token.acquire(&client).await.ok() + }; let mut did_retry = false; loop { let request_builder = http_client::Request::builder().method(Method::POST); - let request = build( - request_builder - .header("Content-Type", "application/json") - .header("Authorization", format!("Bearer {}", token)) - .header(ZED_VERSION_HEADER_NAME, app_version.to_string()), - )?; + let mut request_builder = request_builder + .header("Content-Type", "application/json") + .header(ZED_VERSION_HEADER_NAME, app_version.to_string()); + + // Only add Authorization header if we have a token + if let Some(ref token_value) = token { + request_builder = + request_builder.header("Authorization", format!("Bearer {}", token_value)); + } + + let request = build(request_builder)?; let mut response = http_client.send(request).await?; @@ -1671,13 +1844,14 @@ impl EditPredictionStore { response.body_mut().read_to_end(&mut body).await?; return Ok((serde_json::from_slice(&body)?, usage)); } else if !did_retry + && token.is_some() && response .headers() .get(EXPIRED_LLM_TOKEN_HEADER_NAME) .is_some() { did_retry = true; - token = llm_token.refresh(&client).await?; + token = Some(llm_token.refresh(&client).await?); } else { let mut body = String::new(); response.body_mut().read_to_string(&mut body).await?; @@ -1706,6 +1880,20 @@ impl EditPredictionStore { } } + #[cfg(feature = "cli-support")] + pub fn set_context_for_buffer( + &mut self, + project: &Entity, + related_files: Vec, + cx: &mut Context, + ) { + self.get_or_init_project(project, cx) + .context + .update(cx, |store, _| { + store.set_related_files(related_files); + }); + } + fn is_file_open_source( &self, project: &Entity, @@ -1729,14 +1917,14 @@ impl EditPredictionStore { self.data_collection_choice.is_enabled() && self.is_file_open_source(project, file, cx) } - fn can_collect_events(&self, events: &[Arc]) -> bool { + fn can_collect_events(&self, events: &[Arc]) -> bool { if !self.data_collection_choice.is_enabled() { return false; } events.iter().all(|event| { matches!( event.as_ref(), - Event::BufferChange { + zeta_prompt::Event::BufferChange { in_open_source_repo: true, .. } @@ -1817,10 +2005,10 @@ pub struct ZedUpdateRequiredError { minimum_version: Version, } -#[cfg(feature = "eval-support")] +#[cfg(feature = "cli-support")] pub type EvalCacheKey = (EvalCacheEntryKind, u64); -#[cfg(feature = "eval-support")] +#[cfg(feature = "cli-support")] #[derive(Debug, Clone, Copy, PartialEq)] pub enum EvalCacheEntryKind { Context, @@ -1828,7 +2016,7 @@ pub enum EvalCacheEntryKind { Prediction, } -#[cfg(feature = "eval-support")] +#[cfg(feature = "cli-support")] impl std::fmt::Display for EvalCacheEntryKind { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -1839,7 +2027,7 @@ impl std::fmt::Display for EvalCacheEntryKind { } } -#[cfg(feature = "eval-support")] +#[cfg(feature = "cli-support")] pub trait EvalCache: Send + Sync { fn read(&self, key: EvalCacheKey) -> Option; fn write(&self, key: EvalCacheKey, input: &str, value: &str); diff --git a/crates/edit_prediction/src/edit_prediction_tests.rs b/crates/edit_prediction/src/edit_prediction_tests.rs index 0b7e289bb32b5a10c32a4bd34f118d7cb6c7d43c..eee3f1f79e93b60ee3ea7c80bd987af22d613833 100644 --- a/crates/edit_prediction/src/edit_prediction_tests.rs +++ b/crates/edit_prediction/src/edit_prediction_tests.rs @@ -1,5 +1,5 @@ use super::*; -use crate::zeta1::MAX_EVENT_TOKENS; +use crate::{udiff::apply_diff_to_string, zeta1::MAX_EVENT_TOKENS}; use client::{UserStore, test::FakeServer}; use clock::{FakeSystemClock, ReplicaId}; use cloud_api_types::{CreateLlmTokenResponse, LlmToken}; @@ -7,7 +7,6 @@ use cloud_llm_client::{ EditPredictionRejectReason, EditPredictionRejection, PredictEditsBody, PredictEditsResponse, RejectEditPredictionsBody, }; -use edit_prediction_context::Line; use futures::{ AsyncReadExt, StreamExt, channel::{mpsc, oneshot}, @@ -28,6 +27,7 @@ use settings::SettingsStore; use std::{path::Path, sync::Arc, time::Duration}; use util::{path, rel_path::rel_path}; use uuid::Uuid; +use zeta_prompt::ZetaPromptInput; use crate::{BufferEditPrediction, EditPredictionId, EditPredictionStore, REJECT_REQUEST_DEBOUNCE}; @@ -45,10 +45,6 @@ async fn test_current_state(cx: &mut TestAppContext) { .await; let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; - ep_store.update(cx, |ep_store, cx| { - ep_store.register_project(&project, cx); - }); - let buffer1 = project .update(cx, |project, cx| { let path = project.find_project_path(path!("/root/1.txt"), cx).unwrap(); @@ -60,30 +56,38 @@ async fn test_current_state(cx: &mut TestAppContext) { let snapshot1 = buffer1.read_with(cx, |buffer, _cx| buffer.snapshot()); let position = snapshot1.anchor_before(language::Point::new(1, 3)); + ep_store.update(cx, |ep_store, cx| { + ep_store.register_project(&project, cx); + ep_store.register_buffer(&buffer1, &project, cx); + }); + // Prediction for current file ep_store.update(cx, |ep_store, cx| { ep_store.refresh_prediction_from_buffer(project.clone(), buffer1.clone(), position, cx) }); - let (_request, respond_tx) = requests.predict.next().await.unwrap(); + let (request, respond_tx) = requests.predict.next().await.unwrap(); respond_tx - .send(model_response(indoc! {r" - --- a/root/1.txt - +++ b/root/1.txt - @@ ... @@ - Hello! - -How - +How are you? - Bye - "})) + .send(model_response( + request, + indoc! {r" + --- a/root/1.txt + +++ b/root/1.txt + @@ ... @@ + Hello! + -How + +How are you? + Bye + "}, + )) .unwrap(); cx.run_until_parked(); - ep_store.read_with(cx, |ep_store, cx| { + ep_store.update(cx, |ep_store, cx| { let prediction = ep_store - .current_prediction_for_buffer(&buffer1, &project, cx) + .prediction_at(&buffer1, None, &project, cx) .unwrap(); assert_matches!(prediction, BufferEditPrediction::Local { .. }); }); @@ -120,22 +124,26 @@ async fn test_current_state(cx: &mut TestAppContext) { }); }); - let (_request, respond_tx) = requests.predict.next().await.unwrap(); + let (request, respond_tx) = requests.predict.next().await.unwrap(); respond_tx - .send(model_response(indoc! {r#" - --- a/root/2.txt - +++ b/root/2.txt - Hola! - -Como - +Como estas? - Adios - "#})) + .send(model_response( + request, + indoc! {r#" + --- a/root/2.txt + +++ b/root/2.txt + @@ ... @@ + Hola! + -Como + +Como estas? + Adios + "#}, + )) .unwrap(); cx.run_until_parked(); - ep_store.read_with(cx, |ep_store, cx| { + ep_store.update(cx, |ep_store, cx| { let prediction = ep_store - .current_prediction_for_buffer(&buffer1, &project, cx) + .prediction_at(&buffer1, None, &project, cx) .unwrap(); assert_matches!( prediction, @@ -151,9 +159,9 @@ async fn test_current_state(cx: &mut TestAppContext) { .await .unwrap(); - ep_store.read_with(cx, |ep_store, cx| { + ep_store.update(cx, |ep_store, cx| { let prediction = ep_store - .current_prediction_for_buffer(&buffer2, &project, cx) + .prediction_at(&buffer2, None, &project, cx) .unwrap(); assert_matches!(prediction, BufferEditPrediction::Local { .. }); }); @@ -186,7 +194,7 @@ async fn test_simple_request(cx: &mut TestAppContext) { ep_store.request_prediction(&project, &buffer, position, Default::default(), cx) }); - let (_, respond_tx) = requests.predict.next().await.unwrap(); + let (request, respond_tx) = requests.predict.next().await.unwrap(); // TODO Put back when we have a structured request again // assert_eq!( @@ -202,15 +210,18 @@ async fn test_simple_request(cx: &mut TestAppContext) { // ); respond_tx - .send(model_response(indoc! { r" - --- a/root/foo.md - +++ b/root/foo.md - @@ ... @@ - Hello! - -How - +How are you? - Bye - "})) + .send(model_response( + request, + indoc! { r" + --- a/root/foo.md + +++ b/root/foo.md + @@ ... @@ + Hello! + -How + +How are you? + Bye + "}, + )) .unwrap(); let prediction = prediction_task.await.unwrap().unwrap().prediction.unwrap(); @@ -276,25 +287,119 @@ async fn test_request_events(cx: &mut TestAppContext) { ); respond_tx - .send(model_response(indoc! {r#" - --- a/root/foo.md - +++ b/root/foo.md - @@ ... @@ - Hello! - -How - +How are you? - Bye - "#})) + .send(model_response( + request, + indoc! {r#" + --- a/root/foo.md + +++ b/root/foo.md + @@ ... @@ + Hello! + -How + +How are you? + Bye + "#}, + )) .unwrap(); let prediction = prediction_task.await.unwrap().unwrap().prediction.unwrap(); assert_eq!(prediction.edits.len(), 1); + assert_eq!(prediction.edits[0].1.as_ref(), " are you?"); +} + +#[gpui::test] +async fn test_edit_history_getter_pause_splits_last_event(cx: &mut TestAppContext) { + let (ep_store, _requests) = init_test_with_fake_client(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "foo.md": "Hello!\n\nBye\n" + }), + ) + .await; + let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; + + let buffer = project + .update(cx, |project, cx| { + let path = project.find_project_path(path!("root/foo.md"), cx).unwrap(); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + + ep_store.update(cx, |ep_store, cx| { + ep_store.register_buffer(&buffer, &project, cx); + }); + + // First burst: insert "How" + buffer.update(cx, |buffer, cx| { + buffer.edit(vec![(7..7, "How")], None, cx); + }); + + // Simulate a pause longer than the grouping threshold (e.g. 500ms). + cx.executor().advance_clock(LAST_CHANGE_GROUPING_TIME * 2); + cx.run_until_parked(); + + // Second burst: append " are you?" immediately after "How" on the same line. + // + // Keeping both bursts on the same line ensures the existing line-span coalescing logic + // groups them into a single `LastEvent`, allowing the pause-split getter to return two diffs. + buffer.update(cx, |buffer, cx| { + buffer.edit(vec![(10..10, " are you?")], None, cx); + }); + + // A second edit shortly after the first post-pause edit ensures the last edit timestamp is + // advanced after the pause boundary is recorded, making pause-splitting deterministic. + buffer.update(cx, |buffer, cx| { + buffer.edit(vec![(19..19, "!")], None, cx); + }); + + // Without time-based splitting, there is one event. + let events = ep_store.update(cx, |ep_store, cx| { + ep_store.edit_history_for_project(&project, cx) + }); + assert_eq!(events.len(), 1); + let zeta_prompt::Event::BufferChange { diff, .. } = events[0].as_ref(); assert_eq!( - prediction.edits[0].0.to_point(&snapshot).start, - language::Point::new(1, 3) + diff.as_str(), + indoc! {" + @@ -1,3 +1,3 @@ + Hello! + - + +How are you?! + Bye + "} + ); + + // With time-based splitting, there are two distinct events. + let events = ep_store.update(cx, |ep_store, cx| { + ep_store.edit_history_for_project_with_pause_split_last_event(&project, cx) + }); + assert_eq!(events.len(), 2); + let zeta_prompt::Event::BufferChange { diff, .. } = events[0].as_ref(); + assert_eq!( + diff.as_str(), + indoc! {" + @@ -1,3 +1,3 @@ + Hello! + - + +How + Bye + "} + ); + + let zeta_prompt::Event::BufferChange { diff, .. } = events[1].as_ref(); + assert_eq!( + diff.as_str(), + indoc! {" + @@ -1,3 +1,3 @@ + Hello! + -How + +How are you?! + Bye + "} ); - assert_eq!(prediction.edits[0].1.as_ref(), " are you?"); } #[gpui::test] @@ -324,27 +429,17 @@ async fn test_empty_prediction(cx: &mut TestAppContext) { ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); }); - const NO_OP_DIFF: &str = indoc! { r" - --- a/root/foo.md - +++ b/root/foo.md - @@ ... @@ - Hello! - -How - +How - Bye - "}; - - let (_, respond_tx) = requests.predict.next().await.unwrap(); - let response = model_response(NO_OP_DIFF); + let (request, respond_tx) = requests.predict.next().await.unwrap(); + let response = model_response(request, ""); let id = response.id.clone(); respond_tx.send(response).unwrap(); cx.run_until_parked(); - ep_store.read_with(cx, |ep_store, cx| { + ep_store.update(cx, |ep_store, cx| { assert!( ep_store - .current_prediction_for_buffer(&buffer, &project, cx) + .prediction_at(&buffer, None, &project, cx) .is_none() ); }); @@ -389,22 +484,22 @@ async fn test_interpolated_empty(cx: &mut TestAppContext) { ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); }); - let (_, respond_tx) = requests.predict.next().await.unwrap(); + let (request, respond_tx) = requests.predict.next().await.unwrap(); buffer.update(cx, |buffer, cx| { buffer.set_text("Hello!\nHow are you?\nBye", cx); }); - let response = model_response(SIMPLE_DIFF); + let response = model_response(request, SIMPLE_DIFF); let id = response.id.clone(); respond_tx.send(response).unwrap(); cx.run_until_parked(); - ep_store.read_with(cx, |ep_store, cx| { + ep_store.update(cx, |ep_store, cx| { assert!( ep_store - .current_prediction_for_buffer(&buffer, &project, cx) + .prediction_at(&buffer, None, &project, cx) .is_none() ); }); @@ -459,17 +554,17 @@ async fn test_replace_current(cx: &mut TestAppContext) { ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); }); - let (_, respond_tx) = requests.predict.next().await.unwrap(); - let first_response = model_response(SIMPLE_DIFF); + let (request, respond_tx) = requests.predict.next().await.unwrap(); + let first_response = model_response(request, SIMPLE_DIFF); let first_id = first_response.id.clone(); respond_tx.send(first_response).unwrap(); cx.run_until_parked(); - ep_store.read_with(cx, |ep_store, cx| { + ep_store.update(cx, |ep_store, cx| { assert_eq!( ep_store - .current_prediction_for_buffer(&buffer, &project, cx) + .prediction_at(&buffer, None, &project, cx) .unwrap() .id .0, @@ -482,18 +577,18 @@ async fn test_replace_current(cx: &mut TestAppContext) { ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); }); - let (_, respond_tx) = requests.predict.next().await.unwrap(); - let second_response = model_response(SIMPLE_DIFF); + let (request, respond_tx) = requests.predict.next().await.unwrap(); + let second_response = model_response(request, SIMPLE_DIFF); let second_id = second_response.id.clone(); respond_tx.send(second_response).unwrap(); cx.run_until_parked(); - ep_store.read_with(cx, |ep_store, cx| { + ep_store.update(cx, |ep_store, cx| { // second replaces first assert_eq!( ep_store - .current_prediction_for_buffer(&buffer, &project, cx) + .prediction_at(&buffer, None, &project, cx) .unwrap() .id .0, @@ -541,17 +636,17 @@ async fn test_current_preferred(cx: &mut TestAppContext) { ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); }); - let (_, respond_tx) = requests.predict.next().await.unwrap(); - let first_response = model_response(SIMPLE_DIFF); + let (request, respond_tx) = requests.predict.next().await.unwrap(); + let first_response = model_response(request, SIMPLE_DIFF); let first_id = first_response.id.clone(); respond_tx.send(first_response).unwrap(); cx.run_until_parked(); - ep_store.read_with(cx, |ep_store, cx| { + ep_store.update(cx, |ep_store, cx| { assert_eq!( ep_store - .current_prediction_for_buffer(&buffer, &project, cx) + .prediction_at(&buffer, None, &project, cx) .unwrap() .id .0, @@ -564,27 +659,30 @@ async fn test_current_preferred(cx: &mut TestAppContext) { ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); }); - let (_, respond_tx) = requests.predict.next().await.unwrap(); + let (request, respond_tx) = requests.predict.next().await.unwrap(); // worse than current prediction - let second_response = model_response(indoc! { r" - --- a/root/foo.md - +++ b/root/foo.md - @@ ... @@ - Hello! - -How - +How are - Bye - "}); + let second_response = model_response( + request, + indoc! { r" + --- a/root/foo.md + +++ b/root/foo.md + @@ ... @@ + Hello! + -How + +How are + Bye + "}, + ); let second_id = second_response.id.clone(); respond_tx.send(second_response).unwrap(); cx.run_until_parked(); - ep_store.read_with(cx, |ep_store, cx| { + ep_store.update(cx, |ep_store, cx| { // first is preferred over second assert_eq!( ep_store - .current_prediction_for_buffer(&buffer, &project, cx) + .prediction_at(&buffer, None, &project, cx) .unwrap() .id .0, @@ -633,29 +731,29 @@ async fn test_cancel_earlier_pending_requests(cx: &mut TestAppContext) { ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); }); - let (_, respond_first) = requests.predict.next().await.unwrap(); + let (request1, respond_first) = requests.predict.next().await.unwrap(); ep_store.update(cx, |ep_store, cx| { ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); }); - let (_, respond_second) = requests.predict.next().await.unwrap(); + let (request, respond_second) = requests.predict.next().await.unwrap(); // wait for throttle cx.run_until_parked(); // second responds first - let second_response = model_response(SIMPLE_DIFF); + let second_response = model_response(request, SIMPLE_DIFF); let second_id = second_response.id.clone(); respond_second.send(second_response).unwrap(); cx.run_until_parked(); - ep_store.read_with(cx, |ep_store, cx| { + ep_store.update(cx, |ep_store, cx| { // current prediction is second assert_eq!( ep_store - .current_prediction_for_buffer(&buffer, &project, cx) + .prediction_at(&buffer, None, &project, cx) .unwrap() .id .0, @@ -663,17 +761,17 @@ async fn test_cancel_earlier_pending_requests(cx: &mut TestAppContext) { ); }); - let first_response = model_response(SIMPLE_DIFF); + let first_response = model_response(request1, SIMPLE_DIFF); let first_id = first_response.id.clone(); respond_first.send(first_response).unwrap(); cx.run_until_parked(); - ep_store.read_with(cx, |ep_store, cx| { + ep_store.update(cx, |ep_store, cx| { // current prediction is still second, since first was cancelled assert_eq!( ep_store - .current_prediction_for_buffer(&buffer, &project, cx) + .prediction_at(&buffer, None, &project, cx) .unwrap() .id .0, @@ -724,13 +822,13 @@ async fn test_cancel_second_on_third_request(cx: &mut TestAppContext) { ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); }); - let (_, respond_first) = requests.predict.next().await.unwrap(); + let (request1, respond_first) = requests.predict.next().await.unwrap(); ep_store.update(cx, |ep_store, cx| { ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); }); - let (_, respond_second) = requests.predict.next().await.unwrap(); + let (request2, respond_second) = requests.predict.next().await.unwrap(); // wait for throttle, so requests are sent cx.run_until_parked(); @@ -754,19 +852,19 @@ async fn test_cancel_second_on_third_request(cx: &mut TestAppContext) { // wait for throttle cx.run_until_parked(); - let (_, respond_third) = requests.predict.next().await.unwrap(); + let (request3, respond_third) = requests.predict.next().await.unwrap(); - let first_response = model_response(SIMPLE_DIFF); + let first_response = model_response(request1, SIMPLE_DIFF); let first_id = first_response.id.clone(); respond_first.send(first_response).unwrap(); cx.run_until_parked(); - ep_store.read_with(cx, |ep_store, cx| { + ep_store.update(cx, |ep_store, cx| { // current prediction is first assert_eq!( ep_store - .current_prediction_for_buffer(&buffer, &project, cx) + .prediction_at(&buffer, None, &project, cx) .unwrap() .id .0, @@ -774,17 +872,17 @@ async fn test_cancel_second_on_third_request(cx: &mut TestAppContext) { ); }); - let cancelled_response = model_response(SIMPLE_DIFF); + let cancelled_response = model_response(request2, SIMPLE_DIFF); let cancelled_id = cancelled_response.id.clone(); respond_second.send(cancelled_response).unwrap(); cx.run_until_parked(); - ep_store.read_with(cx, |ep_store, cx| { + ep_store.update(cx, |ep_store, cx| { // current prediction is still first, since second was cancelled assert_eq!( ep_store - .current_prediction_for_buffer(&buffer, &project, cx) + .prediction_at(&buffer, None, &project, cx) .unwrap() .id .0, @@ -792,17 +890,17 @@ async fn test_cancel_second_on_third_request(cx: &mut TestAppContext) { ); }); - let third_response = model_response(SIMPLE_DIFF); + let third_response = model_response(request3, SIMPLE_DIFF); let third_response_id = third_response.id.clone(); respond_third.send(third_response).unwrap(); cx.run_until_parked(); - ep_store.read_with(cx, |ep_store, cx| { + ep_store.update(cx, |ep_store, cx| { // third completes and replaces first assert_eq!( ep_store - .current_prediction_for_buffer(&buffer, &project, cx) + .prediction_at(&buffer, None, &project, cx) .unwrap() .id .0, @@ -1036,7 +1134,24 @@ async fn test_rejections_flushing(cx: &mut TestAppContext) { // ); // } -fn model_response(text: &str) -> open_ai::Response { +// Generate a model response that would apply the given diff to the active file. +fn model_response(request: open_ai::Request, diff_to_apply: &str) -> open_ai::Response { + let prompt = match &request.messages[0] { + open_ai::RequestMessage::User { + content: open_ai::MessageContent::Plain(content), + } => content, + _ => panic!("unexpected request {request:?}"), + }; + + let open = "\n"; + let close = ""; + let cursor = "<|user_cursor|>"; + + let start_ix = open.len() + prompt.find(open).unwrap(); + let end_ix = start_ix + &prompt[start_ix..].find(close).unwrap(); + let excerpt = prompt[start_ix..end_ix].replace(cursor, ""); + let new_excerpt = apply_diff_to_string(diff_to_apply, &excerpt).unwrap(); + open_ai::Response { id: Uuid::new_v4().to_string(), object: "response".into(), @@ -1045,7 +1160,7 @@ fn model_response(text: &str) -> open_ai::Response { choices: vec![open_ai::Choice { index: 0, message: open_ai::RequestMessage::Assistant { - content: Some(open_ai::MessageContent::Plain(text.to_string())), + content: Some(open_ai::MessageContent::Plain(new_excerpt)), tool_calls: vec![], }, finish_reason: None, @@ -1160,20 +1275,19 @@ async fn test_edit_prediction_basic_interpolation(cx: &mut TestAppContext) { .read(|cx| buffer.read(cx).preview_edits(edits.clone(), cx)) .await; - let completion = EditPrediction { + let prediction = EditPrediction { edits, edit_preview, buffer: buffer.clone(), snapshot: cx.read(|cx| buffer.read(cx).snapshot()), id: EditPredictionId("the-id".into()), - inputs: EditPredictionInputs { + inputs: ZetaPromptInput { events: Default::default(), - included_files: Default::default(), - cursor_point: cloud_llm_client::predict_edits_v3::Point { - line: Line(0), - column: 0, - }, + related_files: Default::default(), cursor_path: Path::new("").into(), + cursor_excerpt: "".into(), + editable_range_in_excerpt: 0..0, + cursor_offset_in_excerpt: 0, }, buffer_snapshotted_at: Instant::now(), response_received_at: Instant::now(), @@ -1182,7 +1296,7 @@ async fn test_edit_prediction_basic_interpolation(cx: &mut TestAppContext) { cx.update(|cx| { assert_eq!( from_completion_edits( - &completion.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), &buffer, cx ), @@ -1192,7 +1306,7 @@ async fn test_edit_prediction_basic_interpolation(cx: &mut TestAppContext) { buffer.update(cx, |buffer, cx| buffer.edit([(2..5, "")], None, cx)); assert_eq!( from_completion_edits( - &completion.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), &buffer, cx ), @@ -1202,7 +1316,7 @@ async fn test_edit_prediction_basic_interpolation(cx: &mut TestAppContext) { buffer.update(cx, |buffer, cx| buffer.undo(cx)); assert_eq!( from_completion_edits( - &completion.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), &buffer, cx ), @@ -1212,7 +1326,7 @@ async fn test_edit_prediction_basic_interpolation(cx: &mut TestAppContext) { buffer.update(cx, |buffer, cx| buffer.edit([(2..5, "R")], None, cx)); assert_eq!( from_completion_edits( - &completion.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), &buffer, cx ), @@ -1222,7 +1336,7 @@ async fn test_edit_prediction_basic_interpolation(cx: &mut TestAppContext) { buffer.update(cx, |buffer, cx| buffer.edit([(3..3, "E")], None, cx)); assert_eq!( from_completion_edits( - &completion.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), &buffer, cx ), @@ -1232,7 +1346,7 @@ async fn test_edit_prediction_basic_interpolation(cx: &mut TestAppContext) { buffer.update(cx, |buffer, cx| buffer.edit([(4..4, "M")], None, cx)); assert_eq!( from_completion_edits( - &completion.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), &buffer, cx ), @@ -1242,7 +1356,7 @@ async fn test_edit_prediction_basic_interpolation(cx: &mut TestAppContext) { buffer.update(cx, |buffer, cx| buffer.edit([(4..5, "")], None, cx)); assert_eq!( from_completion_edits( - &completion.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), &buffer, cx ), @@ -1252,7 +1366,7 @@ async fn test_edit_prediction_basic_interpolation(cx: &mut TestAppContext) { buffer.update(cx, |buffer, cx| buffer.edit([(8..10, "")], None, cx)); assert_eq!( from_completion_edits( - &completion.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), &buffer, cx ), @@ -1260,7 +1374,7 @@ async fn test_edit_prediction_basic_interpolation(cx: &mut TestAppContext) { ); buffer.update(cx, |buffer, cx| buffer.edit([(4..6, "")], None, cx)); - assert_eq!(completion.interpolate(&buffer.read(cx).snapshot()), None); + assert_eq!(prediction.interpolate(&buffer.read(cx).snapshot()), None); }) } @@ -1800,6 +1914,174 @@ fn from_completion_edits( .collect() } +#[gpui::test] +async fn test_unauthenticated_without_custom_url_blocks_prediction_impl(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + serde_json::json!({ + "main.rs": "fn main() {\n \n}\n" + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + + let http_client = FakeHttpClient::create(|_req| async move { + Ok(gpui::http_client::Response::builder() + .status(401) + .body("Unauthorized".into()) + .unwrap()) + }); + + let client = + cx.update(|cx| client::Client::new(Arc::new(FakeSystemClock::new()), http_client, cx)); + cx.update(|cx| { + language_model::RefreshLlmTokenListener::register(client.clone(), cx); + }); + + let ep_store = cx.new(|cx| EditPredictionStore::new(client, project.read(cx).user_store(), cx)); + + let buffer = project + .update(cx, |project, cx| { + let path = project + .find_project_path(path!("/project/main.rs"), cx) + .unwrap(); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + + let cursor = buffer.read_with(cx, |buffer, _| buffer.anchor_before(Point::new(1, 4))); + ep_store.update(cx, |ep_store, cx| { + ep_store.register_buffer(&buffer, &project, cx) + }); + cx.background_executor.run_until_parked(); + + let completion_task = ep_store.update(cx, |ep_store, cx| { + ep_store.set_edit_prediction_model(EditPredictionModel::Zeta1); + ep_store.request_prediction(&project, &buffer, cursor, Default::default(), cx) + }); + + let result = completion_task.await; + assert!( + result.is_err(), + "Without authentication and without custom URL, prediction should fail" + ); +} + +#[gpui::test] +async fn test_unauthenticated_with_custom_url_allows_prediction_impl(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + serde_json::json!({ + "main.rs": "fn main() {\n \n}\n" + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + + let predict_called = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let predict_called_clone = predict_called.clone(); + + let http_client = FakeHttpClient::create({ + move |req| { + let uri = req.uri().path().to_string(); + let predict_called = predict_called_clone.clone(); + async move { + if uri.contains("predict") { + predict_called.store(true, std::sync::atomic::Ordering::SeqCst); + Ok(gpui::http_client::Response::builder() + .body( + serde_json::to_string(&open_ai::Response { + id: "test-123".to_string(), + object: "chat.completion".to_string(), + created: 0, + model: "test".to_string(), + usage: open_ai::Usage { + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + }, + choices: vec![open_ai::Choice { + index: 0, + message: open_ai::RequestMessage::Assistant { + content: Some(open_ai::MessageContent::Plain( + indoc! {" + ```main.rs + <|start_of_file|> + <|editable_region_start|> + fn main() { + println!(\"Hello, world!\"); + } + <|editable_region_end|> + ``` + "} + .to_string(), + )), + tool_calls: vec![], + }, + finish_reason: Some("stop".to_string()), + }], + }) + .unwrap() + .into(), + ) + .unwrap()) + } else { + Ok(gpui::http_client::Response::builder() + .status(401) + .body("Unauthorized".into()) + .unwrap()) + } + } + } + }); + + let client = + cx.update(|cx| client::Client::new(Arc::new(FakeSystemClock::new()), http_client, cx)); + cx.update(|cx| { + language_model::RefreshLlmTokenListener::register(client.clone(), cx); + }); + + let ep_store = cx.new(|cx| EditPredictionStore::new(client, project.read(cx).user_store(), cx)); + + let buffer = project + .update(cx, |project, cx| { + let path = project + .find_project_path(path!("/project/main.rs"), cx) + .unwrap(); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + + let cursor = buffer.read_with(cx, |buffer, _| buffer.anchor_before(Point::new(1, 4))); + ep_store.update(cx, |ep_store, cx| { + ep_store.register_buffer(&buffer, &project, cx) + }); + cx.background_executor.run_until_parked(); + + let completion_task = ep_store.update(cx, |ep_store, cx| { + ep_store.set_custom_predict_edits_url(Url::parse("http://test/predict").unwrap()); + ep_store.set_edit_prediction_model(EditPredictionModel::Zeta1); + ep_store.request_prediction(&project, &buffer, cursor, Default::default(), cx) + }); + + let _ = completion_task.await; + + assert!( + predict_called.load(std::sync::atomic::Ordering::SeqCst), + "With custom URL, predict endpoint should be called even without authentication" + ); +} + #[ctor::ctor] fn init_logger() { zlog::init_test(); diff --git a/crates/edit_prediction/src/example_spec.rs b/crates/edit_prediction/src/example_spec.rs new file mode 100644 index 0000000000000000000000000000000000000000..bf221b576b890f1200c4ee3c095f73edaea71462 --- /dev/null +++ b/crates/edit_prediction/src/example_spec.rs @@ -0,0 +1,212 @@ +use serde::{Deserialize, Serialize}; +use std::{fmt::Write as _, mem, path::Path, sync::Arc}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ExampleSpec { + #[serde(default)] + pub name: String, + pub repository_url: String, + pub revision: String, + #[serde(default)] + pub uncommitted_diff: String, + pub cursor_path: Arc, + pub cursor_position: String, + pub edit_history: String, + pub expected_patch: String, +} + +const UNCOMMITTED_DIFF_HEADING: &str = "Uncommitted Diff"; +const EDIT_HISTORY_HEADING: &str = "Edit History"; +const CURSOR_POSITION_HEADING: &str = "Cursor Position"; +const EXPECTED_PATCH_HEADING: &str = "Expected Patch"; +const EXPECTED_CONTEXT_HEADING: &str = "Expected Context"; +const REPOSITORY_URL_FIELD: &str = "repository_url"; +const REVISION_FIELD: &str = "revision"; + +impl ExampleSpec { + /// Format this example spec as markdown. + pub fn to_markdown(&self) -> String { + let mut markdown = String::new(); + + _ = writeln!(markdown, "# {}", self.name); + markdown.push('\n'); + + _ = writeln!(markdown, "repository_url = {}", self.repository_url); + _ = writeln!(markdown, "revision = {}", self.revision); + markdown.push('\n'); + + if !self.uncommitted_diff.is_empty() { + _ = writeln!(markdown, "## {}", UNCOMMITTED_DIFF_HEADING); + _ = writeln!(markdown); + _ = writeln!(markdown, "```diff"); + markdown.push_str(&self.uncommitted_diff); + if !markdown.ends_with('\n') { + markdown.push('\n'); + } + _ = writeln!(markdown, "```"); + markdown.push('\n'); + } + + _ = writeln!(markdown, "## {}", EDIT_HISTORY_HEADING); + _ = writeln!(markdown); + + if self.edit_history.is_empty() { + _ = writeln!(markdown, "(No edit history)"); + _ = writeln!(markdown); + } else { + _ = writeln!(markdown, "```diff"); + markdown.push_str(&self.edit_history); + if !markdown.ends_with('\n') { + markdown.push('\n'); + } + _ = writeln!(markdown, "```"); + markdown.push('\n'); + } + + _ = writeln!(markdown, "## {}", CURSOR_POSITION_HEADING); + _ = writeln!(markdown); + _ = writeln!(markdown, "```{}", self.cursor_path.to_string_lossy()); + markdown.push_str(&self.cursor_position); + if !markdown.ends_with('\n') { + markdown.push('\n'); + } + _ = writeln!(markdown, "```"); + markdown.push('\n'); + + _ = writeln!(markdown, "## {}", EXPECTED_PATCH_HEADING); + markdown.push('\n'); + _ = writeln!(markdown, "```diff"); + markdown.push_str(&self.expected_patch); + if !markdown.ends_with('\n') { + markdown.push('\n'); + } + _ = writeln!(markdown, "```"); + markdown.push('\n'); + + markdown + } + + /// Parse an example spec from markdown. + pub fn from_markdown(name: String, input: &str) -> anyhow::Result { + use pulldown_cmark::{CodeBlockKind, CowStr, Event, HeadingLevel, Parser, Tag, TagEnd}; + + let parser = Parser::new(input); + + let mut spec = ExampleSpec { + name, + repository_url: String::new(), + revision: String::new(), + uncommitted_diff: String::new(), + cursor_path: Path::new("").into(), + cursor_position: String::new(), + edit_history: String::new(), + expected_patch: String::new(), + }; + + let mut text = String::new(); + let mut block_info: CowStr = "".into(); + + #[derive(PartialEq)] + enum Section { + Start, + UncommittedDiff, + EditHistory, + CursorPosition, + ExpectedExcerpts, + ExpectedPatch, + Other, + } + + let mut current_section = Section::Start; + + for event in parser { + match event { + Event::Text(line) => { + text.push_str(&line); + + if let Section::Start = current_section + && let Some((field, value)) = line.split_once('=') + { + match field.trim() { + REPOSITORY_URL_FIELD => { + spec.repository_url = value.trim().to_string(); + } + REVISION_FIELD => { + spec.revision = value.trim().to_string(); + } + _ => {} + } + } + } + Event::End(TagEnd::Heading(HeadingLevel::H2)) => { + let title = mem::take(&mut text); + current_section = if title.eq_ignore_ascii_case(UNCOMMITTED_DIFF_HEADING) { + Section::UncommittedDiff + } else if title.eq_ignore_ascii_case(EDIT_HISTORY_HEADING) { + Section::EditHistory + } else if title.eq_ignore_ascii_case(CURSOR_POSITION_HEADING) { + Section::CursorPosition + } else if title.eq_ignore_ascii_case(EXPECTED_PATCH_HEADING) { + Section::ExpectedPatch + } else if title.eq_ignore_ascii_case(EXPECTED_CONTEXT_HEADING) { + Section::ExpectedExcerpts + } else { + Section::Other + }; + } + Event::End(TagEnd::Heading(HeadingLevel::H3)) => { + mem::take(&mut text); + } + Event::End(TagEnd::Heading(HeadingLevel::H4)) => { + mem::take(&mut text); + } + Event::End(TagEnd::Heading(level)) => { + anyhow::bail!("Unexpected heading level: {level}"); + } + Event::Start(Tag::CodeBlock(kind)) => { + match kind { + CodeBlockKind::Fenced(info) => { + block_info = info; + } + CodeBlockKind::Indented => { + anyhow::bail!("Unexpected indented codeblock"); + } + }; + } + Event::Start(_) => { + text.clear(); + block_info = "".into(); + } + Event::End(TagEnd::CodeBlock) => { + let block_info = block_info.trim(); + match current_section { + Section::UncommittedDiff => { + spec.uncommitted_diff = mem::take(&mut text); + } + Section::EditHistory => { + spec.edit_history.push_str(&mem::take(&mut text)); + } + Section::CursorPosition => { + spec.cursor_path = Path::new(block_info).into(); + spec.cursor_position = mem::take(&mut text); + } + Section::ExpectedExcerpts => { + mem::take(&mut text); + } + Section::ExpectedPatch => { + spec.expected_patch = mem::take(&mut text); + } + Section::Start | Section::Other => {} + } + } + _ => {} + } + } + + if spec.cursor_path.as_ref() == Path::new("") || spec.cursor_position.is_empty() { + anyhow::bail!("Missing cursor position codeblock"); + } + + Ok(spec) + } +} diff --git a/crates/edit_prediction/src/license_detection.rs b/crates/edit_prediction/src/license_detection.rs index d4d4825615f19e5e5654f7bd78439d9eaa39e4c1..3ad34e7e6df6233cd4ff7462681d7b3588d36534 100644 --- a/crates/edit_prediction/src/license_detection.rs +++ b/crates/edit_prediction/src/license_detection.rs @@ -735,6 +735,7 @@ mod tests { true, fs.clone(), Default::default(), + true, &mut cx.to_async(), ) .await @@ -758,6 +759,7 @@ mod tests { true, fs.clone(), Default::default(), + true, &mut cx.to_async(), ) .await @@ -816,6 +818,7 @@ mod tests { true, fs.clone(), Default::default(), + true, &mut cx.to_async(), ) .await diff --git a/crates/edit_prediction/src/mercury.rs b/crates/edit_prediction/src/mercury.rs index 40c0fdfac021f937df5172fd423d3b6bfc5f8146..8186fc5d8c609468be04c117eabac11c6c015efd 100644 --- a/crates/edit_prediction/src/mercury.rs +++ b/crates/edit_prediction/src/mercury.rs @@ -1,56 +1,51 @@ +use crate::{ + DebugEvent, EditPredictionFinishedDebugEvent, EditPredictionId, EditPredictionModelInput, + EditPredictionStartedDebugEvent, open_ai_response::text_from_response, + prediction::EditPredictionResult, +}; use anyhow::{Context as _, Result}; -use cloud_llm_client::predict_edits_v3::Event; -use credentials_provider::CredentialsProvider; -use edit_prediction_context::RelatedFile; -use futures::{AsyncReadExt as _, FutureExt, future::Shared}; +use futures::AsyncReadExt as _; use gpui::{ - App, AppContext as _, Entity, Task, + App, AppContext as _, Entity, Global, SharedString, Task, http_client::{self, AsyncBody, Method}, }; -use language::{Buffer, BufferSnapshot, OffsetRangeExt as _, Point, ToPoint as _}; -use project::{Project, ProjectPath}; -use std::{ - collections::VecDeque, fmt::Write as _, mem, ops::Range, path::Path, sync::Arc, time::Instant, -}; - -use crate::{ - EditPredictionId, EditPredictionInputs, open_ai_response::text_from_response, - prediction::EditPredictionResult, -}; +use language::{OffsetRangeExt as _, ToOffset, ToPoint as _}; +use language_model::{ApiKeyState, EnvVar, env_var}; +use std::{mem, ops::Range, path::Path, sync::Arc, time::Instant}; +use zeta_prompt::ZetaPromptInput; const MERCURY_API_URL: &str = "https://api.inceptionlabs.ai/v1/edit/completions"; const MAX_CONTEXT_TOKENS: usize = 150; const MAX_REWRITE_TOKENS: usize = 350; pub struct Mercury { - pub api_token: Shared>>, + pub api_token: Entity, } impl Mercury { - pub fn new(cx: &App) -> Self { + pub fn new(cx: &mut App) -> Self { Mercury { - api_token: load_api_token(cx).shared(), + api_token: mercury_api_token(cx), } } - pub fn set_api_token(&mut self, api_token: Option, cx: &mut App) -> Task> { - self.api_token = Task::ready(api_token.clone()).shared(); - store_api_token_in_keychain(api_token, cx) - } - - pub fn request_prediction( + pub(crate) fn request_prediction( &self, - _project: &Entity, - active_buffer: &Entity, - snapshot: BufferSnapshot, - position: language::Anchor, - events: Vec>, - _recent_paths: &VecDeque, - related_files: Vec, - _diagnostic_search_range: Range, + EditPredictionModelInput { + buffer, + snapshot, + position, + events, + related_files, + debug_tx, + .. + }: EditPredictionModelInput, cx: &mut App, ) -> Task>> { - let Some(api_token) = self.api_token.clone().now_or_never().flatten() else { + self.api_token.update(cx, |key_state, cx| { + _ = key_state.load_if_needed(MERCURY_CREDENTIALS_URL, |s| s, cx); + }); + let Some(api_token) = self.api_token.read(cx).key(&MERCURY_CREDENTIALS_URL) else { return Task::ready(Ok(None)); }; let full_path: Arc = snapshot @@ -62,6 +57,7 @@ impl Mercury { let http_client = cx.http_client(); let cursor_point = position.to_point(&snapshot); let buffer_snapshotted_at = Instant::now(); + let active_buffer = buffer.clone(); let result = cx.background_spawn(async move { let (editable_range, context_range) = @@ -72,39 +68,39 @@ impl Mercury { MAX_REWRITE_TOKENS, ); - let offset_range = editable_range.to_offset(&snapshot); - let prompt = build_prompt( - &events, - &related_files, - &snapshot, - full_path.as_ref(), - cursor_point, - editable_range, - context_range.clone(), - ); - - let inputs = EditPredictionInputs { - events: events, - included_files: vec![cloud_llm_client::predict_edits_v3::RelatedFile { - path: full_path.clone(), - max_row: cloud_llm_client::predict_edits_v3::Line(snapshot.max_point().row), - excerpts: vec![cloud_llm_client::predict_edits_v3::Excerpt { - start_line: cloud_llm_client::predict_edits_v3::Line( - context_range.start.row, - ), - text: snapshot - .text_for_range(context_range.clone()) - .collect::() - .into(), - }], - }], - cursor_point: cloud_llm_client::predict_edits_v3::Point { - column: cursor_point.column, - line: cloud_llm_client::predict_edits_v3::Line(cursor_point.row), - }, + let context_offset_range = context_range.to_offset(&snapshot); + + let editable_offset_range = editable_range.to_offset(&snapshot); + + let inputs = zeta_prompt::ZetaPromptInput { + events, + related_files, + cursor_offset_in_excerpt: cursor_point.to_offset(&snapshot) + - context_range.start.to_offset(&snapshot), cursor_path: full_path.clone(), + cursor_excerpt: snapshot + .text_for_range(context_range) + .collect::() + .into(), + editable_range_in_excerpt: (editable_offset_range.start + - context_offset_range.start) + ..(editable_offset_range.end - context_offset_range.start), }; + let prompt = build_prompt(&inputs); + + if let Some(debug_tx) = &debug_tx { + debug_tx + .unbounded_send(DebugEvent::EditPredictionStarted( + EditPredictionStartedDebugEvent { + buffer: active_buffer.downgrade(), + prompt: Some(prompt.clone()), + position, + }, + )) + .ok(); + } + let request_body = open_ai::Request { model: "mercury-coder".into(), messages: vec![open_ai::RequestMessage::User { @@ -160,6 +156,18 @@ impl Mercury { let id = mem::take(&mut response.id); let response_str = text_from_response(response).unwrap_or_default(); + if let Some(debug_tx) = &debug_tx { + debug_tx + .unbounded_send(DebugEvent::EditPredictionFinished( + EditPredictionFinishedDebugEvent { + buffer: active_buffer.downgrade(), + model_output: Some(response_str.clone()), + position, + }, + )) + .ok(); + } + let response_str = response_str.strip_prefix("```\n").unwrap_or(&response_str); let response_str = response_str.strip_suffix("\n```").unwrap_or(&response_str); @@ -168,15 +176,16 @@ impl Mercury { if response_str != NO_PREDICTION_OUTPUT { let old_text = snapshot - .text_for_range(offset_range.clone()) + .text_for_range(editable_offset_range.clone()) .collect::(); edits.extend( language::text_diff(&old_text, &response_str) .into_iter() .map(|(range, text)| { ( - snapshot.anchor_after(offset_range.start + range.start) - ..snapshot.anchor_before(offset_range.start + range.end), + snapshot.anchor_after(editable_offset_range.start + range.start) + ..snapshot + .anchor_before(editable_offset_range.start + range.end), text, ) }), @@ -186,8 +195,6 @@ impl Mercury { anyhow::Ok((id, edits, snapshot, response_received_at, inputs)) }); - let buffer = active_buffer.clone(); - cx.spawn(async move |cx| { let (id, edits, old_snapshot, response_received_at, inputs) = result.await.context("Mercury edit prediction failed")?; @@ -208,15 +215,7 @@ impl Mercury { } } -fn build_prompt( - events: &[Arc], - related_files: &[RelatedFile], - cursor_buffer: &BufferSnapshot, - cursor_buffer_path: &Path, - cursor_point: Point, - editable_range: Range, - context_range: Range, -) -> String { +fn build_prompt(inputs: &ZetaPromptInput) -> String { const RECENTLY_VIEWED_SNIPPETS_START: &str = "<|recently_viewed_code_snippets|>\n"; const RECENTLY_VIEWED_SNIPPETS_END: &str = "<|/recently_viewed_code_snippets|>\n"; const RECENTLY_VIEWED_SNIPPET_START: &str = "<|recently_viewed_code_snippet|>\n"; @@ -237,14 +236,14 @@ fn build_prompt( &mut prompt, RECENTLY_VIEWED_SNIPPETS_START..RECENTLY_VIEWED_SNIPPETS_END, |prompt| { - for related_file in related_files { + for related_file in inputs.related_files.iter() { for related_excerpt in &related_file.excerpts { push_delimited( prompt, RECENTLY_VIEWED_SNIPPET_START..RECENTLY_VIEWED_SNIPPET_END, |prompt| { prompt.push_str(CODE_SNIPPET_FILE_PATH_PREFIX); - prompt.push_str(related_file.path.path.as_unix_str()); + prompt.push_str(related_file.path.to_string_lossy().as_ref()); prompt.push('\n'); prompt.push_str(&related_excerpt.text.to_string()); }, @@ -259,21 +258,22 @@ fn build_prompt( CURRENT_FILE_CONTENT_START..CURRENT_FILE_CONTENT_END, |prompt| { prompt.push_str(CURRENT_FILE_PATH_PREFIX); - prompt.push_str(cursor_buffer_path.as_os_str().to_string_lossy().as_ref()); + prompt.push_str(inputs.cursor_path.as_os_str().to_string_lossy().as_ref()); prompt.push('\n'); - let prefix_range = context_range.start..editable_range.start; - let suffix_range = editable_range.end..context_range.end; - - prompt.extend(cursor_buffer.text_for_range(prefix_range)); + prompt.push_str(&inputs.cursor_excerpt[0..inputs.editable_range_in_excerpt.start]); push_delimited(prompt, CODE_TO_EDIT_START..CODE_TO_EDIT_END, |prompt| { - let range_before_cursor = editable_range.start..cursor_point; - let range_after_cursor = cursor_point..editable_range.end; - prompt.extend(cursor_buffer.text_for_range(range_before_cursor)); + prompt.push_str( + &inputs.cursor_excerpt + [inputs.editable_range_in_excerpt.start..inputs.cursor_offset_in_excerpt], + ); prompt.push_str(CURSOR_TAG); - prompt.extend(cursor_buffer.text_for_range(range_after_cursor)); + prompt.push_str( + &inputs.cursor_excerpt + [inputs.cursor_offset_in_excerpt..inputs.editable_range_in_excerpt.end], + ); }); - prompt.extend(cursor_buffer.text_for_range(suffix_range)); + prompt.push_str(&inputs.cursor_excerpt[inputs.editable_range_in_excerpt.end..]); }, ); @@ -281,8 +281,8 @@ fn build_prompt( &mut prompt, EDIT_DIFF_HISTORY_START..EDIT_DIFF_HISTORY_END, |prompt| { - for event in events { - writeln!(prompt, "{event}").unwrap(); + for event in inputs.events.iter() { + zeta_prompt::write_event(prompt, &event); } }, ); @@ -296,45 +296,27 @@ fn push_delimited(prompt: &mut String, delimiters: Range<&str>, cb: impl FnOnce( prompt.push_str(delimiters.end); } -pub const MERCURY_CREDENTIALS_URL: &str = "https://api.inceptionlabs.ai/v1/edit/completions"; +pub const MERCURY_CREDENTIALS_URL: SharedString = + SharedString::new_static("https://api.inceptionlabs.ai/v1/edit/completions"); pub const MERCURY_CREDENTIALS_USERNAME: &str = "mercury-api-token"; +pub static MERCURY_TOKEN_ENV_VAR: std::sync::LazyLock = env_var!("MERCURY_AI_TOKEN"); + +struct GlobalMercuryApiKey(Entity); -pub fn load_api_token(cx: &App) -> Task> { - if let Some(api_token) = std::env::var("MERCURY_AI_TOKEN") - .ok() - .filter(|value| !value.is_empty()) - { - return Task::ready(Some(api_token)); +impl Global for GlobalMercuryApiKey {} + +pub fn mercury_api_token(cx: &mut App) -> Entity { + if let Some(global) = cx.try_global::() { + return global.0.clone(); } - let credentials_provider = ::global(cx); - cx.spawn(async move |cx| { - let (_, credentials) = credentials_provider - .read_credentials(MERCURY_CREDENTIALS_URL, &cx) - .await - .ok()??; - String::from_utf8(credentials).ok() - }) + let entity = + cx.new(|_| ApiKeyState::new(MERCURY_CREDENTIALS_URL, MERCURY_TOKEN_ENV_VAR.clone())); + cx.set_global(GlobalMercuryApiKey(entity.clone())); + entity } -fn store_api_token_in_keychain(api_token: Option, cx: &App) -> Task> { - let credentials_provider = ::global(cx); - - cx.spawn(async move |cx| { - if let Some(api_token) = api_token { - credentials_provider - .write_credentials( - MERCURY_CREDENTIALS_URL, - MERCURY_CREDENTIALS_USERNAME, - api_token.as_bytes(), - cx, - ) - .await - .context("Failed to save Mercury API token to system keychain") - } else { - credentials_provider - .delete_credentials(MERCURY_CREDENTIALS_URL, cx) - .await - .context("Failed to delete Mercury API token from system keychain") - } +pub fn load_mercury_api_token(cx: &mut App) -> Task> { + mercury_api_token(cx).update(cx, |key_state, cx| { + key_state.load_if_needed(MERCURY_CREDENTIALS_URL, |s| s, 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/src/prediction.rs b/crates/edit_prediction/src/prediction.rs index 8aa2a8218568a99404cc9aceff36b84127700152..c63640ccd0e1815b32f736e8a0fee8d75d124df1 100644 --- a/crates/edit_prediction/src/prediction.rs +++ b/crates/edit_prediction/src/prediction.rs @@ -1,6 +1,5 @@ use std::{ ops::Range, - path::Path, sync::Arc, time::{Duration, Instant}, }; @@ -9,7 +8,7 @@ use cloud_llm_client::EditPredictionRejectReason; use edit_prediction_types::interpolate_edits; use gpui::{AsyncApp, Entity, SharedString}; use language::{Anchor, Buffer, BufferSnapshot, EditPreview, TextBufferSnapshot}; -use serde::Serialize; +use zeta_prompt::ZetaPromptInput; #[derive(Clone, Default, Debug, PartialEq, Eq, Hash)] pub struct EditPredictionId(pub SharedString); @@ -40,7 +39,7 @@ impl EditPredictionResult { edits: Arc<[(Range, Arc)]>, buffer_snapshotted_at: Instant, response_received_at: Instant, - inputs: EditPredictionInputs, + inputs: ZetaPromptInput, cx: &mut AsyncApp, ) -> Self { if edits.is_empty() { @@ -94,15 +93,7 @@ pub struct EditPrediction { pub buffer: Entity, pub buffer_snapshotted_at: Instant, pub response_received_at: Instant, - pub inputs: EditPredictionInputs, -} - -#[derive(Debug, Clone, Serialize)] -pub struct EditPredictionInputs { - pub events: Vec>, - pub included_files: Vec, - pub cursor_point: cloud_llm_client::predict_edits_v3::Point, - pub cursor_path: Arc, + pub inputs: zeta_prompt::ZetaPromptInput, } impl EditPrediction { @@ -133,9 +124,12 @@ impl std::fmt::Debug for EditPrediction { #[cfg(test)] mod tests { + use std::path::Path; + use super::*; use gpui::{App, Entity, TestAppContext, prelude::*}; use language::{Buffer, ToOffset as _}; + use zeta_prompt::ZetaPromptInput; #[gpui::test] async fn test_edit_prediction_basic_interpolation(cx: &mut TestAppContext) { @@ -154,14 +148,13 @@ mod tests { snapshot: cx.read(|cx| buffer.read(cx).snapshot()), buffer: buffer.clone(), edit_preview, - inputs: EditPredictionInputs { + inputs: ZetaPromptInput { events: vec![], - included_files: vec![], - cursor_point: cloud_llm_client::predict_edits_v3::Point { - line: cloud_llm_client::predict_edits_v3::Line(0), - column: 0, - }, + related_files: vec![].into(), cursor_path: Path::new("path.txt").into(), + cursor_offset_in_excerpt: 0, + cursor_excerpt: "".into(), + editable_range_in_excerpt: 0..0, }, buffer_snapshotted_at: Instant::now(), response_received_at: Instant::now(), diff --git a/crates/edit_prediction/src/sweep_ai.rs b/crates/edit_prediction/src/sweep_ai.rs index 4bb014c640cb489db29c800835a58febf91a7270..71f28c9213c3440a9267dab7d5a5416dc219f2f3 100644 --- a/crates/edit_prediction/src/sweep_ai.rs +++ b/crates/edit_prediction/src/sweep_ai.rs @@ -1,83 +1,70 @@ -use anyhow::{Context as _, Result}; -use cloud_llm_client::predict_edits_v3::Event; -use credentials_provider::CredentialsProvider; -use edit_prediction_context::RelatedFile; -use futures::{AsyncReadExt as _, FutureExt, future::Shared}; +use anyhow::Result; +use futures::AsyncReadExt as _; use gpui::{ - App, AppContext as _, Entity, Task, + App, AppContext as _, Entity, Global, SharedString, Task, http_client::{self, AsyncBody, Method}, }; -use language::{Buffer, BufferSnapshot, Point, ToOffset as _, ToPoint as _}; +use language::{Point, ToOffset as _}; +use language_model::{ApiKeyState, EnvVar, env_var}; use lsp::DiagnosticSeverity; -use project::{Project, ProjectPath}; use serde::{Deserialize, Serialize}; use std::{ - collections::VecDeque, fmt::{self, Write as _}, - ops::Range, path::Path, sync::Arc, time::Instant, }; -use crate::{EditPredictionId, EditPredictionInputs, prediction::EditPredictionResult}; +use crate::{EditPredictionId, EditPredictionModelInput, prediction::EditPredictionResult}; const SWEEP_API_URL: &str = "https://autocomplete.sweep.dev/backend/next_edit_autocomplete"; pub struct SweepAi { - pub api_token: Shared>>, + pub api_token: Entity, pub debug_info: Arc, } impl SweepAi { - pub fn new(cx: &App) -> Self { + pub fn new(cx: &mut App) -> Self { SweepAi { - api_token: load_api_token(cx).shared(), + api_token: sweep_api_token(cx), debug_info: debug_info(cx), } } - pub fn set_api_token(&mut self, api_token: Option, cx: &mut App) -> Task> { - self.api_token = Task::ready(api_token.clone()).shared(); - store_api_token_in_keychain(api_token, cx) - } - pub fn request_prediction_with_sweep( &self, - project: &Entity, - active_buffer: &Entity, - snapshot: BufferSnapshot, - position: language::Anchor, - events: Vec>, - recent_paths: &VecDeque, - related_files: Vec, - diagnostic_search_range: Range, + inputs: EditPredictionModelInput, cx: &mut App, ) -> Task>> { let debug_info = self.debug_info.clone(); - let Some(api_token) = self.api_token.clone().now_or_never().flatten() else { + self.api_token.update(cx, |key_state, cx| { + _ = key_state.load_if_needed(SWEEP_CREDENTIALS_URL, |s| s, cx); + }); + let Some(api_token) = self.api_token.read(cx).key(&SWEEP_CREDENTIALS_URL) else { return Task::ready(Ok(None)); }; - let full_path: Arc = snapshot + let full_path: Arc = inputs + .snapshot .file() .map(|file| file.full_path(cx)) .unwrap_or_else(|| "untitled".into()) .into(); - let project_file = project::File::from_dyn(snapshot.file()); + let project_file = project::File::from_dyn(inputs.snapshot.file()); let repo_name = project_file .map(|file| file.worktree.read(cx).root_name_str()) .unwrap_or("untitled") .into(); - let offset = position.to_offset(&snapshot); + let offset = inputs.position.to_offset(&inputs.snapshot); - let recent_buffers = recent_paths.iter().cloned(); + let recent_buffers = inputs.recent_paths.iter().cloned(); let http_client = cx.http_client(); let recent_buffer_snapshots = recent_buffers .filter_map(|project_path| { - let buffer = project.read(cx).get_open_buffer(&project_path, cx)?; - if active_buffer == &buffer { + let buffer = inputs.project.read(cx).get_open_buffer(&project_path, cx)?; + if inputs.buffer == buffer { None } else { Some(buffer.read(cx).snapshot()) @@ -86,14 +73,13 @@ impl SweepAi { .take(3) .collect::>(); - let cursor_point = position.to_point(&snapshot); let buffer_snapshotted_at = Instant::now(); let result = cx.background_spawn(async move { - let text = snapshot.text(); + let text = inputs.snapshot.text(); let mut recent_changes = String::new(); - for event in &events { + for event in &inputs.events { write_event(event.as_ref(), &mut recent_changes).unwrap(); } @@ -122,20 +108,23 @@ impl SweepAi { }) .collect::>(); - let retrieval_chunks = related_files + let retrieval_chunks = inputs + .related_files .iter() .flat_map(|related_file| { related_file.excerpts.iter().map(|excerpt| FileChunk { - file_path: related_file.path.path.as_unix_str().to_string(), - start_line: excerpt.point_range.start.row as usize, - end_line: excerpt.point_range.end.row as usize, + file_path: related_file.path.to_string_lossy().to_string(), + start_line: excerpt.row_range.start as usize, + end_line: excerpt.row_range.end as usize, content: excerpt.text.to_string(), timestamp: None, }) }) .collect(); - let diagnostic_entries = snapshot.diagnostics_in_range(diagnostic_search_range, false); + let diagnostic_entries = inputs + .snapshot + .diagnostics_in_range(inputs.diagnostic_search_range, false); let mut diagnostic_content = String::new(); let mut diagnostic_count = 0; @@ -195,21 +184,14 @@ impl SweepAi { serde_json::to_writer(writer, &request_body)?; let body: AsyncBody = buf.into(); - let inputs = EditPredictionInputs { - events, - included_files: vec![cloud_llm_client::predict_edits_v3::RelatedFile { - path: full_path.clone(), - max_row: cloud_llm_client::predict_edits_v3::Line(snapshot.max_point().row), - excerpts: vec![cloud_llm_client::predict_edits_v3::Excerpt { - start_line: cloud_llm_client::predict_edits_v3::Line(0), - text: request_body.file_contents.into(), - }], - }], - cursor_point: cloud_llm_client::predict_edits_v3::Point { - column: cursor_point.column, - line: cloud_llm_client::predict_edits_v3::Line(cursor_point.row), - }, + let ep_inputs = zeta_prompt::ZetaPromptInput { + events: inputs.events, + related_files: inputs.related_files.clone(), cursor_path: full_path.clone(), + cursor_excerpt: request_body.file_contents.into(), + // we actually don't know + editable_range_in_excerpt: 0..inputs.snapshot.len(), + cursor_offset_in_excerpt: request_body.cursor_position, }; let request = http_client::Request::builder() @@ -237,15 +219,20 @@ impl SweepAi { let response: AutocompleteResponse = serde_json::from_slice(&body)?; - let old_text = snapshot + let old_text = inputs + .snapshot .text_for_range(response.start_index..response.end_index) .collect::(); let edits = language::text_diff(&old_text, &response.completion) .into_iter() .map(|(range, text)| { ( - snapshot.anchor_after(response.start_index + range.start) - ..snapshot.anchor_before(response.start_index + range.end), + inputs + .snapshot + .anchor_after(response.start_index + range.start) + ..inputs + .snapshot + .anchor_before(response.start_index + range.end), text, ) }) @@ -254,13 +241,13 @@ impl SweepAi { anyhow::Ok(( response.autocomplete_id, edits, - snapshot, + inputs.snapshot, response_received_at, - inputs, + ep_inputs, )) }); - let buffer = active_buffer.clone(); + let buffer = inputs.buffer.clone(); cx.spawn(async move |cx| { let (id, edits, old_snapshot, response_received_at, inputs) = result.await?; @@ -281,46 +268,28 @@ impl SweepAi { } } -pub const SWEEP_CREDENTIALS_URL: &str = "https://autocomplete.sweep.dev"; +pub const SWEEP_CREDENTIALS_URL: SharedString = + SharedString::new_static("https://autocomplete.sweep.dev"); pub const SWEEP_CREDENTIALS_USERNAME: &str = "sweep-api-token"; +pub static SWEEP_AI_TOKEN_ENV_VAR: std::sync::LazyLock = env_var!("SWEEP_AI_TOKEN"); + +struct GlobalSweepApiKey(Entity); + +impl Global for GlobalSweepApiKey {} -pub fn load_api_token(cx: &App) -> Task> { - if let Some(api_token) = std::env::var("SWEEP_AI_TOKEN") - .ok() - .filter(|value| !value.is_empty()) - { - return Task::ready(Some(api_token)); +pub fn sweep_api_token(cx: &mut App) -> Entity { + if let Some(global) = cx.try_global::() { + return global.0.clone(); } - let credentials_provider = ::global(cx); - cx.spawn(async move |cx| { - let (_, credentials) = credentials_provider - .read_credentials(SWEEP_CREDENTIALS_URL, &cx) - .await - .ok()??; - String::from_utf8(credentials).ok() - }) + let entity = + cx.new(|_| ApiKeyState::new(SWEEP_CREDENTIALS_URL, SWEEP_AI_TOKEN_ENV_VAR.clone())); + cx.set_global(GlobalSweepApiKey(entity.clone())); + entity } -fn store_api_token_in_keychain(api_token: Option, cx: &App) -> Task> { - let credentials_provider = ::global(cx); - - cx.spawn(async move |cx| { - if let Some(api_token) = api_token { - credentials_provider - .write_credentials( - SWEEP_CREDENTIALS_URL, - SWEEP_CREDENTIALS_USERNAME, - api_token.as_bytes(), - cx, - ) - .await - .context("Failed to save Sweep API token to system keychain") - } else { - credentials_provider - .delete_credentials(SWEEP_CREDENTIALS_URL, cx) - .await - .context("Failed to delete Sweep API token from system keychain") - } +pub fn load_sweep_api_token(cx: &mut App) -> Task> { + sweep_api_token(cx).update(cx, |key_state, cx| { + key_state.load_if_needed(SWEEP_CREDENTIALS_URL, |s| s, cx) }) } @@ -403,12 +372,9 @@ struct AdditionalCompletion { pub finish_reason: Option, } -fn write_event( - event: &cloud_llm_client::predict_edits_v3::Event, - f: &mut impl fmt::Write, -) -> fmt::Result { +fn write_event(event: &zeta_prompt::Event, f: &mut impl fmt::Write) -> fmt::Result { match event { - cloud_llm_client::predict_edits_v3::Event::BufferChange { + zeta_prompt::Event::BufferChange { old_path, path, diff, diff --git a/crates/edit_prediction/src/udiff.rs b/crates/edit_prediction/src/udiff.rs index 5ae029c6c16c2c6b6d0c2451cc961e8399a64a8f..78fec03dd78301d56ac6e3f914ba60432e41637d 100644 --- a/crates/edit_prediction/src/udiff.rs +++ b/crates/edit_prediction/src/udiff.rs @@ -14,87 +14,48 @@ use anyhow::anyhow; use collections::HashMap; use gpui::AsyncApp; use gpui::Entity; -use language::{Anchor, Buffer, BufferSnapshot, OffsetRangeExt as _, TextBufferSnapshot}; -use project::Project; +use language::{Anchor, Buffer, OffsetRangeExt as _, TextBufferSnapshot}; +use project::{Project, ProjectPath}; +use util::paths::PathStyle; +use util::rel_path::RelPath; -pub async fn parse_diff<'a>( - diff_str: &'a str, - get_buffer: impl Fn(&Path) -> Option<(&'a BufferSnapshot, &'a [Range])> + Send, -) -> Result<(&'a BufferSnapshot, Vec<(Range, Arc)>)> { - let mut diff = DiffParser::new(diff_str); - let mut edited_buffer = None; - let mut edits = Vec::new(); - - while let Some(event) = diff.next()? { - match event { - DiffEvent::Hunk { - path: file_path, - hunk, - } => { - let (buffer, ranges) = match edited_buffer { - None => { - edited_buffer = get_buffer(&Path::new(file_path.as_ref())); - edited_buffer - .as_ref() - .context("Model tried to edit a file that wasn't included")? - } - Some(ref current) => current, - }; - - edits.extend( - resolve_hunk_edits_in_buffer(hunk, &buffer.text, ranges) - .with_context(|| format!("Diff:\n{diff_str}"))?, - ); - } - DiffEvent::FileEnd { renamed_to } => { - let (buffer, _) = edited_buffer - .take() - .context("Got a FileEnd event before an Hunk event")?; - - if renamed_to.is_some() { - anyhow::bail!("edit predictions cannot rename files"); - } - - if diff.next()?.is_some() { - anyhow::bail!("Edited more than one file"); - } - - return Ok((buffer, edits)); - } - } - } - - Err(anyhow::anyhow!("No EOF")) -} - -#[derive(Debug)] -pub struct OpenedBuffers<'a>(#[allow(unused)] HashMap, Entity>); +#[derive(Clone, Debug)] +pub struct OpenedBuffers(#[allow(unused)] HashMap>); #[must_use] -pub async fn apply_diff<'a>( - diff_str: &'a str, +pub async fn apply_diff( + diff_str: &str, project: &Entity, cx: &mut AsyncApp, -) -> Result> { +) -> Result { let mut included_files = HashMap::default(); + let worktree_id = project.read_with(cx, |project, cx| { + anyhow::Ok( + project + .visible_worktrees(cx) + .next() + .context("no worktrees")? + .read(cx) + .id(), + ) + })??; + for line in diff_str.lines() { let diff_line = DiffLine::parse(line); if let DiffLine::OldPath { path } = diff_line { let buffer = project .update(cx, |project, cx| { - let project_path = - project - .find_project_path(path.as_ref(), cx) - .with_context(|| { - format!("Failed to find worktree for new path: {}", path) - })?; + let project_path = ProjectPath { + worktree_id, + path: RelPath::new(Path::new(path.as_ref()), PathStyle::Posix)?.into_arc(), + }; anyhow::Ok(project.open_buffer(project_path, cx)) })?? .await?; - included_files.insert(path, buffer); + included_files.insert(path.to_string(), buffer); } } @@ -113,7 +74,7 @@ pub async fn apply_diff<'a>( let (buffer, ranges) = match current_file { None => { let buffer = included_files - .get_mut(&file_path) + .get_mut(file_path.as_ref()) .expect("Opened all files in diff"); current_file = Some((buffer, ranges.as_slice())); @@ -167,6 +128,29 @@ pub async fn apply_diff<'a>( Ok(OpenedBuffers(included_files)) } +pub fn apply_diff_to_string(diff_str: &str, text: &str) -> Result { + let mut diff = DiffParser::new(diff_str); + + let mut text = text.to_string(); + + while let Some(event) = diff.next()? { + match event { + DiffEvent::Hunk { hunk, .. } => { + let hunk_offset = text + .find(&hunk.context) + .ok_or_else(|| anyhow!("couldn't resolve hunk {:?}", hunk.context))?; + for edit in hunk.edits.iter().rev() { + let range = (hunk_offset + edit.range.start)..(hunk_offset + edit.range.end); + text.replace_range(range, &edit.text); + } + } + DiffEvent::FileEnd { .. } => {} + } + } + + Ok(text) +} + struct PatchFile<'a> { old_path: Cow<'a, str>, new_path: Cow<'a, str>, @@ -492,7 +476,6 @@ mod tests { use super::*; use gpui::TestAppContext; use indoc::indoc; - use language::Point; use pretty_assertions::assert_eq; use project::{FakeFs, Project}; use serde_json::json; @@ -754,38 +737,38 @@ mod tests { let project = Project::test(fs, [path!("/root").as_ref()], cx).await; let diff = indoc! {r#" - --- a/root/file1 - +++ b/root/file1 + --- a/file1 + +++ b/file1 one two -three +3 four five - --- a/root/file1 - +++ b/root/file1 + --- a/file1 + +++ b/file1 3 -four -five +4 +5 - --- a/root/file1 - +++ b/root/file1 + --- a/file1 + +++ b/file1 -one -two 3 4 - --- a/root/file2 - +++ b/root/file2 + --- a/file2 + +++ b/file2 +5 six - --- a/root/file2 - +++ b/root/file2 + --- a/file2 + +++ b/file2 seven +7.5 eight - --- a/root/file2 - +++ b/root/file2 + --- a/file2 + +++ b/file2 ten +11 "#}; @@ -817,137 +800,6 @@ mod tests { }); } - #[gpui::test] - async fn test_apply_diff_non_unique(cx: &mut TestAppContext) { - let fs = init_test(cx); - - let buffer_1_text = indoc! {r#" - one - two - three - four - five - one - two - three - four - five - "# }; - - fs.insert_tree( - path!("/root"), - json!({ - "file1": buffer_1_text, - }), - ) - .await; - - let project = Project::test(fs, [path!("/root").as_ref()], cx).await; - let buffer = project - .update(cx, |project, cx| { - project.open_local_buffer(path!("/root/file1"), cx) - }) - .await - .unwrap(); - let buffer_snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); - - let diff = indoc! {r#" - --- a/root/file1 - +++ b/root/file1 - one - two - -three - +3 - four - five - "#}; - - let final_text = indoc! {r#" - one - two - three - four - five - one - two - 3 - four - five - "#}; - - apply_diff(diff, &project, &mut cx.to_async()) - .await - .expect_err("Non-unique edits should fail"); - - let ranges = [buffer_snapshot.anchor_before(Point::new(1, 0)) - ..buffer_snapshot.anchor_after(buffer_snapshot.max_point())]; - - let (edited_snapshot, edits) = parse_diff(diff, |_path| Some((&buffer_snapshot, &ranges))) - .await - .unwrap(); - - assert_eq!(edited_snapshot.remote_id(), buffer_snapshot.remote_id()); - buffer.update(cx, |buffer, cx| { - buffer.edit(edits, None, cx); - assert_eq!(buffer.text(), final_text); - }); - } - - #[gpui::test] - async fn test_parse_diff_with_edits_within_line(cx: &mut TestAppContext) { - let fs = init_test(cx); - - let buffer_1_text = indoc! {r#" - one two three four - five six seven eight - nine ten eleven twelve - "# }; - - fs.insert_tree( - path!("/root"), - json!({ - "file1": buffer_1_text, - }), - ) - .await; - - let project = Project::test(fs, [path!("/root").as_ref()], cx).await; - let buffer = project - .update(cx, |project, cx| { - project.open_local_buffer(path!("/root/file1"), cx) - }) - .await - .unwrap(); - let buffer_snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); - - let diff = indoc! {r#" - --- a/root/file1 - +++ b/root/file1 - one two three four - -five six seven eight - +five SIX seven eight! - nine ten eleven twelve - "#}; - - let (buffer, edits) = parse_diff(diff, |_path| { - Some((&buffer_snapshot, &[(Anchor::MIN..Anchor::MAX)] as &[_])) - }) - .await - .unwrap(); - - let edits = edits - .into_iter() - .map(|(range, text)| (range.to_point(&buffer), text)) - .collect::>(); - assert_eq!( - edits, - &[ - (Point::new(1, 5)..Point::new(1, 8), "SIX".into()), - (Point::new(1, 20)..Point::new(1, 20), "!".into()) - ] - ); - } - #[gpui::test] async fn test_apply_diff_unique_via_previous_context(cx: &mut TestAppContext) { let fs = init_test(cx); @@ -985,8 +837,8 @@ mod tests { let project = Project::test(fs, [path!("/root").as_ref()], cx).await; let diff = indoc! {r#" - --- a/root/file1 - +++ b/root/file1 + --- a/file1 + +++ b/file1 one two -three diff --git a/crates/edit_prediction/src/xml_edits.rs b/crates/edit_prediction/src/xml_edits.rs deleted file mode 100644 index ee8dd47cb25ad3dcd2c3d7d172b62e724b41c22d..0000000000000000000000000000000000000000 --- a/crates/edit_prediction/src/xml_edits.rs +++ /dev/null @@ -1,637 +0,0 @@ -use anyhow::{Context as _, Result}; -use language::{Anchor, BufferSnapshot, OffsetRangeExt as _, Point}; -use std::{cmp, ops::Range, path::Path, sync::Arc}; - -const EDITS_TAG_NAME: &'static str = "edits"; -const OLD_TEXT_TAG_NAME: &'static str = "old_text"; -const NEW_TEXT_TAG_NAME: &'static str = "new_text"; -const XML_TAGS: &[&str] = &[EDITS_TAG_NAME, OLD_TEXT_TAG_NAME, NEW_TEXT_TAG_NAME]; - -pub async fn parse_xml_edits<'a>( - input: &'a str, - get_buffer: impl Fn(&Path) -> Option<(&'a BufferSnapshot, &'a [Range])> + Send, -) -> Result<(&'a BufferSnapshot, Vec<(Range, Arc)>)> { - parse_xml_edits_inner(input, get_buffer) - .await - .with_context(|| format!("Failed to parse XML edits:\n{input}")) -} - -async fn parse_xml_edits_inner<'a>( - input: &'a str, - get_buffer: impl Fn(&Path) -> Option<(&'a BufferSnapshot, &'a [Range])> + Send, -) -> Result<(&'a BufferSnapshot, Vec<(Range, Arc)>)> { - let xml_edits = extract_xml_replacements(input)?; - - let (buffer, context_ranges) = get_buffer(xml_edits.file_path.as_ref()) - .with_context(|| format!("no buffer for file {}", xml_edits.file_path))?; - - let mut all_edits = vec![]; - for (old_text, new_text) in xml_edits.replacements { - let match_range = fuzzy_match_in_ranges(old_text, buffer, context_ranges)?; - let matched_old_text = buffer - .text_for_range(match_range.clone()) - .collect::(); - let edits_within_hunk = language::text_diff(&matched_old_text, new_text); - all_edits.extend( - edits_within_hunk - .into_iter() - .map(move |(inner_range, inner_text)| { - ( - buffer.anchor_after(match_range.start + inner_range.start) - ..buffer.anchor_before(match_range.start + inner_range.end), - inner_text, - ) - }), - ); - } - - Ok((buffer, all_edits)) -} - -fn fuzzy_match_in_ranges( - old_text: &str, - buffer: &BufferSnapshot, - context_ranges: &[Range], -) -> Result> { - let mut state = FuzzyMatcher::new(buffer, old_text); - let mut best_match = None; - let mut tie_match_range = None; - - for range in context_ranges { - let best_match_cost = best_match.as_ref().map(|(score, _)| *score); - match (best_match_cost, state.match_range(range.to_offset(buffer))) { - (Some(lowest_cost), Some((new_cost, new_range))) => { - if new_cost == lowest_cost { - tie_match_range = Some(new_range); - } else if new_cost < lowest_cost { - tie_match_range.take(); - best_match = Some((new_cost, new_range)); - } - } - (None, Some(new_match)) => { - best_match = Some(new_match); - } - (None, None) | (Some(_), None) => {} - }; - } - - if let Some((_, best_match_range)) = best_match { - if let Some(tie_match_range) = tie_match_range { - anyhow::bail!( - "Multiple ambiguous matches:\n{:?}:\n{}\n\n{:?}:\n{}", - best_match_range.clone(), - buffer.text_for_range(best_match_range).collect::(), - tie_match_range.clone(), - buffer.text_for_range(tie_match_range).collect::() - ); - } - return Ok(best_match_range); - } - - anyhow::bail!( - "Failed to fuzzy match `old_text`:\n{}\nin:\n```\n{}\n```", - old_text, - context_ranges - .iter() - .map(|range| buffer.text_for_range(range.clone()).collect::()) - .collect::>() - .join("```\n```") - ); -} - -#[derive(Debug)] -struct XmlEdits<'a> { - file_path: &'a str, - /// Vec of (old_text, new_text) pairs - replacements: Vec<(&'a str, &'a str)>, -} - -fn extract_xml_replacements(input: &str) -> Result> { - let mut cursor = 0; - - let (edits_body_start, edits_attrs) = - find_tag_open(input, &mut cursor, EDITS_TAG_NAME)?.context("No edits tag found")?; - - let file_path = edits_attrs - .trim_start() - .strip_prefix("path") - .context("no path attribute on edits tag")? - .trim_end() - .strip_prefix('=') - .context("no value for path attribute")? - .trim() - .trim_start_matches('"') - .trim_end_matches('"'); - - cursor = edits_body_start; - let mut edits_list = Vec::new(); - - while let Some((old_body_start, _)) = find_tag_open(input, &mut cursor, OLD_TEXT_TAG_NAME)? { - let old_body_end = find_tag_close(input, &mut cursor)?; - let old_text = trim_surrounding_newlines(&input[old_body_start..old_body_end]); - - let (new_body_start, _) = find_tag_open(input, &mut cursor, NEW_TEXT_TAG_NAME)? - .context("no new_text tag following old_text")?; - let new_body_end = find_tag_close(input, &mut cursor)?; - let new_text = trim_surrounding_newlines(&input[new_body_start..new_body_end]); - - edits_list.push((old_text, new_text)); - } - - Ok(XmlEdits { - file_path, - replacements: edits_list, - }) -} - -/// Trims a single leading and trailing newline -fn trim_surrounding_newlines(input: &str) -> &str { - let start = input.strip_prefix('\n').unwrap_or(input); - let end = start.strip_suffix('\n').unwrap_or(start); - end -} - -fn find_tag_open<'a>( - input: &'a str, - cursor: &mut usize, - expected_tag: &str, -) -> Result> { - let mut search_pos = *cursor; - - while search_pos < input.len() { - let Some(tag_start) = input[search_pos..].find("<") else { - break; - }; - let tag_start = search_pos + tag_start; - if !input[tag_start + 1..].starts_with(expected_tag) { - search_pos = search_pos + tag_start + 1; - continue; - }; - - let after_tag_name = tag_start + expected_tag.len() + 1; - let close_bracket = input[after_tag_name..] - .find('>') - .with_context(|| format!("missing > after <{}", expected_tag))?; - let attrs_end = after_tag_name + close_bracket; - let body_start = attrs_end + 1; - - let attributes = input[after_tag_name..attrs_end].trim(); - *cursor = body_start; - - return Ok(Some((body_start, attributes))); - } - - Ok(None) -} - -fn find_tag_close(input: &str, cursor: &mut usize) -> Result { - let mut depth = 1; - let mut search_pos = *cursor; - - while search_pos < input.len() && depth > 0 { - let Some(bracket_offset) = input[search_pos..].find('<') else { - break; - }; - let bracket_pos = search_pos + bracket_offset; - - if input[bracket_pos..].starts_with("') - { - let close_start = bracket_pos + 2; - let tag_name = input[close_start..close_start + close_end].trim(); - - if XML_TAGS.contains(&tag_name) { - depth -= 1; - if depth == 0 { - *cursor = close_start + close_end + 1; - return Ok(bracket_pos); - } - } - search_pos = close_start + close_end + 1; - continue; - } else if let Some(close_bracket_offset) = input[bracket_pos..].find('>') { - let close_bracket_pos = bracket_pos + close_bracket_offset; - let tag_name = &input[bracket_pos + 1..close_bracket_pos].trim(); - if XML_TAGS.contains(&tag_name) { - depth += 1; - } - } - - search_pos = bracket_pos + 1; - } - - anyhow::bail!("no closing tag found") -} - -const REPLACEMENT_COST: u32 = 1; -const INSERTION_COST: u32 = 3; -const DELETION_COST: u32 = 10; - -/// A fuzzy matcher that can process text chunks incrementally -/// and return the best match found so far at each step. -struct FuzzyMatcher<'a> { - snapshot: &'a BufferSnapshot, - query_lines: Vec<&'a str>, - matrix: SearchMatrix, -} - -impl<'a> FuzzyMatcher<'a> { - fn new(snapshot: &'a BufferSnapshot, old_text: &'a str) -> Self { - let query_lines = old_text.lines().collect(); - Self { - snapshot, - query_lines, - matrix: SearchMatrix::new(0), - } - } - - fn match_range(&mut self, range: Range) -> Option<(u32, Range)> { - let point_range = range.to_point(&self.snapshot); - let buffer_line_count = (point_range.end.row - point_range.start.row + 1) as usize; - - self.matrix - .reset(self.query_lines.len() + 1, buffer_line_count + 1); - let query_line_count = self.query_lines.len(); - - for row in 0..query_line_count { - let query_line = self.query_lines[row].trim(); - let leading_deletion_cost = (row + 1) as u32 * DELETION_COST; - - self.matrix.set( - row + 1, - 0, - SearchState::new(leading_deletion_cost, SearchDirection::Up), - ); - - let mut buffer_lines = self.snapshot.text_for_range(range.clone()).lines(); - - let mut col = 0; - while let Some(buffer_line) = buffer_lines.next() { - let buffer_line = buffer_line.trim(); - let up = SearchState::new( - self.matrix - .get(row, col + 1) - .cost - .saturating_add(DELETION_COST), - SearchDirection::Up, - ); - let left = SearchState::new( - self.matrix - .get(row + 1, col) - .cost - .saturating_add(INSERTION_COST), - SearchDirection::Left, - ); - let diagonal = SearchState::new( - if query_line == buffer_line { - self.matrix.get(row, col).cost - } else if fuzzy_eq(query_line, buffer_line) { - self.matrix.get(row, col).cost + REPLACEMENT_COST - } else { - self.matrix - .get(row, col) - .cost - .saturating_add(DELETION_COST + INSERTION_COST) - }, - SearchDirection::Diagonal, - ); - self.matrix - .set(row + 1, col + 1, up.min(left).min(diagonal)); - col += 1; - } - } - - // Find all matches with the best cost - let mut best_cost = u32::MAX; - let mut matches_with_best_cost = Vec::new(); - - for col in 1..=buffer_line_count { - let cost = self.matrix.get(query_line_count, col).cost; - if cost < best_cost { - best_cost = cost; - matches_with_best_cost.clear(); - matches_with_best_cost.push(col as u32); - } else if cost == best_cost { - matches_with_best_cost.push(col as u32); - } - } - - // Find ranges for the matches - for &match_end_col in &matches_with_best_cost { - let mut matched_lines = 0; - let mut query_row = query_line_count; - let mut match_start_col = match_end_col; - while query_row > 0 && match_start_col > 0 { - let current = self.matrix.get(query_row, match_start_col as usize); - match current.direction { - SearchDirection::Diagonal => { - query_row -= 1; - match_start_col -= 1; - matched_lines += 1; - } - SearchDirection::Up => { - query_row -= 1; - } - SearchDirection::Left => { - match_start_col -= 1; - } - } - } - - let buffer_row_start = match_start_col + point_range.start.row; - let buffer_row_end = match_end_col + point_range.start.row; - - let matched_buffer_row_count = buffer_row_end - buffer_row_start; - let matched_ratio = matched_lines as f32 - / (matched_buffer_row_count as f32).max(query_line_count as f32); - if matched_ratio >= 0.8 { - let buffer_start_ix = self - .snapshot - .point_to_offset(Point::new(buffer_row_start, 0)); - let buffer_end_ix = self.snapshot.point_to_offset(Point::new( - buffer_row_end - 1, - self.snapshot.line_len(buffer_row_end - 1), - )); - return Some((best_cost, buffer_start_ix..buffer_end_ix)); - } - } - - None - } -} - -fn fuzzy_eq(left: &str, right: &str) -> bool { - const THRESHOLD: f64 = 0.8; - - let min_levenshtein = left.len().abs_diff(right.len()); - let min_normalized_levenshtein = - 1. - (min_levenshtein as f64 / cmp::max(left.len(), right.len()) as f64); - if min_normalized_levenshtein < THRESHOLD { - return false; - } - - strsim::normalized_levenshtein(left, right) >= THRESHOLD -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] -enum SearchDirection { - Up, - Left, - Diagonal, -} - -#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] -struct SearchState { - cost: u32, - direction: SearchDirection, -} - -impl SearchState { - fn new(cost: u32, direction: SearchDirection) -> Self { - Self { cost, direction } - } -} - -struct SearchMatrix { - cols: usize, - rows: usize, - data: Vec, -} - -impl SearchMatrix { - fn new(cols: usize) -> Self { - SearchMatrix { - cols, - rows: 0, - data: Vec::new(), - } - } - - fn reset(&mut self, rows: usize, cols: usize) { - self.rows = rows; - self.cols = cols; - self.data - .fill(SearchState::new(0, SearchDirection::Diagonal)); - self.data.resize( - self.rows * self.cols, - SearchState::new(0, SearchDirection::Diagonal), - ); - } - - fn get(&self, row: usize, col: usize) -> SearchState { - debug_assert!(row < self.rows); - debug_assert!(col < self.cols); - self.data[row * self.cols + col] - } - - fn set(&mut self, row: usize, col: usize, state: SearchState) { - debug_assert!(row < self.rows && col < self.cols); - self.data[row * self.cols + col] = state; - } -} - -#[cfg(test)] -mod tests { - use super::*; - use gpui::TestAppContext; - use indoc::indoc; - use language::Point; - use project::{FakeFs, Project}; - use serde_json::json; - use settings::SettingsStore; - use util::path; - - #[test] - fn test_extract_xml_edits() { - let input = indoc! {r#" - - - old content - - - new content - - - "#}; - - let result = extract_xml_replacements(input).unwrap(); - assert_eq!(result.file_path, "test.rs"); - assert_eq!(result.replacements.len(), 1); - assert_eq!(result.replacements[0].0, "old content"); - assert_eq!(result.replacements[0].1, "new content"); - } - - #[test] - fn test_extract_xml_edits_with_wrong_closing_tags() { - let input = indoc! {r#" - - - old content - - - new content - - - "#}; - - let result = extract_xml_replacements(input).unwrap(); - assert_eq!(result.file_path, "test.rs"); - assert_eq!(result.replacements.len(), 1); - assert_eq!(result.replacements[0].0, "old content"); - assert_eq!(result.replacements[0].1, "new content"); - } - - #[test] - fn test_extract_xml_edits_with_xml_like_content() { - let input = indoc! {r#" - - - - - - - - - "#}; - - let result = extract_xml_replacements(input).unwrap(); - assert_eq!(result.file_path, "component.tsx"); - assert_eq!(result.replacements.len(), 1); - assert_eq!(result.replacements[0].0, ""); - assert_eq!( - result.replacements[0].1, - "" - ); - } - - #[test] - fn test_extract_xml_edits_with_conflicting_content() { - let input = indoc! {r#" - - - - - - - - - "#}; - - let result = extract_xml_replacements(input).unwrap(); - assert_eq!(result.file_path, "component.tsx"); - assert_eq!(result.replacements.len(), 1); - assert_eq!(result.replacements[0].0, ""); - assert_eq!(result.replacements[0].1, ""); - } - - #[test] - fn test_extract_xml_edits_multiple_pairs() { - let input = indoc! {r#" - Some reasoning before edits. Lots of thinking going on here - - - - first old - - - first new - - - second old - - - second new - - - "#}; - - let result = extract_xml_replacements(input).unwrap(); - assert_eq!(result.file_path, "test.rs"); - assert_eq!(result.replacements.len(), 2); - assert_eq!(result.replacements[0].0, "first old"); - assert_eq!(result.replacements[0].1, "first new"); - assert_eq!(result.replacements[1].0, "second old"); - assert_eq!(result.replacements[1].1, "second new"); - } - - #[test] - fn test_extract_xml_edits_unexpected_eof() { - let input = indoc! {r#" - - - first old - - - nine ten eleven twelve - - - nine TEN eleven twelve! - - - "#}; - - let included_ranges = [(buffer_snapshot.anchor_before(Point::new(1, 0))..Anchor::MAX)]; - let (buffer, edits) = parse_xml_edits(edits, |_path| { - Some((&buffer_snapshot, included_ranges.as_slice())) - }) - .await - .unwrap(); - - let edits = edits - .into_iter() - .map(|(range, text)| (range.to_point(&buffer), text)) - .collect::>(); - assert_eq!( - edits, - &[ - (Point::new(2, 5)..Point::new(2, 8), "TEN".into()), - (Point::new(2, 22)..Point::new(2, 22), "!".into()) - ] - ); - } - - fn init_test(cx: &mut TestAppContext) -> Arc { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - }); - - FakeFs::new(cx.background_executor.clone()) - } -} diff --git a/crates/edit_prediction/src/zed_edit_prediction_delegate.rs b/crates/edit_prediction/src/zed_edit_prediction_delegate.rs index 91371d539beca012e2ded4e9ec8702c8db39bd8a..289bcd76daab2b9a4b82db88b86285e6c7aca00d 100644 --- a/crates/edit_prediction/src/zed_edit_prediction_delegate.rs +++ b/crates/edit_prediction/src/zed_edit_prediction_delegate.rs @@ -2,7 +2,7 @@ use std::{cmp, sync::Arc}; use client::{Client, UserStore}; use cloud_llm_client::EditPredictionRejectReason; -use edit_prediction_types::{DataCollectionState, Direction, EditPredictionDelegate}; +use edit_prediction_types::{DataCollectionState, EditPredictionDelegate}; use gpui::{App, Entity, prelude::*}; use language::{Buffer, ToPoint as _}; use project::Project; @@ -100,7 +100,7 @@ impl EditPredictionDelegate for ZedEditPredictionDelegate { ) -> bool { let store = self.store.read(cx); if store.edit_prediction_model == EditPredictionModel::Sweep { - store.has_sweep_api_token() + store.has_sweep_api_token(cx) } else { true } @@ -125,28 +125,20 @@ impl EditPredictionDelegate for ZedEditPredictionDelegate { return; } - if let Some(current) = store.current_prediction_for_buffer(&buffer, &self.project, cx) - && let BufferEditPrediction::Local { prediction } = current - && prediction.interpolate(buffer.read(cx)).is_some() - { - return; - } - self.store.update(cx, |store, cx| { + if let Some(current) = + store.prediction_at(&buffer, Some(cursor_position), &self.project, cx) + && let BufferEditPrediction::Local { prediction } = current + && prediction.interpolate(buffer.read(cx)).is_some() + { + return; + } + store.refresh_context(&self.project, &buffer, cursor_position, cx); store.refresh_prediction_from_buffer(self.project.clone(), buffer, cursor_position, cx) }); } - fn cycle( - &mut self, - _buffer: Entity, - _cursor_position: language::Anchor, - _direction: Direction, - _cx: &mut Context, - ) { - } - fn accept(&mut self, cx: &mut Context) { self.store.update(cx, |store, cx| { store.accept_current_prediction(&self.project, cx); @@ -171,69 +163,68 @@ impl EditPredictionDelegate for ZedEditPredictionDelegate { cursor_position: language::Anchor, cx: &mut Context, ) -> Option { - let prediction = - self.store - .read(cx) - .current_prediction_for_buffer(buffer, &self.project, cx)?; - - let prediction = match prediction { - BufferEditPrediction::Local { prediction } => prediction, - BufferEditPrediction::Jump { prediction } => { - return Some(edit_prediction_types::EditPrediction::Jump { - id: Some(prediction.id.to_string().into()), - snapshot: prediction.snapshot.clone(), - target: prediction.edits.first().unwrap().0.start, - }); - } - }; + self.store.update(cx, |store, cx| { + let prediction = + store.prediction_at(buffer, Some(cursor_position), &self.project, cx)?; + + let prediction = match prediction { + BufferEditPrediction::Local { prediction } => prediction, + BufferEditPrediction::Jump { prediction } => { + return Some(edit_prediction_types::EditPrediction::Jump { + id: Some(prediction.id.to_string().into()), + snapshot: prediction.snapshot.clone(), + target: prediction.edits.first().unwrap().0.start, + }); + } + }; - let buffer = buffer.read(cx); - let snapshot = buffer.snapshot(); + let buffer = buffer.read(cx); + let snapshot = buffer.snapshot(); - let Some(edits) = prediction.interpolate(&snapshot) else { - self.store.update(cx, |store, _cx| { + let Some(edits) = prediction.interpolate(&snapshot) else { store.reject_current_prediction( EditPredictionRejectReason::InterpolatedEmpty, &self.project, ); - }); - return None; - }; - - let cursor_row = cursor_position.to_point(&snapshot).row; - let (closest_edit_ix, (closest_edit_range, _)) = - edits.iter().enumerate().min_by_key(|(_, (range, _))| { - let distance_from_start = cursor_row.abs_diff(range.start.to_point(&snapshot).row); - let distance_from_end = cursor_row.abs_diff(range.end.to_point(&snapshot).row); - cmp::min(distance_from_start, distance_from_end) - })?; - - let mut edit_start_ix = closest_edit_ix; - for (range, _) in edits[..edit_start_ix].iter().rev() { - let distance_from_closest_edit = closest_edit_range.start.to_point(&snapshot).row - - range.end.to_point(&snapshot).row; - if distance_from_closest_edit <= 1 { - edit_start_ix -= 1; - } else { - break; + return None; + }; + + let cursor_row = cursor_position.to_point(&snapshot).row; + let (closest_edit_ix, (closest_edit_range, _)) = + edits.iter().enumerate().min_by_key(|(_, (range, _))| { + let distance_from_start = + cursor_row.abs_diff(range.start.to_point(&snapshot).row); + let distance_from_end = cursor_row.abs_diff(range.end.to_point(&snapshot).row); + cmp::min(distance_from_start, distance_from_end) + })?; + + let mut edit_start_ix = closest_edit_ix; + for (range, _) in edits[..edit_start_ix].iter().rev() { + let distance_from_closest_edit = closest_edit_range.start.to_point(&snapshot).row + - range.end.to_point(&snapshot).row; + if distance_from_closest_edit <= 1 { + edit_start_ix -= 1; + } else { + break; + } } - } - let mut edit_end_ix = closest_edit_ix + 1; - for (range, _) in &edits[edit_end_ix..] { - let distance_from_closest_edit = - range.start.to_point(buffer).row - closest_edit_range.end.to_point(&snapshot).row; - if distance_from_closest_edit <= 1 { - edit_end_ix += 1; - } else { - break; + let mut edit_end_ix = closest_edit_ix + 1; + for (range, _) in &edits[edit_end_ix..] { + let distance_from_closest_edit = range.start.to_point(buffer).row + - closest_edit_range.end.to_point(&snapshot).row; + if distance_from_closest_edit <= 1 { + edit_end_ix += 1; + } else { + break; + } } - } - Some(edit_prediction_types::EditPrediction::Local { - id: Some(prediction.id.to_string().into()), - edits: edits[edit_start_ix..edit_end_ix].to_vec(), - edit_preview: Some(prediction.edit_preview.clone()), + Some(edit_prediction_types::EditPrediction::Local { + id: Some(prediction.id.to_string().into()), + edits: edits[edit_start_ix..edit_end_ix].to_vec(), + edit_preview: Some(prediction.edit_preview.clone()), + }) }) } } diff --git a/crates/edit_prediction/src/zeta1.rs b/crates/edit_prediction/src/zeta1.rs index ad630484d392d75849bd33a52a55e63ea77ca23f..01c26573307e66cd6ca3bf8ab748ba8d082ea688 100644 --- a/crates/edit_prediction/src/zeta1.rs +++ b/crates/edit_prediction/src/zeta1.rs @@ -1,22 +1,23 @@ use std::{fmt::Write, ops::Range, path::Path, sync::Arc, time::Instant}; use crate::{ - EditPredictionId, EditPredictionStore, ZedUpdateRequiredError, + DebugEvent, EditPredictionFinishedDebugEvent, EditPredictionId, EditPredictionModelInput, + EditPredictionStartedDebugEvent, EditPredictionStore, ZedUpdateRequiredError, cursor_excerpt::{editable_and_context_ranges_for_cursor_position, guess_token_count}, - prediction::{EditPredictionInputs, EditPredictionResult}, + prediction::EditPredictionResult, }; use anyhow::{Context as _, Result}; use cloud_llm_client::{ PredictEditsBody, PredictEditsGitInfo, PredictEditsRequestTrigger, PredictEditsResponse, - predict_edits_v3::Event, }; use gpui::{App, AppContext as _, AsyncApp, Context, Entity, SharedString, Task}; use language::{ - Anchor, Buffer, BufferSnapshot, OffsetRangeExt as _, Point, ToPoint as _, text_diff, + Anchor, Buffer, BufferSnapshot, OffsetRangeExt as _, Point, ToOffset, ToPoint as _, text_diff, }; use project::{Project, ProjectPath}; use release_channel::AppVersion; use workspace::notifications::{ErrorMessagePrompt, NotificationId, show_app_notification}; +use zeta_prompt::{Event, ZetaPromptInput}; const CURSOR_MARKER: &str = "<|user_cursor_is_here|>"; const START_OF_FILE_MARKER: &str = "<|start_of_file|>"; @@ -29,24 +30,27 @@ pub(crate) const MAX_EVENT_TOKENS: usize = 500; pub(crate) fn request_prediction_with_zeta1( store: &mut EditPredictionStore, - project: &Entity, - buffer: &Entity, - snapshot: BufferSnapshot, - position: language::Anchor, - events: Vec>, - trigger: PredictEditsRequestTrigger, + EditPredictionModelInput { + project, + buffer, + snapshot, + position, + events, + trigger, + debug_tx, + .. + }: EditPredictionModelInput, cx: &mut Context, ) -> Task>> { - let buffer = buffer.clone(); let buffer_snapshotted_at = Instant::now(); let client = store.client.clone(); let llm_token = store.llm_token.clone(); let app_version = AppVersion::global(cx); let (git_info, can_collect_file) = if let Some(file) = snapshot.file() { - let can_collect_file = store.can_collect_file(project, file, cx); + let can_collect_file = store.can_collect_file(&project, file, cx); let git_info = if can_collect_file { - git_info_for_file(project, &ProjectPath::from_file(file.as_ref(), cx), cx) + git_info_for_file(&project, &ProjectPath::from_file(file.as_ref(), cx), cx) } else { None }; @@ -74,6 +78,19 @@ pub(crate) fn request_prediction_with_zeta1( cx, ); + let (uri, require_auth) = match &store.custom_predict_edits_url { + Some(custom_url) => (custom_url.clone(), false), + None => { + match client + .http_client() + .build_zed_llm_url("/predict_edits/v2", &[]) + { + Ok(url) => (url.into(), true), + Err(err) => return Task::ready(Err(err)), + } + } + }; + cx.spawn(async move |this, cx| { let GatherContextOutput { mut body, @@ -98,55 +115,46 @@ pub(crate) fn request_prediction_with_zeta1( body.input_excerpt ); - let http_client = client.http_client(); - let response = EditPredictionStore::send_api_request::( |request| { - let uri = if let Ok(predict_edits_url) = std::env::var("ZED_PREDICT_EDITS_URL") { - predict_edits_url - } else { - http_client - .build_zed_llm_url("/predict_edits/v2", &[])? - .as_str() - .into() - }; Ok(request - .uri(uri) + .uri(uri.as_str()) .body(serde_json::to_string(&body)?.into())?) }, client, llm_token, app_version, + require_auth, ) .await; - let inputs = EditPredictionInputs { + let context_start_offset = context_range.start.to_offset(&snapshot); + let editable_offset_range = editable_range.to_offset(&snapshot); + + let inputs = ZetaPromptInput { events: included_events.into(), - included_files: vec![cloud_llm_client::predict_edits_v3::RelatedFile { - path: full_path.clone(), - max_row: cloud_llm_client::predict_edits_v3::Line(snapshot.max_point().row), - excerpts: vec![cloud_llm_client::predict_edits_v3::Excerpt { - start_line: cloud_llm_client::predict_edits_v3::Line(context_range.start.row), - text: snapshot - .text_for_range(context_range) - .collect::() - .into(), - }], - }], - cursor_point: cloud_llm_client::predict_edits_v3::Point { - column: cursor_point.column, - line: cloud_llm_client::predict_edits_v3::Line(cursor_point.row), - }, + related_files: vec![].into(), cursor_path: full_path, + cursor_excerpt: snapshot + .text_for_range(context_range) + .collect::() + .into(), + editable_range_in_excerpt: (editable_range.start - context_start_offset) + ..(editable_offset_range.end - context_start_offset), + cursor_offset_in_excerpt: cursor_point.to_offset(&snapshot) - context_start_offset, }; - // let response = perform_predict_edits(PerformPredictEditsParams { - // client, - // llm_token, - // app_version, - // body, - // }) - // .await; + if let Some(debug_tx) = &debug_tx { + debug_tx + .unbounded_send(DebugEvent::EditPredictionStarted( + EditPredictionStartedDebugEvent { + buffer: buffer.downgrade(), + prompt: Some(serde_json::to_string(&inputs).unwrap()), + position, + }, + )) + .ok(); + } let (response, usage) = match response { Ok(response) => response, @@ -189,6 +197,18 @@ pub(crate) fn request_prediction_with_zeta1( .ok(); } + if let Some(debug_tx) = &debug_tx { + debug_tx + .unbounded_send(DebugEvent::EditPredictionFinished( + EditPredictionFinishedDebugEvent { + buffer: buffer.downgrade(), + model_output: Some(response.output_excerpt.clone()), + position, + }, + )) + .ok(); + } + let edit_prediction = process_completion_response( response, buffer, @@ -226,7 +246,7 @@ fn process_completion_response( buffer: Entity, snapshot: &BufferSnapshot, editable_range: Range, - inputs: EditPredictionInputs, + inputs: ZetaPromptInput, buffer_snapshotted_at: Instant, received_response_at: Instant, cx: &AsyncApp, diff --git a/crates/edit_prediction/src/zeta2.rs b/crates/edit_prediction/src/zeta2.rs index e542bc7e86e6e381766bbedac6a15f431e0693f1..9706e2b9ecd03f6e8ba592210722725f420643d3 100644 --- a/crates/edit_prediction/src/zeta2.rs +++ b/crates/edit_prediction/src/zeta2.rs @@ -1,48 +1,41 @@ -#[cfg(feature = "eval-support")] +#[cfg(feature = "cli-support")] use crate::EvalCacheEntryKind; use crate::open_ai_response::text_from_response; use crate::prediction::EditPredictionResult; use crate::{ - DebugEvent, EDIT_PREDICTIONS_MODEL_ID, EditPredictionId, EditPredictionInputs, - EditPredictionRequestedDebugEvent, EditPredictionStore, + DebugEvent, EDIT_PREDICTIONS_MODEL_ID, EditPredictionFinishedDebugEvent, EditPredictionId, + EditPredictionModelInput, EditPredictionStartedDebugEvent, EditPredictionStore, }; -use anyhow::{Result, anyhow, bail}; -use cloud_llm_client::predict_edits_v3::{self, Event, PromptFormat}; -use cloud_llm_client::{EditPredictionRejectReason, PredictEditsRequestTrigger}; -use cloud_zeta2_prompt::CURSOR_MARKER; -use edit_prediction_context::{EditPredictionExcerpt, Line}; -use edit_prediction_context::{RelatedExcerpt, RelatedFile}; -use futures::channel::oneshot; -use gpui::{Entity, Task, prelude::*}; -use language::{Anchor, BufferSnapshot}; -use language::{Buffer, Point, ToOffset as _, ToPoint}; -use project::{Project, ProjectItem as _}; +use anyhow::{Result, anyhow}; +use cloud_llm_client::EditPredictionRejectReason; +use gpui::{Task, prelude::*}; +use language::{OffsetRangeExt as _, ToOffset as _, ToPoint}; use release_channel::AppVersion; -use std::{ - env, - path::Path, - sync::Arc, - time::{Duration, Instant}, -}; +use std::{path::Path, sync::Arc, time::Instant}; +use zeta_prompt::CURSOR_MARKER; +use zeta_prompt::format_zeta_prompt; + +const MAX_CONTEXT_TOKENS: usize = 150; +const MAX_REWRITE_TOKENS: usize = 350; pub fn request_prediction_with_zeta2( store: &mut EditPredictionStore, - project: &Entity, - active_buffer: &Entity, - active_snapshot: BufferSnapshot, - position: Anchor, - events: Vec>, - mut included_files: Vec, - trigger: PredictEditsRequestTrigger, + EditPredictionModelInput { + buffer, + snapshot, + position, + related_files, + events, + debug_tx, + .. + }: EditPredictionModelInput, cx: &mut Context, ) -> Task>> { - let options = store.options.clone(); let buffer_snapshotted_at = Instant::now(); - let Some((excerpt_path, active_project_path)) = active_snapshot + let Some(excerpt_path) = snapshot .file() .map(|file| -> Arc { file.full_path(cx).into() }) - .zip(active_buffer.read(cx).project_path(cx)) else { return Task::ready(Err(anyhow!("No file path for excerpt"))); }; @@ -50,148 +43,35 @@ pub fn request_prediction_with_zeta2( let client = store.client.clone(); let llm_token = store.llm_token.clone(); let app_version = AppVersion::global(cx); - let debug_tx = store.debug_tx.clone(); - - let file = active_buffer.read(cx).file(); - - let active_file_full_path = file.as_ref().map(|f| f.full_path(cx)); - // TODO data collection - let can_collect_data = file - .as_ref() - .map_or(false, |file| store.can_collect_file(project, file, cx)); - - #[cfg(feature = "eval-support")] + #[cfg(feature = "cli-support")] let eval_cache = store.eval_cache.clone(); let request_task = cx.background_spawn({ - let active_buffer = active_buffer.clone(); async move { - let cursor_offset = position.to_offset(&active_snapshot); - let cursor_point = cursor_offset.to_point(&active_snapshot); - - let before_retrieval = Instant::now(); - - let excerpt_options = options.context; - - let Some(excerpt) = EditPredictionExcerpt::select_from_buffer( - cursor_point, - &active_snapshot, - &excerpt_options, - ) else { - return Ok((None, None)); - }; - - let excerpt_anchor_range = active_snapshot.anchor_after(excerpt.range.start) - ..active_snapshot.anchor_before(excerpt.range.end); - let related_excerpt = RelatedExcerpt { - anchor_range: excerpt_anchor_range.clone(), - point_range: Point::new(excerpt.line_range.start.0, 0) - ..Point::new(excerpt.line_range.end.0, 0), - text: active_snapshot.as_rope().slice(excerpt.range), - }; - - if let Some(buffer_ix) = included_files - .iter() - .position(|file| file.buffer.entity_id() == active_buffer.entity_id()) - { - let file = &mut included_files[buffer_ix]; - file.excerpts.push(related_excerpt); - file.merge_excerpts(); - let last_ix = included_files.len() - 1; - included_files.swap(buffer_ix, last_ix); - } else { - let active_file = RelatedFile { - path: active_project_path, - buffer: active_buffer.downgrade(), - excerpts: vec![related_excerpt], - max_row: active_snapshot.max_point().row, - }; - included_files.push(active_file); - } - - let included_files = included_files - .iter() - .map(|related_file| predict_edits_v3::RelatedFile { - path: Arc::from(related_file.path.path.as_std_path()), - max_row: Line(related_file.max_row), - excerpts: related_file - .excerpts - .iter() - .map(|excerpt| predict_edits_v3::Excerpt { - start_line: Line(excerpt.point_range.start.row), - text: excerpt.text.to_string().into(), - }) - .collect(), - }) - .collect::>(); - - let cloud_request = predict_edits_v3::PredictEditsRequest { - excerpt_path, - excerpt: String::new(), - excerpt_line_range: Line(0)..Line(0), - excerpt_range: 0..0, - cursor_point: predict_edits_v3::Point { - line: predict_edits_v3::Line(cursor_point.row), - column: cursor_point.column, - }, - related_files: included_files, + let cursor_offset = position.to_offset(&snapshot); + let (editable_offset_range, prompt_input) = zeta2_prompt_input( + &snapshot, + related_files, events, - can_collect_data, - debug_info: debug_tx.is_some(), - prompt_max_bytes: Some(options.max_prompt_bytes), - prompt_format: options.prompt_format, - excerpt_parent: None, - git_info: None, - trigger, - }; - - let prompt_result = cloud_zeta2_prompt::build_prompt(&cloud_request); - - let inputs = EditPredictionInputs { - included_files: cloud_request.related_files, - events: cloud_request.events, - cursor_point: cloud_request.cursor_point, - cursor_path: cloud_request.excerpt_path, - }; - - let retrieval_time = Instant::now() - before_retrieval; + excerpt_path, + cursor_offset, + ); - let debug_response_tx = if let Some(debug_tx) = &debug_tx { - let (response_tx, response_rx) = oneshot::channel(); + let prompt = format_zeta_prompt(&prompt_input); + if let Some(debug_tx) = &debug_tx { debug_tx - .unbounded_send(DebugEvent::EditPredictionRequested( - EditPredictionRequestedDebugEvent { - inputs: inputs.clone(), - retrieval_time, - buffer: active_buffer.downgrade(), - local_prompt: match prompt_result.as_ref() { - Ok(prompt) => Ok(prompt.clone()), - Err(err) => Err(err.to_string()), - }, + .unbounded_send(DebugEvent::EditPredictionStarted( + EditPredictionStartedDebugEvent { + buffer: buffer.downgrade(), + prompt: Some(prompt.clone()), position, - response_rx, }, )) .ok(); - Some(response_tx) - } else { - None - }; - - if cfg!(debug_assertions) && env::var("ZED_ZETA2_SKIP_REQUEST").is_ok() { - if let Some(debug_response_tx) = debug_response_tx { - debug_response_tx - .send((Err("Request skipped".to_string()), Duration::ZERO)) - .ok(); - } - anyhow::bail!("Skipping request because ZED_ZETA2_SKIP_REQUEST is set") } - let prompt = prompt_result?; - let generation_params = - cloud_zeta2_prompt::generation_params(cloud_request.prompt_format); let request = open_ai::Request { model: EDIT_PREDICTIONS_MODEL_ID.clone(), messages: vec![open_ai::RequestMessage::User { @@ -199,8 +79,8 @@ pub fn request_prediction_with_zeta2( }], stream: false, max_completion_tokens: None, - stop: generation_params.stop.unwrap_or_default(), - temperature: generation_params.temperature.or(Some(0.7)), + stop: Default::default(), + temperature: Default::default(), tool_choice: None, parallel_tool_calls: None, tools: vec![], @@ -210,81 +90,65 @@ pub fn request_prediction_with_zeta2( log::trace!("Sending edit prediction request"); - let before_request = Instant::now(); let response = EditPredictionStore::send_raw_llm_request( request, client, llm_token, app_version, - #[cfg(feature = "eval-support")] + #[cfg(feature = "cli-support")] eval_cache, - #[cfg(feature = "eval-support")] + #[cfg(feature = "cli-support")] EvalCacheEntryKind::Prediction, ) .await; let received_response_at = Instant::now(); - let request_time = received_response_at - before_request; log::trace!("Got edit prediction response"); - if let Some(debug_response_tx) = debug_response_tx { - debug_response_tx - .send(( - response - .as_ref() - .map_err(|err| err.to_string()) - .map(|response| response.0.clone()), - request_time, - )) - .ok(); - } - let (res, usage) = response?; let request_id = EditPredictionId(res.id.clone().into()); let Some(mut output_text) = text_from_response(res) else { return Ok((Some((request_id, None)), usage)); }; + if let Some(debug_tx) = &debug_tx { + debug_tx + .unbounded_send(DebugEvent::EditPredictionFinished( + EditPredictionFinishedDebugEvent { + buffer: buffer.downgrade(), + position, + model_output: Some(output_text.clone()), + }, + )) + .ok(); + } + if output_text.contains(CURSOR_MARKER) { log::trace!("Stripping out {CURSOR_MARKER} from response"); output_text = output_text.replace(CURSOR_MARKER, ""); } - let get_buffer_from_context = |path: &Path| { - if Some(path) == active_file_full_path.as_deref() { - Some(( - &active_snapshot, - std::slice::from_ref(&excerpt_anchor_range), - )) - } else { - None - } - }; - - let (_, edits) = match options.prompt_format { - PromptFormat::Minimal | PromptFormat::MinimalQwen | PromptFormat::SeedCoder1120 => { - if output_text.contains("--- a/\n+++ b/\nNo edits") { - let edits = vec![]; - (&active_snapshot, edits) - } else { - crate::udiff::parse_diff(&output_text, get_buffer_from_context).await? - } - } - PromptFormat::OldTextNewText => { - crate::xml_edits::parse_xml_edits(&output_text, get_buffer_from_context).await? - } - _ => { - bail!("unsupported prompt format {}", options.prompt_format) - } - }; + let old_text = snapshot + .text_for_range(editable_offset_range.clone()) + .collect::(); + let edits: Vec<_> = language::text_diff(&old_text, &output_text) + .into_iter() + .map(|(range, text)| { + ( + snapshot.anchor_after(editable_offset_range.start + range.start) + ..snapshot.anchor_before(editable_offset_range.start + range.end), + text, + ) + }) + .collect(); anyhow::Ok(( Some(( request_id, Some(( - inputs, - active_buffer, - active_snapshot.clone(), + prompt_input, + buffer, + snapshot.clone(), edits, received_response_at, )), @@ -325,3 +189,55 @@ pub fn request_prediction_with_zeta2( )) }) } + +pub fn zeta2_prompt_input( + snapshot: &language::BufferSnapshot, + related_files: Arc<[zeta_prompt::RelatedFile]>, + events: Vec>, + excerpt_path: Arc, + cursor_offset: usize, +) -> (std::ops::Range, zeta_prompt::ZetaPromptInput) { + let cursor_point = cursor_offset.to_point(snapshot); + + let (editable_range, context_range) = + crate::cursor_excerpt::editable_and_context_ranges_for_cursor_position( + cursor_point, + snapshot, + MAX_CONTEXT_TOKENS, + MAX_REWRITE_TOKENS, + ); + + let context_start_offset = context_range.start.to_offset(snapshot); + let editable_offset_range = editable_range.to_offset(snapshot); + let cursor_offset_in_excerpt = cursor_offset - context_start_offset; + let editable_range_in_excerpt = (editable_offset_range.start - context_start_offset) + ..(editable_offset_range.end - context_start_offset); + + let prompt_input = zeta_prompt::ZetaPromptInput { + cursor_path: excerpt_path, + cursor_excerpt: snapshot + .text_for_range(context_range) + .collect::() + .into(), + editable_range_in_excerpt, + cursor_offset_in_excerpt, + events, + related_files, + }; + (editable_offset_range, prompt_input) +} + +#[cfg(feature = "cli-support")] +pub fn zeta2_output_for_patch(input: &zeta_prompt::ZetaPromptInput, patch: &str) -> Result { + let text = &input.cursor_excerpt; + let editable_region = input.editable_range_in_excerpt.clone(); + let old_prefix = &text[..editable_region.start]; + let old_suffix = &text[editable_region.end..]; + + let new = crate::udiff::apply_diff_to_string(patch, text)?; + if !new.starts_with(old_prefix) || !new.ends_with(old_suffix) { + anyhow::bail!("Patch shouldn't affect text outside of editable region"); + } + + Ok(new[editable_region.start..new.len() - old_suffix.len()].to_string()) +} diff --git a/crates/edit_prediction_cli/Cargo.toml b/crates/edit_prediction_cli/Cargo.toml index 26a060994d75a2c194cc159c33d88fbc296dfa47..b6bace2a2c080626126af96f9ef51e435d6ab8fa 100644 --- a/crates/edit_prediction_cli/Cargo.toml +++ b/crates/edit_prediction_cli/Cargo.toml @@ -9,7 +9,7 @@ license = "GPL-3.0-or-later" workspace = true [[bin]] -name = "ep_cli" +name = "ep" path = "src/main.rs" [dependencies] @@ -20,10 +20,9 @@ chrono.workspace = true clap.workspace = true client.workspace = true cloud_llm_client.workspace= true -cloud_zeta2_prompt.workspace = true collections.workspace = true debug_adapter_extension.workspace = true -edit_prediction_context.workspace = true +dirs.workspace = true extension.workspace = true fs.workspace = true futures.workspace = true @@ -35,12 +34,12 @@ language_extension.workspace = true language_model.workspace = true language_models.workspace = true languages = { workspace = true, features = ["load-grammars"] } +libc.workspace = true log.workspace = true node_runtime.workspace = true paths.workspace = true project.workspace = true prompt_store.workspace = true -pulldown-cmark.workspace = true release_channel.workspace = true reqwest_client.workspace = true serde.workspace = true @@ -51,11 +50,19 @@ smol.workspace = true sqlez.workspace = true sqlez_macros.workspace = true terminal_view.workspace = true -toml.workspace = true util.workspace = true watch.workspace = true -edit_prediction = { workspace = true, features = ["eval-support"] } -zlog.workspace = true +edit_prediction = { workspace = true, features = ["cli-support"] } +wasmtime.workspace = true +zeta_prompt.workspace = true + +# Wasmtime is included as a dependency in order to enable the same +# features that are enabled in Zed. +# +# If we don't enable these features we get crashes when creating +# a Tree-sitter WasmStore. +[package.metadata.cargo-machete] +ignored = ["wasmtime"] [dev-dependencies] indoc.workspace = true diff --git a/crates/edit_prediction_cli/src/training/llm_client.rs b/crates/edit_prediction_cli/src/anthropic_client.rs similarity index 89% rename from crates/edit_prediction_cli/src/training/llm_client.rs rename to crates/edit_prediction_cli/src/anthropic_client.rs index ebecbe915d36a9a456296e818e559c654370f939..8afc4d1c03f8a37ae258cc2926daf85caebe3d8a 100644 --- a/crates/edit_prediction_cli/src/training/llm_client.rs +++ b/crates/edit_prediction_cli/src/anthropic_client.rs @@ -5,11 +5,13 @@ use anthropic::{ use anyhow::Result; use http_client::HttpClient; use indoc::indoc; +use reqwest_client::ReqwestClient; use sqlez::bindable::Bind; use sqlez::bindable::StaticColumnCount; use sqlez_macros::sql; use std::hash::Hash; use std::hash::Hasher; +use std::path::Path; use std::sync::Arc; pub struct PlainLlmClient { @@ -18,7 +20,8 @@ pub struct PlainLlmClient { } impl PlainLlmClient { - fn new(http_client: Arc) -> Result { + fn new() -> Result { + let http_client: Arc = Arc::new(ReqwestClient::new()); let api_key = std::env::var("ANTHROPIC_API_KEY") .map_err(|_| anyhow::anyhow!("ANTHROPIC_API_KEY environment variable not set"))?; Ok(Self { @@ -29,12 +32,12 @@ impl PlainLlmClient { async fn generate( &self, - model: String, + model: &str, max_tokens: u64, messages: Vec, ) -> Result { let request = AnthropicRequest { - model, + model: model.to_string(), max_tokens, messages, tools: Vec::new(), @@ -105,11 +108,12 @@ struct SerializableMessage { } impl BatchingLlmClient { - fn new(cache_path: &str, http_client: Arc) -> Result { + fn new(cache_path: &Path) -> Result { + let http_client: Arc = Arc::new(ReqwestClient::new()); let api_key = std::env::var("ANTHROPIC_API_KEY") .map_err(|_| anyhow::anyhow!("ANTHROPIC_API_KEY environment variable not set"))?; - let connection = sqlez::connection::Connection::open_file(&cache_path); + let connection = sqlez::connection::Connection::open_file(&cache_path.to_str().unwrap()); let mut statement = sqlez::statement::Statement::prepare( &connection, indoc! {" @@ -182,16 +186,16 @@ impl BatchingLlmClient { async fn generate( &self, - model: String, + model: &str, max_tokens: u64, messages: Vec, ) -> Result> { - let response = self.lookup(&model, max_tokens, &messages)?; + let response = self.lookup(model, max_tokens, &messages)?; if let Some(response) = response { return Ok(Some(response)); } - self.mark_for_batch(&model, max_tokens, &messages)?; + self.mark_for_batch(model, max_tokens, &messages)?; Ok(None) } @@ -258,7 +262,7 @@ impl BatchingLlmClient { } } } - log::info!("Uploaded {} successful requests", success_count); + log::info!("Downloaded {} successful requests", success_count); } } @@ -363,23 +367,20 @@ fn message_content_to_string(content: &[RequestContent]) -> String { .join("\n") } -pub enum LlmClient { +pub enum AnthropicClient { // No batching Plain(PlainLlmClient), Batch(BatchingLlmClient), Dummy, } -impl LlmClient { - pub fn plain(http_client: Arc) -> Result { - Ok(Self::Plain(PlainLlmClient::new(http_client)?)) +impl AnthropicClient { + pub fn plain() -> Result { + Ok(Self::Plain(PlainLlmClient::new()?)) } - pub fn batch(cache_path: &str, http_client: Arc) -> Result { - Ok(Self::Batch(BatchingLlmClient::new( - cache_path, - http_client, - )?)) + pub fn batch(cache_path: &Path) -> Result { + Ok(Self::Batch(BatchingLlmClient::new(cache_path)?)) } #[allow(dead_code)] @@ -389,29 +390,29 @@ impl LlmClient { pub async fn generate( &self, - model: String, + model: &str, max_tokens: u64, messages: Vec, ) -> Result> { match self { - LlmClient::Plain(plain_llm_client) => plain_llm_client + AnthropicClient::Plain(plain_llm_client) => plain_llm_client .generate(model, max_tokens, messages) .await .map(Some), - LlmClient::Batch(batching_llm_client) => { + AnthropicClient::Batch(batching_llm_client) => { batching_llm_client .generate(model, max_tokens, messages) .await } - LlmClient::Dummy => panic!("Dummy LLM client is not expected to be used"), + AnthropicClient::Dummy => panic!("Dummy LLM client is not expected to be used"), } } pub async fn sync_batches(&self) -> Result<()> { match self { - LlmClient::Plain(_) => Ok(()), - LlmClient::Batch(batching_llm_client) => batching_llm_client.sync_batches().await, - LlmClient::Dummy => panic!("Dummy LLM client is not expected to be used"), + AnthropicClient::Plain(_) => Ok(()), + AnthropicClient::Batch(batching_llm_client) => batching_llm_client.sync_batches().await, + AnthropicClient::Dummy => panic!("Dummy LLM client is not expected to be used"), } } } diff --git a/crates/edit_prediction_cli/src/distill.rs b/crates/edit_prediction_cli/src/distill.rs new file mode 100644 index 0000000000000000000000000000000000000000..abfe178ae61b6da522f43c93d40b6000800d0e4d --- /dev/null +++ b/crates/edit_prediction_cli/src/distill.rs @@ -0,0 +1,22 @@ +use anyhow::{Result, anyhow}; +use std::mem; + +use crate::example::Example; + +pub async fn run_distill(example: &mut Example) -> Result<()> { + let [prediction]: [_; 1] = + mem::take(&mut example.predictions) + .try_into() + .map_err(|preds: Vec<_>| { + anyhow!( + "Example has {} predictions, but it should have exactly one", + preds.len() + ) + })?; + + example.spec.expected_patch = prediction.actual_patch; + example.prompt = None; + example.predictions = Vec::new(); + example.score = Vec::new(); + Ok(()) +} diff --git a/crates/edit_prediction_cli/src/evaluate.rs b/crates/edit_prediction_cli/src/evaluate.rs deleted file mode 100644 index 686c8ce7e7865f265d6bf17e51ca9477194e5252..0000000000000000000000000000000000000000 --- a/crates/edit_prediction_cli/src/evaluate.rs +++ /dev/null @@ -1,641 +0,0 @@ -use crate::metrics::{self, Scores}; -use std::{ - collections::HashMap, - io::{IsTerminal, Write}, - sync::Arc, -}; - -use anyhow::Result; -use edit_prediction::{EditPredictionStore, udiff::DiffLine}; -use gpui::{AsyncApp, Entity}; -use project::Project; -use util::ResultExt as _; - -use crate::{ - EvaluateArguments, PredictionOptions, - example::{Example, NamedExample}, - headless::ZetaCliAppState, - paths::print_run_data_dir, - predict::{PredictionDetails, perform_predict, setup_store}, -}; - -#[derive(Debug)] -pub(crate) struct ExecutionData { - execution_id: String, - diff: String, - reasoning: String, -} - -pub async fn run_evaluate( - args: EvaluateArguments, - app_state: &Arc, - cx: &mut AsyncApp, -) { - if args.example_paths.is_empty() { - eprintln!("No examples provided"); - return; - } - - let all_tasks = args.example_paths.into_iter().map(|path| { - let options = args.options.clone(); - let app_state = app_state.clone(); - let example = NamedExample::load(&path).expect("Failed to load example"); - - cx.spawn(async move |cx| { - let project = example.setup_project(&app_state, cx).await.unwrap(); - - let providers = (0..args.repetitions) - .map(|_| setup_store(args.options.provider, &project, &app_state, cx).unwrap()) - .collect::>(); - - let _edited_buffers = example.apply_edit_history(&project, cx).await.unwrap(); - - let tasks = providers - .into_iter() - .enumerate() - .map(move |(repetition_ix, store)| { - let repetition_ix = (args.repetitions > 1).then(|| repetition_ix as u16); - let example = example.clone(); - let project = project.clone(); - let options = options.clone(); - - cx.spawn(async move |cx| { - let name = example.name.clone(); - run_evaluate_one( - example, - repetition_ix, - project, - store, - options, - !args.skip_prediction, - cx, - ) - .await - .map_err(|err| (err, name, repetition_ix)) - }) - }); - futures::future::join_all(tasks).await - }) - }); - let all_results = futures::future::join_all(all_tasks).await; - - write_aggregated_scores(&mut std::io::stdout(), &all_results).unwrap(); - if let Some(mut output_file) = - std::fs::File::create(crate::paths::RUN_DIR.join("aggregated_results.md")).log_err() - { - write_aggregated_scores(&mut output_file, &all_results).log_err(); - }; - - if args.repetitions > 1 { - if let Err(e) = write_bucketed_analysis(&all_results) { - eprintln!("Failed to write bucketed analysis: {:?}", e); - } - } - - print_run_data_dir(args.repetitions == 1, std::io::stdout().is_terminal()); -} - -fn write_aggregated_scores( - w: &mut impl std::io::Write, - all_results: &Vec< - Vec)>>, - >, -) -> Result<()> { - let mut successful = Vec::new(); - let mut failed_count = 0; - - for result in all_results.iter().flatten() { - match result { - Ok((eval_result, _execution_data)) => successful.push(eval_result), - Err((err, name, repetition_ix)) => { - if failed_count == 0 { - writeln!(w, "## Errors\n")?; - } - - failed_count += 1; - writeln!(w, "{}", fmt_evaluation_error(err, name, repetition_ix))?; - } - } - } - - if successful.len() > 1 { - let edit_scores = successful - .iter() - .filter_map(|r| r.edit_scores.clone()) - .collect::>(); - let has_edit_predictions = edit_scores.len() > 0; - let aggregated_result = EvaluationResult { - context_scores: Scores::aggregate(successful.iter().map(|r| &r.context_scores)), - edit_scores: has_edit_predictions.then(|| EditScores::aggregate(&edit_scores)), - prompt_len: successful.iter().map(|r| r.prompt_len).sum::() / successful.len(), - generated_len: successful.iter().map(|r| r.generated_len).sum::() - / successful.len(), - }; - - writeln!(w, "\n{}", "-".repeat(80))?; - writeln!(w, "\n## TOTAL SCORES")?; - writeln!(w, "{:#}", aggregated_result)?; - } - - if successful.len() + failed_count > 1 { - writeln!( - w, - "\nCongratulations! {}/{} ({:.2}%) of runs weren't outright failures 🎉", - successful.len(), - successful.len() + failed_count, - (successful.len() as f64 / (successful.len() + failed_count) as f64) * 100.0 - )?; - } - - Ok(()) -} - -pub async fn run_evaluate_one( - example: NamedExample, - repetition_ix: Option, - project: Entity, - store: Entity, - prediction_options: PredictionOptions, - predict: bool, - cx: &mut AsyncApp, -) -> Result<(EvaluationResult, ExecutionData)> { - let predict_result = perform_predict( - example.clone(), - project, - store, - repetition_ix, - prediction_options, - cx, - ) - .await?; - - let evaluation_result = evaluate(&example.example, &predict_result, predict); - - if repetition_ix.is_none() { - write_eval_result( - &example, - &predict_result, - &evaluation_result, - &mut std::io::stdout(), - std::io::stdout().is_terminal(), - predict, - )?; - } - - if let Some(mut results_file) = - std::fs::File::create(predict_result.run_example_dir.join("results.md")).log_err() - { - write_eval_result( - &example, - &predict_result, - &evaluation_result, - &mut results_file, - false, - predict, - ) - .log_err(); - } - - let execution_data = ExecutionData { - execution_id: if let Some(rep_ix) = repetition_ix { - format!("{:03}", rep_ix) - } else { - example.name.clone() - }, - diff: predict_result.diff.clone(), - reasoning: std::fs::read_to_string( - predict_result - .run_example_dir - .join("prediction_response.md"), - ) - .unwrap_or_default(), - }; - - anyhow::Ok((evaluation_result, execution_data)) -} - -fn write_eval_result( - example: &NamedExample, - predictions: &PredictionDetails, - evaluation_result: &EvaluationResult, - out: &mut impl Write, - use_color: bool, - predict: bool, -) -> Result<()> { - if predict { - writeln!( - out, - "## Expected edit prediction:\n\n```diff\n{}\n```\n", - compare_diffs( - &example.example.expected_patch, - &predictions.diff, - use_color - ) - )?; - writeln!( - out, - "## Actual edit prediction:\n\n```diff\n{}\n```\n", - compare_diffs( - &predictions.diff, - &example.example.expected_patch, - use_color - ) - )?; - } - - writeln!(out, "{:#}", evaluation_result)?; - - anyhow::Ok(()) -} - -#[derive(Debug, Default, Clone)] -pub struct EditScores { - pub line_match: Scores, - pub chr_f: f64, -} - -impl EditScores { - pub fn aggregate(scores: &[EditScores]) -> EditScores { - let line_match = Scores::aggregate(scores.iter().map(|s| &s.line_match)); - let chr_f = scores.iter().map(|s| s.chr_f).sum::() / scores.len() as f64; - - EditScores { line_match, chr_f } - } -} - -#[derive(Debug, Default)] -pub struct EvaluationResult { - pub edit_scores: Option, - pub context_scores: Scores, - pub prompt_len: usize, - pub generated_len: usize, -} - -impl std::fmt::Display for EvaluationResult { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - if f.alternate() { - self.fmt_table(f) - } else { - self.fmt_markdown(f) - } - } -} - -impl EvaluationResult { - fn fmt_markdown(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - r#" -### Context Scores -{} -"#, - self.context_scores.to_markdown(), - )?; - if let Some(scores) = &self.edit_scores { - write!( - f, - r#" - ### Edit Prediction Scores - {}"#, - scores.line_match.to_markdown() - )?; - } - Ok(()) - } - - fn fmt_table(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - writeln!(f, "#### Prompt Statistics")?; - writeln!(f, "─────────────────────────")?; - writeln!(f, "Prompt_len Generated_len")?; - writeln!(f, "─────────────────────────")?; - writeln!(f, "{:<11} {:<14}", self.prompt_len, self.generated_len,)?; - writeln!(f)?; - writeln!(f)?; - writeln!(f, "#### Performance Scores")?; - writeln!( - f, - "──────────────────────────────────────────────────────────────────" - )?; - writeln!( - f, - " TP FP FN Precision Recall F1" - )?; - writeln!( - f, - "──────────────────────────────────────────────────────────────────" - )?; - writeln!( - f, - "Context Retrieval {:<6} {:<6} {:<6} {:>8.2} {:>7.2} {:>6.2}", - self.context_scores.true_positives, - self.context_scores.false_positives, - self.context_scores.false_negatives, - self.context_scores.precision() * 100.0, - self.context_scores.recall() * 100.0, - self.context_scores.f1_score() * 100.0 - )?; - if let Some(edit_scores) = &self.edit_scores { - let line_match = &edit_scores.line_match; - writeln!(f, "Edit Prediction")?; - writeln!( - f, - " ├─ exact lines {:<6} {:<6} {:<6} {:>8.2} {:>7.2} {:>6.2}", - line_match.true_positives, - line_match.false_positives, - line_match.false_negatives, - line_match.precision() * 100.0, - line_match.recall() * 100.0, - line_match.f1_score() * 100.0 - )?; - writeln!( - f, - " └─ diff chrF {:<6} {:<6} {:<6} {:>8} {:>8} {:>6.2}", - "-", "-", "-", "-", "-", edit_scores.chr_f - )?; - } - Ok(()) - } -} - -fn evaluate(example: &Example, preds: &PredictionDetails, predict: bool) -> EvaluationResult { - let mut eval_result = EvaluationResult { - prompt_len: preds.prompt_len, - generated_len: preds.generated_len, - ..Default::default() - }; - - if predict { - // todo: alternatives for patches - let expected_patch = example - .expected_patch - .lines() - .map(DiffLine::parse) - .collect::>(); - let actual_patch = preds.diff.lines().map(DiffLine::parse).collect::>(); - - let line_match = metrics::line_match_score(&expected_patch, &actual_patch); - let chr_f = metrics::delta_chr_f(&expected_patch, &actual_patch); - - eval_result.edit_scores = Some(EditScores { line_match, chr_f }); - } - - eval_result -} - -/// Return annotated `patch_a` so that: -/// Additions and deletions that are not present in `patch_b` will be highlighted in red. -/// Additions and deletions that are present in `patch_b` will be highlighted in green. -pub fn compare_diffs(patch_a: &str, patch_b: &str, use_color: bool) -> String { - let green = if use_color { "\x1b[32m✓ " } else { "" }; - let red = if use_color { "\x1b[31m✗ " } else { "" }; - let neutral = if use_color { " " } else { "" }; - let reset = if use_color { "\x1b[0m" } else { "" }; - let lines_a = patch_a.lines().map(DiffLine::parse); - let lines_b: Vec<_> = patch_b.lines().map(DiffLine::parse).collect(); - - let annotated = lines_a - .map(|line| match line { - DiffLine::Addition(_) | DiffLine::Deletion(_) => { - if lines_b.contains(&line) { - format!("{green}{line}{reset}") - } else { - format!("{red}{line}{reset}") - } - } - _ => format!("{neutral}{line}{reset}"), - }) - .collect::>(); - - annotated.join("\n") -} - -fn write_bucketed_analysis( - all_results: &Vec< - Vec)>>, - >, -) -> Result<()> { - #[derive(Debug)] - struct EditBucket { - diff: String, - is_correct: bool, - execution_indices: Vec, - reasoning_samples: Vec, - } - - let mut total_executions = 0; - let mut empty_predictions = Vec::new(); - let mut errors = Vec::new(); - - let mut buckets: HashMap = HashMap::new(); - - for result in all_results.iter().flatten() { - total_executions += 1; - - let (evaluation_result, execution_data) = match result { - Ok((eval_result, execution_data)) => { - if execution_data.diff.is_empty() { - empty_predictions.push(execution_data); - continue; - } - (eval_result, execution_data) - } - Err(err) => { - errors.push(err); - continue; - } - }; - - buckets - .entry(execution_data.diff.clone()) - .and_modify(|bucket| { - bucket - .execution_indices - .push(execution_data.execution_id.clone()); - bucket - .reasoning_samples - .push(execution_data.reasoning.clone()); - }) - .or_insert_with(|| EditBucket { - diff: execution_data.diff.clone(), - is_correct: { - evaluation_result - .edit_scores - .as_ref() - .map_or(false, |edit_scores| { - edit_scores.line_match.false_positives == 0 - && edit_scores.line_match.false_negatives == 0 - && edit_scores.line_match.true_positives > 0 - }) - }, - execution_indices: vec![execution_data.execution_id.clone()], - reasoning_samples: vec![execution_data.reasoning.clone()], - }); - } - - let mut sorted_buckets = buckets.into_values().collect::>(); - sorted_buckets.sort_by(|a, b| match (a.is_correct, b.is_correct) { - (true, false) => std::cmp::Ordering::Less, - (false, true) => std::cmp::Ordering::Greater, - _ => b.execution_indices.len().cmp(&a.execution_indices.len()), - }); - - let output_path = crate::paths::RUN_DIR.join("bucketed_analysis.md"); - let mut output = std::fs::File::create(&output_path)?; - - writeln!(output, "# Bucketed Edit Analysis\n")?; - - writeln!(output, "## Summary\n")?; - writeln!(output, "- **Total executions**: {}", total_executions)?; - - let correct_count: usize = sorted_buckets - .iter() - .filter(|b| b.is_correct) - .map(|b| b.execution_indices.len()) - .sum(); - - let incorrect_count: usize = sorted_buckets - .iter() - .filter(|b| !b.is_correct) - .map(|b| b.execution_indices.len()) - .sum(); - - writeln!( - output, - "- **Correct predictions**: {} ({:.1}%)", - correct_count, - (correct_count as f64 / total_executions as f64) * 100.0 - )?; - - writeln!( - output, - "- **Incorrect predictions**: {} ({:.1}%)", - incorrect_count, - (incorrect_count as f64 / total_executions as f64) * 100.0 - )?; - - writeln!( - output, - "- **No Predictions**: {} ({:.1}%)", - empty_predictions.len(), - (empty_predictions.len() as f64 / total_executions as f64) * 100.0 - )?; - - let unique_incorrect = sorted_buckets.iter().filter(|b| !b.is_correct).count(); - writeln!( - output, - "- **Unique incorrect edit patterns**: {}\n", - unique_incorrect - )?; - - writeln!(output, "---\n")?; - - for (idx, bucket) in sorted_buckets.iter().filter(|b| b.is_correct).enumerate() { - if idx == 0 { - writeln!( - output, - "## Correct Predictions ({} occurrences)\n", - bucket.execution_indices.len() - )?; - } - - writeln!(output, "**Predicted Edit:**\n")?; - writeln!(output, "```diff")?; - writeln!(output, "{}", bucket.diff)?; - writeln!(output, "```\n")?; - - writeln!( - output, - "**Executions:** {}\n", - bucket.execution_indices.join(", ") - )?; - writeln!(output, "---\n")?; - } - - for (idx, bucket) in sorted_buckets.iter().filter(|b| !b.is_correct).enumerate() { - writeln!( - output, - "## Incorrect Prediction #{} ({} occurrences)\n", - idx + 1, - bucket.execution_indices.len() - )?; - - writeln!(output, "**Predicted Edit:**\n")?; - writeln!(output, "```diff")?; - writeln!(output, "{}", bucket.diff)?; - writeln!(output, "```\n")?; - - writeln!( - output, - "**Executions:** {}\n", - bucket.execution_indices.join(", ") - )?; - - for (exec_id, reasoning) in bucket - .execution_indices - .iter() - .zip(bucket.reasoning_samples.iter()) - { - writeln!(output, "{}", fmt_execution(exec_id, reasoning))?; - } - - writeln!(output, "\n---\n")?; - } - - if !empty_predictions.is_empty() { - writeln!( - output, - "## No Predictions ({} occurrences)\n", - empty_predictions.len() - )?; - - for execution_data in &empty_predictions { - writeln!( - output, - "{}", - fmt_execution(&execution_data.execution_id, &execution_data.reasoning) - )?; - } - writeln!(output, "\n---\n")?; - } - - if !errors.is_empty() { - writeln!(output, "## Errors ({} occurrences)\n", errors.len())?; - - for (err, name, repetition_ix) in &errors { - writeln!(output, "{}", fmt_evaluation_error(err, name, repetition_ix))?; - } - writeln!(output, "\n---\n")?; - } - - fn fmt_execution(exec_id: &str, reasoning: &str) -> String { - let exec_content = format!( - "\n### Execution {} `{}/{}/prediction_response.md`{}", - exec_id, - crate::paths::RUN_DIR.display(), - exec_id, - indent_text(&format!("\n\n```\n{}\n```\n", reasoning,), 2) - ); - indent_text(&exec_content, 2) - } - - fn indent_text(text: &str, spaces: usize) -> String { - let indent = " ".repeat(spaces); - text.lines() - .collect::>() - .join(&format!("\n{}", indent)) - } - - Ok(()) -} - -fn fmt_evaluation_error(err: &anyhow::Error, name: &str, repetition_ix: &Option) -> String { - let err = format!("{err:?}") - .replace("", "\n```"); - format!( - "### ERROR {name}{}\n\n{err}\n", - repetition_ix - .map(|ix| format!(" [RUN {ix:03}]")) - .unwrap_or_default() - ) -} diff --git a/crates/edit_prediction_cli/src/example.rs b/crates/edit_prediction_cli/src/example.rs index 4f8c1867cd57d7fb5dbb9c2c08b63dccf2b97d30..e37619bf224b3fa506516714856cfbc5024ece14 100644 --- a/crates/edit_prediction_cli/src/example.rs +++ b/crates/edit_prediction_cli/src/example.rs @@ -1,66 +1,101 @@ +use crate::{PredictionProvider, PromptFormat, metrics::ClassificationMetrics}; +use anyhow::{Context as _, Result}; +use collections::HashMap; +use edit_prediction::example_spec::ExampleSpec; +use edit_prediction::udiff::OpenedBuffers; +use gpui::Entity; +use http_client::Url; +use language::{Anchor, Buffer}; +use project::Project; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; use std::{ borrow::Cow, - cell::RefCell, - fmt::{self, Display}, - fs, - hash::Hash, - hash::Hasher, - io::Write, - mem, + io::{Read, Write}, path::{Path, PathBuf}, - sync::{Arc, OnceLock}, }; +use zeta_prompt::RelatedFile; -use crate::headless::ZetaCliAppState; -use anyhow::{Context as _, Result, anyhow}; -use clap::ValueEnum; -use cloud_zeta2_prompt::CURSOR_MARKER; -use collections::HashMap; -use edit_prediction::udiff::OpenedBuffers; -use futures::{ - AsyncWriteExt as _, - lock::{Mutex, OwnedMutexGuard}, -}; -use futures::{FutureExt as _, future::Shared}; -use gpui::{AsyncApp, Entity, Task, http_client::Url}; -use language::{Anchor, Buffer}; -use project::{Project, ProjectPath}; -use pulldown_cmark::CowStr; -use serde::{Deserialize, Serialize}; -use util::{paths::PathStyle, rel_path::RelPath}; +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Example { + #[serde(flatten)] + pub spec: ExampleSpec, + + /// The full content of the file where an edit is being predicted, and the + /// actual cursor offset. + #[serde(skip_serializing_if = "Option::is_none")] + pub buffer: Option, + + /// The context retrieved for the prediction. This requires the worktree to + /// be loaded and the language server to be started. + #[serde(skip_serializing_if = "Option::is_none")] + pub context: Option, + + /// The input and expected output from the edit prediction model. + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt: Option, + + /// The actual predictions from the model. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub predictions: Vec, + + /// The scores, for how well the actual predictions match the expected + /// predictions. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub score: Vec, + + /// The application state used to process this example. + #[serde(skip)] + pub state: Option, +} + +#[derive(Clone, Debug)] +pub struct ExampleState { + pub project: Entity, + pub buffer: Entity, + pub cursor_position: Anchor, + pub _open_buffers: OpenedBuffers, +} -use crate::paths::{REPOS_DIR, WORKTREES_DIR}; +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ExampleContext { + pub files: Arc<[RelatedFile]>, +} -const UNCOMMITTED_DIFF_HEADING: &str = "Uncommitted Diff"; -const EDIT_HISTORY_HEADING: &str = "Edit History"; -const CURSOR_POSITION_HEADING: &str = "Cursor Position"; -const EXPECTED_PATCH_HEADING: &str = "Expected Patch"; -const EXPECTED_CONTEXT_HEADING: &str = "Expected Context"; -const REPOSITORY_URL_FIELD: &str = "repository_url"; -const REVISION_FIELD: &str = "revision"; +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ExampleBuffer { + pub content: String, + pub cursor_row: u32, + pub cursor_column: u32, + pub cursor_offset: usize, +} -#[derive(Debug, Clone)] -pub struct NamedExample { - pub name: String, - pub example: Example, +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ExamplePrompt { + pub input: String, + pub expected_output: String, + pub format: PromptFormat, } -#[derive(Clone, Debug, Hash, Serialize, Deserialize)] -pub struct Example { - pub repository_url: String, - pub revision: String, - pub uncommitted_diff: String, - pub cursor_path: PathBuf, - pub cursor_position: String, - pub edit_history: String, - pub expected_patch: String, +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ExamplePrediction { + pub actual_patch: String, + pub actual_output: String, + pub provider: PredictionProvider, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ExampleScore { + pub delta_chr_f: f32, + pub line_match: ClassificationMetrics, } impl Example { - fn repo_name(&self) -> Result<(Cow<'_, str>, Cow<'_, str>)> { + pub fn repo_name(&self) -> Result<(Cow<'_, str>, Cow<'_, str>)> { // git@github.com:owner/repo.git - if self.repository_url.contains('@') { + if self.spec.repository_url.contains('@') { let (owner, repo) = self + .spec .repository_url .split_once(':') .context("expected : in git url")? @@ -73,7 +108,7 @@ impl Example { )) // http://github.com/owner/repo.git } else { - let url = Url::parse(&self.repository_url)?; + let url = Url::parse(&self.spec.repository_url)?; let mut segments = url.path_segments().context("empty http url")?; let owner = segments .next() @@ -89,486 +124,127 @@ impl Example { Ok((owner.into(), repo.into())) } } +} - pub async fn setup_worktree(&self, file_name: String) -> Result { - let (repo_owner, repo_name) = self.repo_name()?; - - let repo_dir = REPOS_DIR.join(repo_owner.as_ref()).join(repo_name.as_ref()); - let repo_lock = lock_repo(&repo_dir).await; - - if !repo_dir.is_dir() { - fs::create_dir_all(&repo_dir)?; - run_git(&repo_dir, &["init"]).await?; - run_git( - &repo_dir, - &["remote", "add", "origin", &self.repository_url], - ) - .await?; - } - - // Resolve the example to a revision, fetching it if needed. - let revision = run_git( - &repo_dir, - &["rev-parse", &format!("{}^{{commit}}", self.revision)], - ) - .await; - let revision = if let Ok(revision) = revision { - revision +pub fn read_examples(inputs: &[PathBuf]) -> Vec { + let mut examples = Vec::new(); + + let stdin_path: PathBuf = PathBuf::from("-"); + + let inputs = if inputs.is_empty() { + &[stdin_path] + } else { + inputs + }; + + for path in inputs { + let is_stdin = path.as_path() == Path::new("-"); + let content = if is_stdin { + let mut buffer = String::new(); + std::io::stdin() + .read_to_string(&mut buffer) + .expect("Failed to read from stdin"); + buffer } else { - if run_git( - &repo_dir, - &["fetch", "--depth", "1", "origin", &self.revision], - ) - .await - .is_err() - { - run_git(&repo_dir, &["fetch", "origin"]).await?; - } - let revision = run_git(&repo_dir, &["rev-parse", "FETCH_HEAD"]).await?; - if revision != self.revision { - run_git(&repo_dir, &["tag", &self.revision, &revision]).await?; - } - revision + std::fs::read_to_string(path) + .unwrap_or_else(|_| panic!("Failed to read path: {:?}", &path)) }; - - // Create the worktree for this example if needed. - let worktree_path = WORKTREES_DIR.join(&file_name).join(repo_name.as_ref()); - if worktree_path.is_dir() { - run_git(&worktree_path, &["clean", "--force", "-d"]).await?; - run_git(&worktree_path, &["reset", "--hard", "HEAD"]).await?; - run_git(&worktree_path, &["checkout", revision.as_str()]).await?; + let filename = path.file_stem().unwrap().to_string_lossy().to_string(); + let ext = if !is_stdin { + path.extension() + .map(|ext| ext.to_string_lossy().to_string()) + .unwrap_or_else(|| panic!("{} should have an extension", path.display())) } else { - let worktree_path_string = worktree_path.to_string_lossy(); - run_git(&repo_dir, &["branch", "-f", &file_name, revision.as_str()]).await?; - run_git( - &repo_dir, - &["worktree", "add", "-f", &worktree_path_string, &file_name], - ) - .await?; - } - drop(repo_lock); - - // Apply the uncommitted diff for this example. - if !self.uncommitted_diff.is_empty() { - let mut apply_process = smol::process::Command::new("git") - .current_dir(&worktree_path) - .args(&["apply", "-"]) - .stdin(std::process::Stdio::piped()) - .spawn()?; - - let mut stdin = apply_process.stdin.take().unwrap(); - stdin.write_all(self.uncommitted_diff.as_bytes()).await?; - stdin.close().await?; - drop(stdin); - - let apply_result = apply_process.output().await?; - if !apply_result.status.success() { - anyhow::bail!( - "Failed to apply uncommitted diff patch with status: {}\nstderr:\n{}\nstdout:\n{}", - apply_result.status, - String::from_utf8_lossy(&apply_result.stderr), - String::from_utf8_lossy(&apply_result.stdout), - ); - } - } - - Ok(worktree_path) - } - - pub fn unique_name(&self) -> String { - let mut hasher = std::hash::DefaultHasher::new(); - self.hash(&mut hasher); - let disambiguator = hasher.finish(); - let hash = format!("{:04x}", disambiguator); - format!("{}_{}", &self.revision[..8], &hash[..4]) - } -} - -pub type ActualExcerpt = Excerpt; - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct Excerpt { - pub path: PathBuf, - pub text: String, -} - -#[derive(ValueEnum, Debug, Clone)] -pub enum ExampleFormat { - Json, - Toml, - Md, -} - -impl NamedExample { - pub fn load(path: impl AsRef) -> Result { - let path = path.as_ref(); - let content = std::fs::read_to_string(path)?; - let ext = path.extension(); - - match ext.and_then(|s| s.to_str()) { - Some("json") => Ok(Self { - name: path.file_stem().unwrap_or_default().display().to_string(), - example: serde_json::from_str(&content)?, - }), - Some("toml") => Ok(Self { - name: path.file_stem().unwrap_or_default().display().to_string(), - example: toml::from_str(&content)?, - }), - Some("md") => Self::parse_md(&content), - Some(_) => { - anyhow::bail!("Unrecognized example extension: {}", ext.unwrap().display()); - } - None => { - anyhow::bail!( - "Failed to determine example type since the file does not have an extension." - ); - } - } - } - - pub fn parse_md(input: &str) -> Result { - use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Parser, Tag, TagEnd}; - - let parser = Parser::new(input); - - let mut named = NamedExample { - name: String::new(), - example: Example { - repository_url: String::new(), - revision: String::new(), - uncommitted_diff: String::new(), - cursor_path: PathBuf::new(), - cursor_position: String::new(), - edit_history: String::new(), - expected_patch: String::new(), - }, + "jsonl".to_string() }; - let mut text = String::new(); - let mut block_info: CowStr = "".into(); - - #[derive(PartialEq)] - enum Section { - UncommittedDiff, - EditHistory, - CursorPosition, - ExpectedExcerpts, - ExpectedPatch, - Other, - } - - let mut current_section = Section::Other; - - for event in parser { - match event { - Event::Text(line) => { - text.push_str(&line); - - if !named.name.is_empty() - && current_section == Section::Other - // in h1 section - && let Some((field, value)) = line.split_once('=') - { - match field.trim() { - REPOSITORY_URL_FIELD => { - named.example.repository_url = value.trim().to_string(); - } - REVISION_FIELD => { - named.example.revision = value.trim().to_string(); - } - _ => {} - } - } - } - Event::End(TagEnd::Heading(HeadingLevel::H1)) => { - if !named.name.is_empty() { - anyhow::bail!( - "Found multiple H1 headings. There should only be one with the name of the example." - ); - } - named.name = mem::take(&mut text); - } - Event::End(TagEnd::Heading(HeadingLevel::H2)) => { - let title = mem::take(&mut text); - current_section = if title.eq_ignore_ascii_case(UNCOMMITTED_DIFF_HEADING) { - Section::UncommittedDiff - } else if title.eq_ignore_ascii_case(EDIT_HISTORY_HEADING) { - Section::EditHistory - } else if title.eq_ignore_ascii_case(CURSOR_POSITION_HEADING) { - Section::CursorPosition - } else if title.eq_ignore_ascii_case(EXPECTED_PATCH_HEADING) { - Section::ExpectedPatch - } else if title.eq_ignore_ascii_case(EXPECTED_CONTEXT_HEADING) { - Section::ExpectedExcerpts - } else { - Section::Other - }; - } - Event::End(TagEnd::Heading(HeadingLevel::H3)) => { - mem::take(&mut text); - } - Event::End(TagEnd::Heading(HeadingLevel::H4)) => { - mem::take(&mut text); - } - Event::End(TagEnd::Heading(level)) => { - anyhow::bail!("Unexpected heading level: {level}"); - } - Event::Start(Tag::CodeBlock(kind)) => { - match kind { - CodeBlockKind::Fenced(info) => { - block_info = info; - } - CodeBlockKind::Indented => { - anyhow::bail!("Unexpected indented codeblock"); - } - }; + match ext.as_ref() { + "json" => { + let mut example = + serde_json::from_str::(&content).unwrap_or_else(|error| { + panic!("Failed to parse example file: {}\n{error}", path.display()) + }); + if example.spec.name.is_empty() { + example.spec.name = filename; } - Event::Start(_) => { - text.clear(); - block_info = "".into(); - } - Event::End(TagEnd::CodeBlock) => { - let block_info = block_info.trim(); - match current_section { - Section::UncommittedDiff => { - named.example.uncommitted_diff = mem::take(&mut text); - } - Section::EditHistory => { - named.example.edit_history.push_str(&mem::take(&mut text)); - } - Section::CursorPosition => { - named.example.cursor_path = block_info.into(); - named.example.cursor_position = mem::take(&mut text); - } - Section::ExpectedExcerpts => { - mem::take(&mut text); - } - Section::ExpectedPatch => { - named.example.expected_patch = mem::take(&mut text); + examples.push(example); + } + "jsonl" => examples.extend( + content + .lines() + .enumerate() + .map(|(line_ix, line)| { + let mut example = + serde_json::from_str::(line).unwrap_or_else(|error| { + panic!( + "Failed to parse example on {}:{}\n{error}", + path.display(), + line_ix + 1 + ) + }); + if example.spec.name.is_empty() { + example.spec.name = format!("{filename}-{line_ix}") } - Section::Other => {} - } - } - _ => {} + example + }) + .collect::>(), + ), + "md" => { + examples.push(parse_markdown_example(filename, &content).unwrap()); } - } - - if named.example.cursor_path.as_path() == Path::new("") - || named.example.cursor_position.is_empty() - { - anyhow::bail!("Missing cursor position codeblock"); - } - - Ok(named) - } - - pub fn write(&self, format: ExampleFormat, mut out: impl Write) -> Result<()> { - match format { - ExampleFormat::Json => Ok(serde_json::to_writer(out, &self.example)?), - ExampleFormat::Toml => { - Ok(out.write_all(toml::to_string_pretty(&self.example)?.as_bytes())?) + ext => { + panic!("{} has invalid example extension `{ext}`", path.display()) } - ExampleFormat::Md => Ok(write!(out, "{}", self)?), } } - pub async fn setup_project( - &self, - app_state: &Arc, - cx: &mut AsyncApp, - ) -> Result> { - let worktree_path = self.setup_worktree().await?; - - static AUTHENTICATED: OnceLock>> = OnceLock::new(); - - AUTHENTICATED - .get_or_init(|| { - let client = app_state.client.clone(); - cx.spawn(async move |cx| { - client - .sign_in_with_optional_connect(true, cx) - .await - .unwrap(); - }) - .shared() - }) - .clone() - .await; - - let project = cx.update(|cx| { - Project::local( - app_state.client.clone(), - app_state.node_runtime.clone(), - app_state.user_store.clone(), - app_state.languages.clone(), - app_state.fs.clone(), - None, - cx, - ) - })?; - - let worktree = project - .update(cx, |project, cx| { - project.create_worktree(&worktree_path, true, cx) - })? - .await?; - worktree - .read_with(cx, |worktree, _cx| { - worktree.as_local().unwrap().scan_complete() - })? - .await; - - anyhow::Ok(project) - } - - pub async fn setup_worktree(&self) -> Result { - self.example.setup_worktree(self.file_name()).await - } - - pub fn file_name(&self) -> String { - self.name - .chars() - .map(|c| { - if c.is_whitespace() { - '-' - } else { - c.to_ascii_lowercase() - } - }) - .collect() - } - - pub async fn cursor_position( - &self, - project: &Entity, - cx: &mut AsyncApp, - ) -> Result<(Entity, Anchor)> { - let worktree = project.read_with(cx, |project, cx| { - project.visible_worktrees(cx).next().unwrap() - })?; - let cursor_path = RelPath::new(&self.example.cursor_path, PathStyle::Posix)?.into_arc(); - let cursor_buffer = project - .update(cx, |project, cx| { - project.open_buffer( - ProjectPath { - worktree_id: worktree.read(cx).id(), - path: cursor_path, - }, - cx, - ) - })? - .await?; - let cursor_offset_within_excerpt = self - .example - .cursor_position - .find(CURSOR_MARKER) - .ok_or_else(|| anyhow!("missing cursor marker"))?; - let mut cursor_excerpt = self.example.cursor_position.clone(); - cursor_excerpt.replace_range( - cursor_offset_within_excerpt..(cursor_offset_within_excerpt + CURSOR_MARKER.len()), - "", - ); - let excerpt_offset = cursor_buffer.read_with(cx, |buffer, _cx| { - let text = buffer.text(); - - let mut matches = text.match_indices(&cursor_excerpt); - let Some((excerpt_offset, _)) = matches.next() else { - anyhow::bail!( - "\nExcerpt:\n\n{cursor_excerpt}\nBuffer text:\n{text}\n.Cursor excerpt did not exist in buffer." - ); - }; - assert!(matches.next().is_none()); - - Ok(excerpt_offset) - })??; + sort_examples_by_repo_and_rev(&mut examples); + examples +} - let cursor_offset = excerpt_offset + cursor_offset_within_excerpt; - let cursor_anchor = - cursor_buffer.read_with(cx, |buffer, _| buffer.anchor_after(cursor_offset))?; - Ok((cursor_buffer, cursor_anchor)) +pub fn write_examples(examples: &[Example], output_path: Option<&PathBuf>) { + let mut content = String::new(); + for example in examples { + let line = serde_json::to_string(example).unwrap(); + content.push_str(&line); + content.push('\n'); } - - #[must_use] - pub async fn apply_edit_history( - &self, - project: &Entity, - cx: &mut AsyncApp, - ) -> Result> { - edit_prediction::udiff::apply_diff(&self.example.edit_history, project, cx).await + if let Some(output_path) = output_path { + std::fs::write(output_path, content).expect("Failed to write examples"); + } else { + std::io::stdout().write_all(&content.as_bytes()).unwrap(); } } -async fn run_git(repo_path: &Path, args: &[&str]) -> Result { - let output = smol::process::Command::new("git") - .current_dir(repo_path) - .args(args) - .output() - .await?; - - anyhow::ensure!( - output.status.success(), - "`git {}` within `{}` failed with status: {}\nstderr:\n{}\nstdout:\n{}", - args.join(" "), - repo_path.display(), - output.status, - String::from_utf8_lossy(&output.stderr), - String::from_utf8_lossy(&output.stdout), - ); - Ok(String::from_utf8(output.stdout)?.trim().to_string()) +pub fn sort_examples_by_repo_and_rev(examples: &mut [Example]) { + examples.sort_by(|a, b| { + a.spec + .repository_url + .cmp(&b.spec.repository_url) + .then(b.spec.revision.cmp(&a.spec.revision)) + }); } -impl Display for NamedExample { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "# {}\n\n", self.name)?; - write!( - f, - "{REPOSITORY_URL_FIELD} = {}\n", - self.example.repository_url - )?; - write!(f, "{REVISION_FIELD} = {}\n\n", self.example.revision)?; - - write!(f, "## {UNCOMMITTED_DIFF_HEADING}\n\n")?; - write!(f, "`````diff\n")?; - write!(f, "{}", self.example.uncommitted_diff)?; - write!(f, "`````\n")?; - - if !self.example.edit_history.is_empty() { - write!(f, "`````diff\n{}`````\n", self.example.edit_history)?; - } - - write!( - f, - "## {CURSOR_POSITION_HEADING}\n\n`````{}\n{}`````\n", - self.example.cursor_path.display(), - self.example.cursor_position - )?; - write!(f, "## {EDIT_HISTORY_HEADING}\n\n")?; - - if !self.example.expected_patch.is_empty() { - write!( - f, - "\n## {EXPECTED_PATCH_HEADING}\n\n`````diff\n{}`````\n", - self.example.expected_patch - )?; - } - - Ok(()) +pub fn group_examples_by_repo(examples: &mut [Example]) -> Vec> { + let mut examples_by_repo = HashMap::default(); + for example in examples.iter_mut() { + examples_by_repo + .entry(example.spec.repository_url.clone()) + .or_insert_with(Vec::new) + .push(example); } + examples_by_repo.into_values().collect() } -thread_local! { - static REPO_LOCKS: RefCell>>> = RefCell::new(HashMap::default()); -} - -#[must_use] -pub async fn lock_repo(path: impl AsRef) -> OwnedMutexGuard<()> { - REPO_LOCKS - .with(|cell| { - cell.borrow_mut() - .entry(path.as_ref().to_path_buf()) - .or_default() - .clone() - }) - .lock_owned() - .await +fn parse_markdown_example(name: String, input: &str) -> Result { + let spec = ExampleSpec::from_markdown(name, input)?; + Ok(Example { + spec, + buffer: None, + context: None, + prompt: None, + predictions: Vec::new(), + score: Vec::new(), + state: None, + }) } diff --git a/crates/edit_prediction_cli/src/format_prompt.rs b/crates/edit_prediction_cli/src/format_prompt.rs new file mode 100644 index 0000000000000000000000000000000000000000..f543d0799b379403f0caa980df76954649e1aceb --- /dev/null +++ b/crates/edit_prediction_cli/src/format_prompt.rs @@ -0,0 +1,288 @@ +use crate::{ + PromptFormat, + example::{Example, ExamplePrompt}, + headless::EpAppState, + load_project::run_load_project, + progress::{Progress, Step}, + retrieve_context::run_context_retrieval, +}; +use anyhow::{Context as _, Result, ensure}; +use edit_prediction::{ + EditPredictionStore, + zeta2::{zeta2_output_for_patch, zeta2_prompt_input}, +}; +use gpui::AsyncApp; +use std::sync::Arc; +use zeta_prompt::format_zeta_prompt; + +pub async fn run_format_prompt( + example: &mut Example, + prompt_format: PromptFormat, + app_state: Arc, + mut cx: AsyncApp, +) -> Result<()> { + run_context_retrieval(example, app_state.clone(), cx.clone()).await?; + + let _step_progress = Progress::global().start(Step::FormatPrompt, &example.spec.name); + + match prompt_format { + PromptFormat::Teacher => { + let prompt = TeacherPrompt::format_prompt(example); + example.prompt = Some(ExamplePrompt { + input: prompt, + expected_output: example.spec.expected_patch.clone(), // TODO + format: prompt_format, + }); + } + PromptFormat::Zeta2 => { + run_load_project(example, app_state, cx.clone()).await?; + + let ep_store = cx.update(|cx| { + EditPredictionStore::try_global(cx).context("EditPredictionStore not initialized") + })??; + + let state = example.state.as_ref().context("state must be set")?; + let snapshot = state.buffer.read_with(&cx, |buffer, _| buffer.snapshot())?; + let project = state.project.clone(); + let (_, input) = ep_store.update(&mut cx, |ep_store, cx| { + anyhow::Ok(zeta2_prompt_input( + &snapshot, + example + .context + .as_ref() + .context("context must be set")? + .files + .clone(), + ep_store.edit_history_for_project(&project, cx), + example.spec.cursor_path.clone(), + example + .buffer + .as_ref() + .context("buffer must be set")? + .cursor_offset, + )) + })??; + let prompt = format_zeta_prompt(&input); + let expected_output = + zeta2_output_for_patch(&input, &example.spec.expected_patch.clone())?; + example.prompt = Some(ExamplePrompt { + input: prompt, + expected_output, + format: prompt_format, + }); + } + }; + Ok(()) +} + +pub struct TeacherPrompt; + +impl TeacherPrompt { + const PROMPT: &str = include_str!("teacher.prompt.md"); + pub(crate) const EDITABLE_REGION_START: &str = "<|editable_region_start|>\n"; + pub(crate) const EDITABLE_REGION_END: &str = "<|editable_region_end|>"; + + /// Truncate edit history to this number of last lines + const MAX_HISTORY_LINES: usize = 128; + + pub fn format_prompt(example: &Example) -> String { + let edit_history = Self::format_edit_history(&example.spec.edit_history); + let context = Self::format_context(example); + let editable_region = Self::format_editable_region(example); + + let prompt = Self::PROMPT + .replace("{{context}}", &context) + .replace("{{edit_history}}", &edit_history) + .replace("{{editable_region}}", &editable_region); + + prompt + } + + pub fn parse(example: &Example, response: &str) -> Result { + // Ideally, we should always be able to find cursor position in the retrieved context. + // In reality, sometimes we don't find it for these reasons: + // 1. `example.cursor_position` contains _more_ context than included in the retrieved context + // (can be fixed by getting cursor coordinates at the load_example stage) + // 2. Context retriever just didn't include cursor line. + // + // In that case, fallback to using `cursor_position` as excerpt. + let cursor_file = &example + .buffer + .as_ref() + .context("`buffer` should be filled in in the context collection step")? + .content; + + // Extract updated (new) editable region from the model response + let new_editable_region = extract_last_codeblock(response); + + // Reconstruct old editable region we sent to the model + let old_editable_region = Self::format_editable_region(example); + let old_editable_region = Self::extract_editable_region(&old_editable_region); + ensure!( + cursor_file.contains(&old_editable_region), + "Something's wrong: editable_region is not found in the cursor file" + ); + + // Apply editable region to a larger context and compute diff. + // This is needed to get a better context lines around the editable region + let edited_file = cursor_file.replace(&old_editable_region, &new_editable_region); + let diff = language::unified_diff(&cursor_file, &edited_file); + + let diff = indoc::formatdoc! {" + --- a/{path} + +++ b/{path} + {diff}", + path = example.spec.cursor_path.to_string_lossy(), + diff = diff, + }; + + Ok(diff) + } + + fn format_edit_history(edit_history: &str) -> String { + // Strip comments ("garbage lines") from edit history + let lines = edit_history + .lines() + .filter(|&s| Self::is_udiff_content_line(s)) + .collect::>(); + + let history_lines = if lines.len() > Self::MAX_HISTORY_LINES { + &lines[lines.len() - Self::MAX_HISTORY_LINES..] + } else { + &lines + }; + + if history_lines.is_empty() { + return "(No edit history)".to_string(); + } + + history_lines.join("\n") + } + + fn format_context(example: &Example) -> String { + assert!(example.context.is_some(), "Missing context retriever step"); + + let mut prompt = String::new(); + zeta_prompt::write_related_files(&mut prompt, &example.context.as_ref().unwrap().files); + + prompt + } + + fn format_editable_region(example: &Example) -> String { + let mut result = String::new(); + + let path_str = example.spec.cursor_path.to_string_lossy(); + result.push_str(&format!("`````path=\"{path_str}\"\n")); + result.push_str(Self::EDITABLE_REGION_START); + + // TODO: control number of lines around cursor + result.push_str(&example.spec.cursor_position); + if !example.spec.cursor_position.ends_with('\n') { + result.push('\n'); + } + + result.push_str(&format!("{}\n", Self::EDITABLE_REGION_END)); + result.push_str("`````"); + + result + } + + fn extract_editable_region(text: &str) -> String { + let start = text + .find(Self::EDITABLE_REGION_START) + .map_or(0, |pos| pos + Self::EDITABLE_REGION_START.len()); + let end = text.find(Self::EDITABLE_REGION_END).unwrap_or(text.len()); + + let region = &text[start..end]; + + region.replace("<|user_cursor|>", "") + } + + fn is_udiff_content_line(s: &str) -> bool { + s.starts_with("-") + || s.starts_with("+") + || s.starts_with(" ") + || s.starts_with("---") + || s.starts_with("+++") + || s.starts_with("@@") + } +} + +fn extract_last_codeblock(text: &str) -> String { + let mut last_block = None; + let mut search_start = 0; + + while let Some(start) = text[search_start..].find("```") { + let start = start + search_start; + let bytes = text.as_bytes(); + let mut backtick_end = start; + + while backtick_end < bytes.len() && bytes[backtick_end] == b'`' { + backtick_end += 1; + } + + let backtick_count = backtick_end - start; + let closing_backticks = "`".repeat(backtick_count); + + while backtick_end < bytes.len() && bytes[backtick_end] != b'\n' { + backtick_end += 1; + } + + if let Some(end_pos) = text[backtick_end..].find(&closing_backticks) { + let code_block = &text[backtick_end + 1..backtick_end + end_pos]; + last_block = Some(code_block.to_string()); + search_start = backtick_end + end_pos + backtick_count; + } else { + break; + } + } + + last_block.unwrap_or_else(|| text.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_last_code_block() { + let text = indoc::indoc! {" + Some thinking + + ``` + first block + ``` + + `````path='something' lines=1:2 + last block + ````` + "}; + let last_block = extract_last_codeblock(text); + assert_eq!(last_block, "last block\n"); + } + + #[test] + fn test_extract_editable_region() { + let text = indoc::indoc! {" + some lines + are + here + <|editable_region_start|> + one + two three + + <|editable_region_end|> + more + lines here + "}; + let parsed = TeacherPrompt::extract_editable_region(text); + assert_eq!( + parsed, + indoc::indoc! {" + one + two three + + "} + ); + } +} diff --git a/crates/edit_prediction_cli/src/headless.rs b/crates/edit_prediction_cli/src/headless.rs index c4d8667d63dfb3dd39fbced609e0ae0bc44974d2..da96e7ef6520e952e2b7696eee6b82c243e90e4e 100644 --- a/crates/edit_prediction_cli/src/headless.rs +++ b/crates/edit_prediction_cli/src/headless.rs @@ -1,4 +1,5 @@ use client::{Client, ProxySettings, UserStore}; +use collections::HashMap; use extension::ExtensionHostProxy; use fs::RealFs; use gpui::http_client::read_proxy_from_env; @@ -7,25 +8,38 @@ use gpui_tokio::Tokio; use language::LanguageRegistry; use language_extension::LspAccess; use node_runtime::{NodeBinaryOptions, NodeRuntime}; -use project::project_settings::ProjectSettings; +use project::{Project, project_settings::ProjectSettings}; use release_channel::{AppCommitSha, AppVersion}; use reqwest_client::ReqwestClient; use settings::{Settings, SettingsStore}; use std::path::PathBuf; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use util::ResultExt as _; /// Headless subset of `workspace::AppState`. -pub struct ZetaCliAppState { +pub struct EpAppState { pub languages: Arc, pub client: Arc, pub user_store: Entity, pub fs: Arc, pub node_runtime: NodeRuntime, + pub project_cache: ProjectCache, } -// TODO: dedupe with crates/eval/src/eval.rs -pub fn init(cx: &mut App) -> ZetaCliAppState { +#[derive(Default)] +pub struct ProjectCache(Mutex>>); + +impl ProjectCache { + pub fn insert(&self, repository_url: String, project: Entity) { + self.0.lock().unwrap().insert(repository_url, project); + } + + pub fn get(&self, repository_url: &String) -> Option> { + self.0.lock().unwrap().get(repository_url).cloned() + } +} + +pub fn init(cx: &mut App) -> EpAppState { let app_commit_sha = option_env!("ZED_COMMIT_SHA").map(|s| AppCommitSha::new(s.to_owned())); let app_version = AppVersion::load( @@ -112,11 +126,14 @@ pub fn init(cx: &mut App) -> ZetaCliAppState { prompt_store::init(cx); terminal_view::init(cx); - ZetaCliAppState { + let project_cache = ProjectCache::default(); + + EpAppState { languages, client, user_store, fs, node_runtime, + project_cache, } } diff --git a/crates/edit_prediction_cli/src/load_project.rs b/crates/edit_prediction_cli/src/load_project.rs new file mode 100644 index 0000000000000000000000000000000000000000..70daf00b79486fd917556cffaa26b1fd01ed4d28 --- /dev/null +++ b/crates/edit_prediction_cli/src/load_project.rs @@ -0,0 +1,357 @@ +use crate::{ + example::{Example, ExampleBuffer, ExampleState}, + headless::EpAppState, + paths::{REPOS_DIR, WORKTREES_DIR}, + progress::{InfoStyle, Progress, Step, StepProgress}, +}; +use anyhow::{Context as _, Result}; +use collections::HashMap; +use edit_prediction::EditPredictionStore; +use edit_prediction::udiff::OpenedBuffers; +use futures::{ + AsyncWriteExt as _, + lock::{Mutex, OwnedMutexGuard}, +}; +use gpui::{AsyncApp, Entity}; +use language::{Anchor, Buffer, LanguageNotFound, ToOffset, ToPoint}; +use project::buffer_store::BufferStoreEvent; +use project::{Project, ProjectPath}; +use std::{ + cell::RefCell, + fs, + path::{Path, PathBuf}, + sync::Arc, +}; +use util::{paths::PathStyle, rel_path::RelPath}; +use zeta_prompt::CURSOR_MARKER; + +pub async fn run_load_project( + example: &mut Example, + app_state: Arc, + mut cx: AsyncApp, +) -> Result<()> { + if example.state.is_some() { + return Ok(()); + } + + let progress = Progress::global().start(Step::LoadProject, &example.spec.name); + + let project = setup_project(example, &app_state, &progress, &mut cx).await?; + + let _open_buffers = apply_edit_history(example, &project, &mut cx).await?; + + let (buffer, cursor_position) = cursor_position(example, &project, &mut cx).await?; + let (example_buffer, language_name) = buffer.read_with(&cx, |buffer, _cx| { + let cursor_point = cursor_position.to_point(&buffer); + let language_name = buffer + .language() + .map(|l| l.name().to_string()) + .unwrap_or_else(|| "Unknown".to_string()); + ( + ExampleBuffer { + content: buffer.text(), + cursor_row: cursor_point.row, + cursor_column: cursor_point.column, + cursor_offset: cursor_position.to_offset(&buffer), + }, + language_name, + ) + })?; + + progress.set_info(language_name, InfoStyle::Normal); + + example.buffer = Some(example_buffer); + example.state = Some(ExampleState { + buffer, + project, + cursor_position, + _open_buffers, + }); + Ok(()) +} + +async fn cursor_position( + example: &Example, + project: &Entity, + cx: &mut AsyncApp, +) -> Result<(Entity, Anchor)> { + let language_registry = project.read_with(cx, |project, _| project.languages().clone())?; + let result = language_registry + .load_language_for_file_path(&example.spec.cursor_path) + .await; + + if let Err(error) = result + && !error.is::() + { + return Err(error); + } + + let worktree = project.read_with(cx, |project, cx| { + project + .visible_worktrees(cx) + .next() + .context("No visible worktrees") + })??; + + let cursor_path = RelPath::new(&example.spec.cursor_path, PathStyle::Posix) + .context("Failed to create RelPath")? + .into_arc(); + let cursor_buffer = project + .update(cx, |project, cx| { + project.open_buffer( + ProjectPath { + worktree_id: worktree.read(cx).id(), + path: cursor_path, + }, + cx, + ) + })? + .await?; + let cursor_offset_within_excerpt = example + .spec + .cursor_position + .find(CURSOR_MARKER) + .context("missing cursor marker")?; + let mut cursor_excerpt = example.spec.cursor_position.clone(); + cursor_excerpt.replace_range( + cursor_offset_within_excerpt..(cursor_offset_within_excerpt + CURSOR_MARKER.len()), + "", + ); + let excerpt_offset = cursor_buffer.read_with(cx, |buffer, _cx| { + let text = buffer.text(); + + let mut matches = text.match_indices(&cursor_excerpt); + let (excerpt_offset, _) = matches.next().with_context(|| { + format!( + "\nExcerpt:\n\n{cursor_excerpt}\nBuffer text:\n{text}\n.Example: {}\nCursor excerpt did not exist in buffer.", + example.spec.name + ) + })?; + anyhow::ensure!( + matches.next().is_none(), + "More than one cursor position match found for {}", + &example.spec.name + ); + Ok(excerpt_offset) + })??; + + let cursor_offset = excerpt_offset + cursor_offset_within_excerpt; + let cursor_anchor = + cursor_buffer.read_with(cx, |buffer, _| buffer.anchor_after(cursor_offset))?; + + Ok((cursor_buffer, cursor_anchor)) +} + +async fn setup_project( + example: &mut Example, + app_state: &Arc, + step_progress: &StepProgress, + cx: &mut AsyncApp, +) -> Result> { + let ep_store = cx + .update(|cx| EditPredictionStore::try_global(cx))? + .context("Store should be initialized at init")?; + + let worktree_path = setup_worktree(example, step_progress).await?; + + if let Some(project) = app_state.project_cache.get(&example.spec.repository_url) { + ep_store.update(cx, |ep_store, _| { + ep_store.clear_history_for_project(&project); + })?; + let buffer_store = project.read_with(cx, |project, _| project.buffer_store().clone())?; + let buffers = buffer_store.read_with(cx, |buffer_store, _| { + buffer_store.buffers().collect::>() + })?; + for buffer in buffers { + buffer + .update(cx, |buffer, cx| buffer.reload(cx))? + .await + .ok(); + } + return Ok(project); + } + + let project = cx.update(|cx| { + Project::local( + app_state.client.clone(), + app_state.node_runtime.clone(), + app_state.user_store.clone(), + app_state.languages.clone(), + app_state.fs.clone(), + None, + false, + cx, + ) + })?; + + project + .update(cx, |project, cx| { + project.disable_worktree_scanner(cx); + project.create_worktree(&worktree_path, true, cx) + })? + .await?; + + app_state + .project_cache + .insert(example.spec.repository_url.clone(), project.clone()); + + let buffer_store = project.read_with(cx, |project, _| project.buffer_store().clone())?; + cx.subscribe(&buffer_store, { + let project = project.clone(); + move |_, event, cx| match event { + BufferStoreEvent::BufferAdded(buffer) => { + ep_store.update(cx, |store, cx| store.register_buffer(&buffer, &project, cx)); + } + _ => {} + } + })? + .detach(); + + Ok(project) +} + +async fn setup_worktree(example: &Example, step_progress: &StepProgress) -> Result { + let (repo_owner, repo_name) = example.repo_name().context("failed to get repo name")?; + let repo_dir = REPOS_DIR.join(repo_owner.as_ref()).join(repo_name.as_ref()); + let worktree_path = WORKTREES_DIR + .join(repo_owner.as_ref()) + .join(repo_name.as_ref()); + let repo_lock = lock_repo(&repo_dir).await; + + if !repo_dir.is_dir() { + step_progress.set_substatus(format!("cloning {}", repo_name)); + fs::create_dir_all(&repo_dir)?; + run_git(&repo_dir, &["init"]).await?; + run_git( + &repo_dir, + &["remote", "add", "origin", &example.spec.repository_url], + ) + .await?; + } + + // Resolve the example to a revision, fetching it if needed. + let revision = run_git( + &repo_dir, + &[ + "rev-parse", + &format!("{}^{{commit}}", example.spec.revision), + ], + ) + .await; + let revision = if let Ok(revision) = revision { + revision + } else { + step_progress.set_substatus("fetching"); + if run_git( + &repo_dir, + &["fetch", "--depth", "1", "origin", &example.spec.revision], + ) + .await + .is_err() + { + run_git(&repo_dir, &["fetch", "origin"]).await?; + } + let revision = run_git(&repo_dir, &["rev-parse", "FETCH_HEAD"]).await?; + revision + }; + + // Create the worktree for this example if needed. + step_progress.set_substatus("preparing worktree"); + if worktree_path.is_dir() { + run_git(&worktree_path, &["clean", "--force", "-d"]).await?; + run_git(&worktree_path, &["reset", "--hard", "HEAD"]).await?; + run_git(&worktree_path, &["checkout", revision.as_str()]).await?; + } else { + let worktree_path_string = worktree_path.to_string_lossy(); + run_git( + &repo_dir, + &["branch", "-f", &example.spec.name, revision.as_str()], + ) + .await?; + run_git( + &repo_dir, + &[ + "worktree", + "add", + "-f", + &worktree_path_string, + &example.spec.name, + ], + ) + .await?; + } + drop(repo_lock); + + // Apply the uncommitted diff for this example. + if !example.spec.uncommitted_diff.is_empty() { + step_progress.set_substatus("applying diff"); + let mut apply_process = smol::process::Command::new("git") + .current_dir(&worktree_path) + .args(&["apply", "-"]) + .stdin(std::process::Stdio::piped()) + .spawn()?; + + let mut stdin = apply_process.stdin.take().context("Failed to get stdin")?; + stdin + .write_all(example.spec.uncommitted_diff.as_bytes()) + .await?; + stdin.close().await?; + drop(stdin); + + let apply_result = apply_process.output().await?; + anyhow::ensure!( + apply_result.status.success(), + "Failed to apply uncommitted diff patch with status: {}\nstderr:\n{}\nstdout:\n{}", + apply_result.status, + String::from_utf8_lossy(&apply_result.stderr), + String::from_utf8_lossy(&apply_result.stdout), + ); + } + + step_progress.clear_substatus(); + Ok(worktree_path) +} + +async fn apply_edit_history( + example: &Example, + project: &Entity, + cx: &mut AsyncApp, +) -> Result { + edit_prediction::udiff::apply_diff(&example.spec.edit_history, project, cx).await +} + +thread_local! { + static REPO_LOCKS: RefCell>>> = RefCell::new(HashMap::default()); +} + +#[must_use] +pub async fn lock_repo(path: impl AsRef) -> OwnedMutexGuard<()> { + REPO_LOCKS + .with(|cell| { + cell.borrow_mut() + .entry(path.as_ref().to_path_buf()) + .or_default() + .clone() + }) + .lock_owned() + .await +} + +async fn run_git(repo_path: &Path, args: &[&str]) -> Result { + let output = smol::process::Command::new("git") + .current_dir(repo_path) + .args(args) + .output() + .await?; + + anyhow::ensure!( + output.status.success(), + "`git {}` within `{}` failed with status: {}\nstderr:\n{}\nstdout:\n{}", + args.join(" "), + repo_path.display(), + output.status, + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout), + ); + Ok(String::from_utf8(output.stdout)?.trim().to_string()) +} diff --git a/crates/edit_prediction_cli/src/main.rs b/crates/edit_prediction_cli/src/main.rs index 00086777f1f03112b92f11923ad2d025276699f5..dce0fbbed57dbc4b18faf93787cfb8f2341a126a 100644 --- a/crates/edit_prediction_cli/src/main.rs +++ b/crates/edit_prediction_cli/src/main.rs @@ -1,523 +1,340 @@ -mod evaluate; +mod anthropic_client; +mod distill; mod example; +mod format_prompt; mod headless; +mod load_project; mod metrics; mod paths; mod predict; -mod source_location; -mod training; -mod util; +mod progress; +mod retrieve_context; +mod score; -use crate::{ - evaluate::run_evaluate, - example::{ExampleFormat, NamedExample}, - headless::ZetaCliAppState, - predict::run_predict, - source_location::SourceLocation, - training::{context::ContextType, distill::run_distill}, - util::{open_buffer, open_buffer_with_language_server}, -}; -use ::util::{ResultExt, paths::PathStyle}; -use anyhow::{Result, anyhow}; -use clap::{Args, Parser, Subcommand, ValueEnum}; -use cloud_llm_client::predict_edits_v3; -use edit_prediction::udiff::DiffLine; -use edit_prediction_context::EditPredictionExcerptOptions; -use gpui::{Application, AsyncApp, Entity, prelude::*}; -use language::{Bias, Buffer, BufferSnapshot, Point}; -use metrics::delta_chr_f; -use project::{Project, Worktree, lsp_store::OpenLspBufferHandle}; +use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum}; +use edit_prediction::EditPredictionStore; +use gpui::Application; use reqwest_client::ReqwestClient; -use std::io::{self}; -use std::{collections::HashSet, path::PathBuf, str::FromStr, sync::Arc}; +use serde::{Deserialize, Serialize}; +use std::fmt::Display; +use std::{path::PathBuf, sync::Arc}; + +use crate::distill::run_distill; +use crate::example::{group_examples_by_repo, read_examples, write_examples}; +use crate::format_prompt::run_format_prompt; +use crate::load_project::run_load_project; +use crate::paths::FAILED_EXAMPLES_DIR; +use crate::predict::run_prediction; +use crate::progress::Progress; +use crate::retrieve_context::run_context_retrieval; +use crate::score::run_scoring; #[derive(Parser, Debug)] -#[command(name = "zeta")] -struct ZetaCliArgs { +#[command(name = "ep")] +struct EpArgs { #[arg(long, default_value_t = false)] printenv: bool, + #[clap(long, default_value_t = 10, global = true)] + max_parallelism: usize, #[command(subcommand)] command: Option, + #[clap(global = true)] + inputs: Vec, + #[arg(long, short, global = true)] + output: Option, + #[arg(long, short, global = true)] + in_place: bool, + #[arg(long, short, global = true)] + failfast: bool, } #[derive(Subcommand, Debug)] enum Command { - Context(ContextArgs), - Predict(PredictArguments), - Eval(EvaluateArguments), - Distill(DistillArguments), - ConvertExample { - path: PathBuf, - #[arg(long, value_enum, default_value_t = ExampleFormat::Md)] - output_format: ExampleFormat, - }, - Score { - golden_patch: PathBuf, - actual_patch: PathBuf, - }, + /// Parse markdown examples and output a combined .jsonl file + ParseExample, + /// Create git worktrees for each example and load file contents + LoadProject, + /// Retrieve context for input examples. + Context, + /// Generate a prompt string for a specific model + FormatPrompt(FormatPromptArgs), + /// Runs edit prediction + Predict(PredictArgs), + /// Computes a score based on actual and expected patches + Score(PredictArgs), + /// Prepares a distillation dataset by copying expected outputs to + /// predicted outputs and removing actual outputs and prompts. + Distill, + /// Print aggregated scores + Eval(PredictArgs), + /// Remove git repositories and worktrees Clean, } -#[derive(Debug, Args)] -struct ContextArgs { - #[arg(long)] - provider: ContextProvider, - #[arg(long)] - worktree: PathBuf, - #[arg(long)] - cursor: SourceLocation, - #[arg(long)] - use_language_server: bool, - #[arg(long)] - edit_history: Option, - #[clap(flatten)] - zeta2_args: Zeta2Args, -} - -#[derive(clap::ValueEnum, Default, Debug, Clone, Copy)] -enum ContextProvider { - Zeta1, - #[default] - Zeta2, +impl Display for Command { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Command::ParseExample => write!(f, "parse-example"), + Command::LoadProject => write!(f, "load-project"), + Command::Context => write!(f, "context"), + Command::FormatPrompt(format_prompt_args) => write!( + f, + "format-prompt --prompt-format={}", + format_prompt_args + .prompt_format + .to_possible_value() + .unwrap() + .get_name() + ), + Command::Predict(predict_args) => { + write!( + f, + "predict --provider={:?}", + predict_args + .provider + .to_possible_value() + .unwrap() + .get_name() + ) + } + Command::Score(predict_args) => { + write!( + f, + "score --provider={:?}", + predict_args + .provider + .to_possible_value() + .unwrap() + .get_name() + ) + } + Command::Distill => write!(f, "distill"), + Command::Eval(predict_args) => write!( + f, + "eval --provider={:?}", + predict_args + .provider + .to_possible_value() + .unwrap() + .get_name() + ), + Command::Clean => write!(f, "clean"), + } + } } -#[derive(Clone, Debug, Args)] -struct Zeta2Args { - #[arg(long, default_value_t = 8192)] - max_prompt_bytes: usize, - #[arg(long, default_value_t = 2048)] - max_excerpt_bytes: usize, - #[arg(long, default_value_t = 1024)] - min_excerpt_bytes: usize, - #[arg(long, default_value_t = 0.66)] - target_before_cursor_over_total_bytes: f32, - #[arg(long, default_value_t = 1024)] - max_diagnostic_bytes: usize, - #[arg(long, value_enum, default_value_t = PromptFormat::default())] +#[derive(Debug, Args)] +struct FormatPromptArgs { + #[clap(long)] prompt_format: PromptFormat, - #[arg(long, value_enum, default_value_t = Default::default())] - output_format: OutputFormat, - #[arg(long, default_value_t = 42)] - file_indexing_parallelism: usize, - #[arg(long, default_value_t = false)] - disable_imports_gathering: bool, - #[arg(long, default_value_t = u8::MAX)] - max_retrieved_definitions: u8, } -#[derive(Debug, Args)] -pub struct PredictArguments { - #[clap(long, short, value_enum, default_value_t = PredictionsOutputFormat::Md)] - format: PredictionsOutputFormat, - example_path: PathBuf, - #[clap(flatten)] - options: PredictionOptions, +#[derive(Clone, Copy, Debug, ValueEnum, Serialize, Deserialize)] +enum PromptFormat { + Teacher, + Zeta2, } #[derive(Debug, Args)] -pub struct DistillArguments { - split_commit_dataset: PathBuf, - #[clap(long, value_enum, default_value_t = ContextType::CurrentFile)] - context_type: ContextType, - #[clap(long)] - batch: Option, -} - -#[derive(Clone, Debug, Args)] -pub struct PredictionOptions { - #[clap(flatten)] - zeta2: Zeta2Args, +struct PredictArgs { #[clap(long)] provider: PredictionProvider, - #[clap(long, value_enum, default_value_t = CacheMode::default())] - cache: CacheMode, -} - -#[derive(Debug, ValueEnum, Default, Clone, Copy, PartialEq)] -pub enum CacheMode { - /// Use cached LLM requests and responses, except when multiple repetitions are requested - #[default] - Auto, - /// Use cached LLM requests and responses, based on the hash of the prompt and the endpoint. - #[value(alias = "request")] - Requests, - /// Ignore existing cache entries for both LLM and search. - Skip, - /// Use cached LLM responses AND search results for full determinism. Fails if they haven't been cached yet. - /// Useful for reproducing results and fixing bugs outside of search queries - Force, + #[clap(long, default_value_t = 1)] + repetitions: usize, } -impl CacheMode { - fn use_cached_llm_responses(&self) -> bool { - self.assert_not_auto(); - matches!(self, CacheMode::Requests | CacheMode::Force) - } - - fn use_cached_search_results(&self) -> bool { - self.assert_not_auto(); - matches!(self, CacheMode::Force) - } - - fn assert_not_auto(&self) { - assert_ne!( - *self, - CacheMode::Auto, - "Cache mode should not be auto at this point!" - ); - } -} - -#[derive(clap::ValueEnum, Debug, Clone)] -pub enum PredictionsOutputFormat { - Json, - Md, - Diff, -} - -#[derive(Debug, Args)] -pub struct EvaluateArguments { - example_paths: Vec, - #[clap(flatten)] - options: PredictionOptions, - #[clap(short, long, default_value_t = 1, alias = "repeat")] - repetitions: u16, - #[arg(long)] - skip_prediction: bool, -} - -#[derive(clap::ValueEnum, Default, Debug, Clone, Copy, PartialEq)] +#[derive(Clone, Copy, Debug, ValueEnum, Serialize, Deserialize)] enum PredictionProvider { + Sweep, + Mercury, Zeta1, - #[default] Zeta2, - Sweep, -} - -fn zeta2_args_to_options(args: &Zeta2Args) -> edit_prediction::ZetaOptions { - edit_prediction::ZetaOptions { - context: EditPredictionExcerptOptions { - max_bytes: args.max_excerpt_bytes, - min_bytes: args.min_excerpt_bytes, - target_before_cursor_over_total_bytes: args.target_before_cursor_over_total_bytes, - }, - max_prompt_bytes: args.max_prompt_bytes, - prompt_format: args.prompt_format.into(), - } + Teacher, + TeacherNonBatching, } -#[derive(clap::ValueEnum, Default, Debug, Clone, Copy)] -enum PromptFormat { - OnlySnippets, - #[default] - OldTextNewText, - Minimal, - MinimalQwen, - SeedCoder1120, -} - -impl Into for PromptFormat { - fn into(self) -> predict_edits_v3::PromptFormat { - match self { - Self::OnlySnippets => predict_edits_v3::PromptFormat::OnlySnippets, - Self::OldTextNewText => predict_edits_v3::PromptFormat::OldTextNewText, - Self::Minimal => predict_edits_v3::PromptFormat::Minimal, - Self::MinimalQwen => predict_edits_v3::PromptFormat::MinimalQwen, - Self::SeedCoder1120 => predict_edits_v3::PromptFormat::SeedCoder1120, +impl EpArgs { + fn output_path(&self) -> Option { + if self.in_place { + if self.inputs.len() == 1 { + self.inputs.first().cloned() + } else { + panic!("--in-place requires exactly one input file") + } + } else { + self.output.clone() } } } -#[derive(clap::ValueEnum, Default, Debug, Clone)] -enum OutputFormat { - #[default] - Prompt, - Request, - Full, -} - -#[derive(Debug, Clone)] -enum FileOrStdin { - File(PathBuf), - Stdin, -} +fn main() { + let args = EpArgs::parse(); -impl FileOrStdin { - async fn read_to_string(&self) -> Result { - match self { - FileOrStdin::File(path) => smol::fs::read_to_string(path).await, - FileOrStdin::Stdin => smol::unblock(|| std::io::read_to_string(std::io::stdin())).await, - } + if args.printenv { + ::util::shell_env::print_env(); + return; } -} - -impl FromStr for FileOrStdin { - type Err = ::Err; - fn from_str(s: &str) -> Result { - match s { - "-" => Ok(Self::Stdin), - _ => Ok(Self::File(PathBuf::from_str(s)?)), + let output = args.output_path(); + let command = match args.command { + Some(cmd) => cmd, + None => { + EpArgs::command().print_help().unwrap(); + return; } - } -} - -struct LoadedContext { - full_path_str: String, - snapshot: BufferSnapshot, - clipped_cursor: Point, - worktree: Entity, - project: Entity, - buffer: Entity, - lsp_open_handle: Option, -} - -async fn load_context( - args: &ContextArgs, - app_state: &Arc, - cx: &mut AsyncApp, -) -> Result { - let ContextArgs { - worktree: worktree_path, - cursor, - use_language_server, - .. - } = args; - - let worktree_path = worktree_path.canonicalize()?; - - let project = cx.update(|cx| { - Project::local( - app_state.client.clone(), - app_state.node_runtime.clone(), - app_state.user_store.clone(), - app_state.languages.clone(), - app_state.fs.clone(), - None, - cx, - ) - })?; - - let worktree = project - .update(cx, |project, cx| { - project.create_worktree(&worktree_path, true, cx) - })? - .await?; - - let mut ready_languages = HashSet::default(); - let (lsp_open_handle, buffer) = if *use_language_server { - let (lsp_open_handle, _, buffer) = open_buffer_with_language_server( - project.clone(), - worktree.clone(), - cursor.path.clone(), - &mut ready_languages, - cx, - ) - .await?; - (Some(lsp_open_handle), buffer) - } else { - let buffer = - open_buffer(project.clone(), worktree.clone(), cursor.path.clone(), cx).await?; - (None, buffer) }; - let full_path_str = worktree - .read_with(cx, |worktree, _| worktree.root_name().join(&cursor.path))? - .display(PathStyle::local()) - .to_string(); - - let snapshot = cx.update(|cx| buffer.read(cx).snapshot())?; - let clipped_cursor = snapshot.clip_point(cursor.point, Bias::Left); - if clipped_cursor != cursor.point { - let max_row = snapshot.max_point().row; - if cursor.point.row < max_row { - return Err(anyhow!( - "Cursor position {:?} is out of bounds (line length is {})", - cursor.point, - snapshot.line_len(cursor.point.row) - )); - } else { - return Err(anyhow!( - "Cursor position {:?} is out of bounds (max row is {})", - cursor.point, - max_row - )); + match &command { + Command::Clean => { + std::fs::remove_dir_all(&*paths::DATA_DIR).unwrap(); + return; } + _ => {} } - Ok(LoadedContext { - full_path_str, - snapshot, - clipped_cursor, - worktree, - project, - buffer, - lsp_open_handle, - }) -} - -async fn zeta2_context( - args: ContextArgs, - app_state: &Arc, - cx: &mut AsyncApp, -) -> Result { - let LoadedContext { - worktree, - project, - buffer, - clipped_cursor, - lsp_open_handle: _handle, - .. - } = load_context(&args, app_state, cx).await?; - - // wait for worktree scan before starting zeta2 so that wait_for_initial_indexing waits for - // the whole worktree. - worktree - .read_with(cx, |worktree, _cx| { - worktree.as_local().unwrap().scan_complete() - })? - .await; - let output = cx - .update(|cx| { - let store = cx.new(|cx| { - edit_prediction::EditPredictionStore::new( - app_state.client.clone(), - app_state.user_store.clone(), - cx, - ) - }); - store.update(cx, |store, cx| { - store.set_options(zeta2_args_to_options(&args.zeta2_args)); - store.register_buffer(&buffer, &project, cx); - }); - cx.spawn(async move |cx| { - let updates_rx = store.update(cx, |store, cx| { - let cursor = buffer.read(cx).snapshot().anchor_before(clipped_cursor); - store.set_use_context(true); - store.refresh_context(&project, &buffer, cursor, cx); - store.project_context_updates(&project).unwrap() - })?; - - updates_rx.recv().await.ok(); - - let context = store.update(cx, |store, cx| { - store.context_for_project(&project, cx).to_vec() - })?; - - anyhow::Ok(serde_json::to_string_pretty(&context).unwrap()) - }) - })? - .await?; - - Ok(output) -} - -async fn zeta1_context( - args: ContextArgs, - app_state: &Arc, - cx: &mut AsyncApp, -) -> Result { - let LoadedContext { - full_path_str, - snapshot, - clipped_cursor, - .. - } = load_context(&args, app_state, cx).await?; - - let events = match args.edit_history { - Some(events) => events.read_to_string().await?, - None => String::new(), - }; - - let prompt_for_events = move || (events, 0); - cx.update(|cx| { - edit_prediction::zeta1::gather_context( - full_path_str, - &snapshot, - clipped_cursor, - prompt_for_events, - cloud_llm_client::PredictEditsRequestTrigger::Cli, - cx, - ) - })? - .await -} - -fn main() { - zlog::init(); - zlog::init_output_stderr(); - let args = ZetaCliArgs::parse(); + let mut examples = read_examples(&args.inputs); let http_client = Arc::new(ReqwestClient::new()); let app = Application::headless().with_http_client(http_client); app.run(move |cx| { let app_state = Arc::new(headless::init(cx)); + EditPredictionStore::global(&app_state.client, &app_state.user_store, cx); + cx.spawn(async move |cx| { - match args.command { - None => { - if args.printenv { - ::util::shell_env::print_env(); - } else { - panic!("Expected a command"); - } + let result = async { + if let Command::Predict(args) = &command { + predict::sync_batches(&args.provider).await?; } - Some(Command::Context(context_args)) => { - let result = match context_args.provider { - ContextProvider::Zeta1 => { - let context = - zeta1_context(context_args, &app_state, cx).await.unwrap(); - serde_json::to_string_pretty(&context.body).unwrap() - } - ContextProvider::Zeta2 => { - zeta2_context(context_args, &app_state, cx).await.unwrap() + + let total_examples = examples.len(); + Progress::global().set_total_examples(total_examples); + + let mut grouped_examples = group_examples_by_repo(&mut examples); + let example_batches = grouped_examples.chunks_mut(args.max_parallelism); + + for example_batch in example_batches { + let futures = example_batch.into_iter().map(|repo_examples| async { + for example in repo_examples.iter_mut() { + let result = async { + match &command { + Command::ParseExample => {} + Command::LoadProject => { + run_load_project(example, app_state.clone(), cx.clone()) + .await?; + } + Command::Context => { + run_context_retrieval( + example, + app_state.clone(), + cx.clone(), + ) + .await?; + } + Command::FormatPrompt(args) => { + run_format_prompt( + example, + args.prompt_format, + app_state.clone(), + cx.clone(), + ) + .await?; + } + Command::Predict(args) => { + run_prediction( + example, + Some(args.provider), + args.repetitions, + app_state.clone(), + cx.clone(), + ) + .await?; + } + Command::Distill => { + run_distill(example).await?; + } + Command::Score(args) | Command::Eval(args) => { + run_scoring(example, &args, app_state.clone(), cx.clone()) + .await?; + } + Command::Clean => { + unreachable!() + } + } + anyhow::Ok(()) + } + .await; + + if let Err(e) = result { + Progress::global().increment_failed(); + let failed_example_path = + FAILED_EXAMPLES_DIR.join(format!("{}.json", example.spec.name)); + app_state + .fs + .write( + &failed_example_path, + &serde_json::to_vec_pretty(&example).unwrap(), + ) + .await + .unwrap(); + let err_path = FAILED_EXAMPLES_DIR + .join(format!("{}_err.txt", example.spec.name)); + app_state + .fs + .write(&err_path, e.to_string().as_bytes()) + .await + .unwrap(); + + let msg = format!( + indoc::indoc! {" + While processing {}: + + {:?} + + Written to: \x1b[36m{}\x1b[0m + + Explore this example data with: + fx \x1b[36m{}\x1b[0m + + Re-run this example with: + cargo run -p edit_prediction_cli -- {} \x1b[36m{}\x1b[0m + "}, + example.spec.name, + e, + err_path.display(), + failed_example_path.display(), + command, + failed_example_path.display(), + ); + if args.failfast || total_examples == 1 { + Progress::global().finalize(); + panic!("{}", msg); + } else { + log::error!("{}", msg); + } + } } - }; - println!("{}", result); - } - Some(Command::Predict(arguments)) => { - run_predict(arguments, &app_state, cx).await; - } - Some(Command::Eval(arguments)) => { - run_evaluate(arguments, &app_state, cx).await; + }); + futures::future::join_all(futures).await; } - Some(Command::Distill(arguments)) => { - let _guard = cx - .update(|cx| gpui_tokio::Tokio::handle(cx)) - .unwrap() - .enter(); - run_distill(arguments).await.log_err(); - } - Some(Command::ConvertExample { - path, - output_format, - }) => { - let example = NamedExample::load(path).unwrap(); - example.write(output_format, io::stdout()).unwrap(); + Progress::global().finalize(); + + if args.output.is_some() || !matches!(command, Command::Eval(_)) { + write_examples(&examples, output.as_ref()); } - Some(Command::Score { - golden_patch, - actual_patch, - }) => { - let golden_content = std::fs::read_to_string(golden_patch).unwrap(); - let actual_content = std::fs::read_to_string(actual_patch).unwrap(); - let golden_diff: Vec = golden_content - .lines() - .map(|line| DiffLine::parse(line)) - .collect(); + match &command { + Command::Predict(args) => predict::sync_batches(&args.provider).await?, + Command::Eval(_) => score::print_report(&examples), + _ => (), + }; - let actual_diff: Vec = actual_content - .lines() - .map(|line| DiffLine::parse(line)) - .collect(); + anyhow::Ok(()) + } + .await; - let score = delta_chr_f(&golden_diff, &actual_diff); - println!("{:.2}", score); - } - Some(Command::Clean) => { - std::fs::remove_dir_all(&*crate::paths::TARGET_ZETA_DIR).unwrap() - } - }; + if let Err(e) = result { + panic!("Fatal error: {:?}", e); + } let _ = cx.update(|cx| cx.quit()); }) diff --git a/crates/edit_prediction_cli/src/metrics.rs b/crates/edit_prediction_cli/src/metrics.rs index 0fdb7fb535df12d00341997a64a96b97867f6f28..b3e5eb8688724c821953a56c4fe82e67c75e13b6 100644 --- a/crates/edit_prediction_cli/src/metrics.rs +++ b/crates/edit_prediction_cli/src/metrics.rs @@ -1,30 +1,34 @@ use collections::{HashMap, HashSet}; use edit_prediction::udiff::DiffLine; +use serde::{Deserialize, Serialize}; type Counts = HashMap; type CountsDelta = HashMap; -#[derive(Default, Debug, Clone)] -pub struct Scores { +#[derive(Default, Debug, Clone, Serialize, Deserialize)] +pub struct ClassificationMetrics { pub true_positives: usize, pub false_positives: usize, pub false_negatives: usize, } -impl Scores { - pub fn from_sets(expected: &HashSet, actual: &HashSet) -> Scores { +impl ClassificationMetrics { + pub fn from_sets( + expected: &HashSet, + actual: &HashSet, + ) -> ClassificationMetrics { let true_positives = expected.intersection(actual).count(); let false_positives = actual.difference(expected).count(); let false_negatives = expected.difference(actual).count(); - Scores { + ClassificationMetrics { true_positives, false_positives, false_negatives, } } - pub fn from_counts(expected: &Counts, actual: &Counts) -> Scores { + pub fn from_counts(expected: &Counts, actual: &Counts) -> ClassificationMetrics { let mut true_positives = 0; let mut false_positives = 0; let mut false_negatives = 0; @@ -45,32 +49,16 @@ impl Scores { } } - Scores { + ClassificationMetrics { true_positives, false_positives, false_negatives, } } - pub fn to_markdown(&self) -> String { - format!( - " -Precision : {:.4} -Recall : {:.4} -F1 Score : {:.4} -True Positives : {} -False Positives : {} -False Negatives : {}", - self.precision(), - self.recall(), - self.f1_score(), - self.true_positives, - self.false_positives, - self.false_negatives - ) - } - - pub fn aggregate<'a>(scores: impl Iterator) -> Scores { + pub fn aggregate<'a>( + scores: impl Iterator, + ) -> ClassificationMetrics { let mut true_positives = 0; let mut false_positives = 0; let mut false_negatives = 0; @@ -81,7 +69,7 @@ False Negatives : {}", false_negatives += score.false_negatives; } - Scores { + ClassificationMetrics { true_positives, false_positives, false_negatives, @@ -115,7 +103,10 @@ False Negatives : {}", } } -pub fn line_match_score(expected_patch: &[DiffLine], actual_patch: &[DiffLine]) -> Scores { +pub fn line_match_score( + expected_patch: &[DiffLine], + actual_patch: &[DiffLine], +) -> ClassificationMetrics { let expected_change_lines = expected_patch .iter() .filter(|line| matches!(line, DiffLine::Addition(_) | DiffLine::Deletion(_))) @@ -128,7 +119,7 @@ pub fn line_match_score(expected_patch: &[DiffLine], actual_patch: &[DiffLine]) .map(|line| line.to_string()) .collect(); - Scores::from_sets(&expected_change_lines, &actual_change_lines) + ClassificationMetrics::from_sets(&expected_change_lines, &actual_change_lines) } enum ChrfWhitespace { @@ -204,7 +195,7 @@ pub fn delta_chr_f(expected: &[DiffLine], actual: &[DiffLine]) -> f64 { let expected_counts = ngram_delta_to_counts(&expected_delta); let actual_counts = ngram_delta_to_counts(&actual_delta); - let score = Scores::from_counts(&expected_counts, &actual_counts); + let score = ClassificationMetrics::from_counts(&expected_counts, &actual_counts); total_precision += score.precision(); total_recall += score.recall(); } diff --git a/crates/edit_prediction_cli/src/paths.rs b/crates/edit_prediction_cli/src/paths.rs index 3cc2beec5bd50380b9eef8b502dcba0ccba32772..e5d420d0e3dbeda9c50b8e5a3683238149dbc604 100644 --- a/crates/edit_prediction_cli/src/paths.rs +++ b/crates/edit_prediction_cli/src/paths.rs @@ -1,57 +1,27 @@ -use std::{env, path::PathBuf, sync::LazyLock}; +use std::{ + path::{Path, PathBuf}, + sync::LazyLock, +}; -pub static TARGET_ZETA_DIR: LazyLock = - LazyLock::new(|| env::current_dir().unwrap().join("target/zeta")); -pub static CACHE_DIR: LazyLock = LazyLock::new(|| TARGET_ZETA_DIR.join("cache")); -pub static REPOS_DIR: LazyLock = LazyLock::new(|| TARGET_ZETA_DIR.join("repos")); -pub static WORKTREES_DIR: LazyLock = LazyLock::new(|| TARGET_ZETA_DIR.join("worktrees")); +pub static DATA_DIR: LazyLock = LazyLock::new(|| { + let dir = dirs::home_dir().unwrap().join(".zed_ep"); + ensure_dir(&dir) +}); +pub static CACHE_DIR: LazyLock = LazyLock::new(|| ensure_dir(&DATA_DIR.join("cache"))); +pub static REPOS_DIR: LazyLock = LazyLock::new(|| ensure_dir(&DATA_DIR.join("repos"))); +pub static WORKTREES_DIR: LazyLock = + LazyLock::new(|| ensure_dir(&DATA_DIR.join("worktrees"))); pub static RUN_DIR: LazyLock = LazyLock::new(|| { - TARGET_ZETA_DIR + DATA_DIR .join("runs") .join(chrono::Local::now().format("%d-%m-%y-%H_%M_%S").to_string()) }); -pub static LATEST_EXAMPLE_RUN_DIR: LazyLock = - LazyLock::new(|| TARGET_ZETA_DIR.join("latest")); - -pub fn print_run_data_dir(deep: bool, use_color: bool) { - println!("\n## Run Data\n"); - let mut files = Vec::new(); - - let current_dir = std::env::current_dir().unwrap(); - for file in std::fs::read_dir(&*RUN_DIR).unwrap() { - let file = file.unwrap(); - if file.file_type().unwrap().is_dir() && deep { - for file in std::fs::read_dir(file.path()).unwrap() { - let path = file.unwrap().path(); - let path = path.strip_prefix(¤t_dir).unwrap_or(&path); - files.push(format!( - "- {}/{}{}{}", - path.parent().unwrap().display(), - if use_color { "\x1b[34m" } else { "" }, - path.file_name().unwrap().display(), - if use_color { "\x1b[0m" } else { "" }, - )); - } - } else { - let path = file.path(); - let path = path.strip_prefix(¤t_dir).unwrap_or(&path); - files.push(format!( - "- {}/{}{}{}", - path.parent().unwrap().display(), - if use_color { "\x1b[34m" } else { "" }, - path.file_name().unwrap().display(), - if use_color { "\x1b[0m" } else { "" } - )); - } - } - files.sort(); - - for file in files { - println!("{}", file); - } +pub static LATEST_EXAMPLE_RUN_DIR: LazyLock = LazyLock::new(|| DATA_DIR.join("latest")); +pub static LLM_CACHE_DB: LazyLock = LazyLock::new(|| CACHE_DIR.join("llm_cache.sqlite")); +pub static FAILED_EXAMPLES_DIR: LazyLock = + LazyLock::new(|| ensure_dir(&RUN_DIR.join("failed"))); - println!( - "\n💡 Tip of the day: {} always points to the latest run\n", - LATEST_EXAMPLE_RUN_DIR.display() - ); +fn ensure_dir(path: &Path) -> PathBuf { + std::fs::create_dir_all(path).expect("Failed to create directory"); + path.to_path_buf() } diff --git a/crates/edit_prediction_cli/src/predict.rs b/crates/edit_prediction_cli/src/predict.rs index 74e939b887ce15790993ec15f5973c7f5fd01866..aa93c5415dea091164a68b76a34242697aac70e3 100644 --- a/crates/edit_prediction_cli/src/predict.rs +++ b/crates/edit_prediction_cli/src/predict.rs @@ -1,374 +1,291 @@ -use crate::example::{ActualExcerpt, NamedExample}; -use crate::headless::ZetaCliAppState; -use crate::paths::{CACHE_DIR, LATEST_EXAMPLE_RUN_DIR, RUN_DIR, print_run_data_dir}; use crate::{ - CacheMode, PredictArguments, PredictionOptions, PredictionProvider, PredictionsOutputFormat, + PredictionProvider, PromptFormat, + anthropic_client::AnthropicClient, + example::{Example, ExamplePrediction}, + format_prompt::{TeacherPrompt, run_format_prompt}, + headless::EpAppState, + load_project::run_load_project, + paths::{LATEST_EXAMPLE_RUN_DIR, RUN_DIR}, + progress::{InfoStyle, Progress, Step}, + retrieve_context::run_context_retrieval, +}; +use anyhow::Context as _; +use edit_prediction::{DebugEvent, EditPredictionStore}; +use futures::{FutureExt as _, StreamExt as _, future::Shared}; +use gpui::{AppContext as _, AsyncApp, Task}; +use std::{ + fs, + sync::{ + Arc, Mutex, OnceLock, + atomic::{AtomicUsize, Ordering::SeqCst}, + }, }; -use ::serde::Serialize; -use anyhow::{Context, Result, anyhow}; -use cloud_zeta2_prompt::{CURSOR_MARKER, write_codeblock}; -use edit_prediction::{EditPredictionStore, EvalCache, EvalCacheEntryKind, EvalCacheKey}; -use futures::StreamExt as _; -use gpui::{AppContext, AsyncApp, Entity}; -use project::Project; -use project::buffer_store::BufferStoreEvent; -use serde::Deserialize; -use std::fs; -use std::io::{IsTerminal, Write}; -use std::path::PathBuf; -use std::sync::Arc; -use std::sync::Mutex; -use std::time::{Duration, Instant}; - -pub async fn run_predict( - args: PredictArguments, - app_state: &Arc, - cx: &mut AsyncApp, -) { - let example = NamedExample::load(args.example_path).unwrap(); - let project = example.setup_project(app_state, cx).await.unwrap(); - let store = setup_store(args.options.provider, &project, app_state, cx).unwrap(); - let _edited_buffers = example.apply_edit_history(&project, cx).await.unwrap(); - let result = perform_predict(example, project, store, None, args.options, cx) - .await - .unwrap(); - result.write(args.format, std::io::stdout()).unwrap(); - - print_run_data_dir(true, std::io::stdout().is_terminal()); -} - -pub fn setup_store( - provider: PredictionProvider, - project: &Entity, - app_state: &Arc, - cx: &mut AsyncApp, -) -> Result> { - let store = cx.new(|cx| { - edit_prediction::EditPredictionStore::new( - app_state.client.clone(), - app_state.user_store.clone(), - cx, - ) - })?; - store.update(cx, |store, _cx| { - let model = match provider { - PredictionProvider::Zeta1 => edit_prediction::EditPredictionModel::Zeta1, - PredictionProvider::Zeta2 => edit_prediction::EditPredictionModel::Zeta2, - PredictionProvider::Sweep => edit_prediction::EditPredictionModel::Sweep, - }; - store.set_edit_prediction_model(model); - })?; +pub async fn run_prediction( + example: &mut Example, + provider: Option, + repetition_count: usize, + app_state: Arc, + mut cx: AsyncApp, +) -> anyhow::Result<()> { + if !example.predictions.is_empty() { + return Ok(()); + } - let buffer_store = project.read_with(cx, |project, _| project.buffer_store().clone())?; + let provider = provider.context("provider is required")?; - cx.subscribe(&buffer_store, { - let project = project.clone(); - let store = store.clone(); - move |_, event, cx| match event { - BufferStoreEvent::BufferAdded(buffer) => { - store.update(cx, |store, cx| store.register_buffer(&buffer, &project, cx)); - } - _ => {} - } - })? - .detach(); + run_context_retrieval(example, app_state.clone(), cx.clone()).await?; - anyhow::Ok(store) -} + if matches!( + provider, + PredictionProvider::Teacher | PredictionProvider::TeacherNonBatching + ) { + let _step_progress = Progress::global().start(Step::Predict, &example.spec.name); -pub async fn perform_predict( - example: NamedExample, - project: Entity, - store: Entity, - repetition_ix: Option, - options: PredictionOptions, - cx: &mut AsyncApp, -) -> Result { - let mut cache_mode = options.cache; - if repetition_ix.is_some() { - if cache_mode != CacheMode::Auto && cache_mode != CacheMode::Skip { - panic!("Repetitions are not supported in Auto cache mode"); - } else { - cache_mode = CacheMode::Skip; + if example.prompt.is_none() { + run_format_prompt(example, PromptFormat::Teacher, app_state.clone(), cx).await?; } - } else if cache_mode == CacheMode::Auto { - cache_mode = CacheMode::Requests; - } - let mut example_run_dir = RUN_DIR.join(&example.file_name()); - if let Some(repetition_ix) = repetition_ix { - example_run_dir = example_run_dir.join(format!("{:03}", repetition_ix)); - } - fs::create_dir_all(&example_run_dir)?; - if LATEST_EXAMPLE_RUN_DIR.is_symlink() { - fs::remove_file(&*LATEST_EXAMPLE_RUN_DIR)?; + let batched = matches!(provider, PredictionProvider::Teacher); + return predict_anthropic(example, repetition_count, batched).await; } - #[cfg(unix)] - std::os::unix::fs::symlink(&example_run_dir, &*LATEST_EXAMPLE_RUN_DIR) - .context("creating latest link")?; - - #[cfg(windows)] - std::os::windows::fs::symlink_dir(&example_run_dir, &*LATEST_EXAMPLE_RUN_DIR) - .context("creating latest link")?; - - store.update(cx, |store, _cx| { - store.with_eval_cache(Arc::new(RunCache { - example_run_dir: example_run_dir.clone(), - cache_mode, - })); - })?; - - let (cursor_buffer, cursor_anchor) = example.cursor_position(&project, cx).await?; - - let result = Arc::new(Mutex::new(PredictionDetails::new(example_run_dir.clone()))); + run_load_project(example, app_state.clone(), cx.clone()).await?; + + let _step_progress = Progress::global().start(Step::Predict, &example.spec.name); + + if matches!( + provider, + PredictionProvider::Zeta1 | PredictionProvider::Zeta2 + ) { + static AUTHENTICATED: OnceLock>> = OnceLock::new(); + AUTHENTICATED + .get_or_init(|| { + let client = app_state.client.clone(); + cx.spawn(async move |cx| { + if let Err(e) = client.sign_in_with_optional_connect(true, cx).await { + eprintln!("Authentication failed: {}", e); + } + }) + .shared() + }) + .clone() + .await; + } - let prompt_format = options.zeta2.prompt_format; + let ep_store = cx.update(|cx| { + EditPredictionStore::try_global(cx).context("EditPredictionStore not initialized") + })??; - store.update(cx, |store, _cx| { - let mut options = store.options().clone(); - options.prompt_format = prompt_format.into(); - store.set_options(options); + ep_store.update(&mut cx, |store, _cx| { + let model = match provider { + PredictionProvider::Zeta1 => edit_prediction::EditPredictionModel::Zeta1, + PredictionProvider::Zeta2 => edit_prediction::EditPredictionModel::Zeta2, + PredictionProvider::Sweep => edit_prediction::EditPredictionModel::Sweep, + PredictionProvider::Mercury => edit_prediction::EditPredictionModel::Mercury, + PredictionProvider::Teacher | PredictionProvider::TeacherNonBatching => { + unreachable!() + } + }; + store.set_edit_prediction_model(model); })?; - - let mut debug_task = gpui::Task::ready(Ok(())); - - if options.provider == crate::PredictionProvider::Zeta2 { - let mut debug_rx = store.update(cx, |store, _| store.debug_info())?; - - debug_task = cx.background_spawn({ - let result = result.clone(); - async move { - let mut start_time = None; - let mut retrieval_finished_at = None; - while let Some(event) = debug_rx.next().await { - match event { - edit_prediction::DebugEvent::ContextRetrievalStarted(info) => { - start_time = Some(info.timestamp); - fs::write( - example_run_dir.join("search_prompt.md"), - &info.search_prompt, - )?; + let state = example.state.as_ref().context("state must be set")?; + let run_dir = RUN_DIR.join(&example.spec.name); + + let updated_example = Arc::new(Mutex::new(example.clone())); + let current_run_ix = Arc::new(AtomicUsize::new(0)); + + let mut debug_rx = + ep_store.update(&mut cx, |store, cx| store.debug_info(&state.project, cx))?; + let debug_task = cx.background_spawn({ + let updated_example = updated_example.clone(); + let current_run_ix = current_run_ix.clone(); + let run_dir = run_dir.clone(); + async move { + while let Some(event) = debug_rx.next().await { + let run_ix = current_run_ix.load(SeqCst); + let mut updated_example = updated_example.lock().unwrap(); + + let run_dir = if repetition_count > 1 { + run_dir.join(format!("{:03}", run_ix)) + } else { + run_dir.clone() + }; + + match event { + DebugEvent::EditPredictionStarted(request) => { + assert_eq!(updated_example.predictions.len(), run_ix + 1); + + if let Some(prompt) = request.prompt { + fs::write(run_dir.join("prediction_prompt.md"), &prompt)?; } - edit_prediction::DebugEvent::ContextRetrievalFinished(info) => { - retrieval_finished_at = Some(info.timestamp); - for (key, value) in &info.metadata { - if *key == "search_queries" { - fs::write( - example_run_dir.join("search_queries.json"), - value.as_bytes(), - )?; - } - } + } + DebugEvent::EditPredictionFinished(request) => { + assert_eq!(updated_example.predictions.len(), run_ix + 1); + + if let Some(output) = request.model_output { + fs::write(run_dir.join("prediction_response.md"), &output)?; + updated_example + .predictions + .last_mut() + .unwrap() + .actual_output = output; } - edit_prediction::DebugEvent::EditPredictionRequested(request) => { - let prediction_started_at = Instant::now(); - start_time.get_or_insert(prediction_started_at); - let prompt = request.local_prompt.unwrap_or_default(); - fs::write(example_run_dir.join("prediction_prompt.md"), &prompt)?; - - { - let mut result = result.lock().unwrap(); - result.prompt_len = prompt.chars().count(); - - for included_file in request.inputs.included_files { - let insertions = - vec![(request.inputs.cursor_point, CURSOR_MARKER)]; - result.excerpts.extend(included_file.excerpts.iter().map( - |excerpt| ActualExcerpt { - path: included_file.path.components().skip(1).collect(), - text: String::from(excerpt.text.as_ref()), - }, - )); - write_codeblock( - &included_file.path, - included_file.excerpts.iter(), - if included_file.path == request.inputs.cursor_path { - &insertions - } else { - &[] - }, - included_file.max_row, - false, - &mut result.excerpts_text, - ); - } - } - - let response = - request.response_rx.await?.0.map_err(|err| anyhow!(err))?; - let response = - edit_prediction::open_ai_response::text_from_response(response) - .unwrap_or_default(); - let prediction_finished_at = Instant::now(); - fs::write(example_run_dir.join("prediction_response.md"), &response)?; - - let mut result = result.lock().unwrap(); - result.generated_len = response.chars().count(); - result.retrieval_time = - retrieval_finished_at.unwrap() - start_time.unwrap(); - result.prediction_time = prediction_finished_at - prediction_started_at; - result.total_time = prediction_finished_at - start_time.unwrap(); - + if run_ix >= repetition_count { break; } } + _ => {} } - anyhow::Ok(()) } - }); - - store.update(cx, |store, cx| { - store.refresh_context(&project, &cursor_buffer, cursor_anchor, cx) - })?; - } - - let prediction = store - .update(cx, |store, cx| { - store.request_prediction( - &project, - &cursor_buffer, - cursor_anchor, - cloud_llm_client::PredictEditsRequestTrigger::Cli, - cx, - ) - })? - .await?; - - debug_task.await?; - - let mut result = Arc::into_inner(result).unwrap().into_inner().unwrap(); - - result.diff = prediction - .and_then(|prediction| { - let prediction = prediction.prediction.ok()?; - prediction.edit_preview.as_unified_diff(&prediction.edits) - }) - .unwrap_or_default(); - - anyhow::Ok(result) -} - -struct RunCache { - cache_mode: CacheMode, - example_run_dir: PathBuf, -} - -impl RunCache { - fn output_cache_path((kind, key): &EvalCacheKey) -> PathBuf { - CACHE_DIR.join(format!("{kind}_out_{key:x}.json",)) - } - - fn input_cache_path((kind, key): &EvalCacheKey) -> PathBuf { - CACHE_DIR.join(format!("{kind}_in_{key:x}.json",)) - } - - fn link_to_run(&self, key: &EvalCacheKey) { - let output_link_path = self.example_run_dir.join(format!("{}_out.json", key.0)); - fs::hard_link(Self::output_cache_path(key), &output_link_path).unwrap(); - - let input_link_path = self.example_run_dir.join(format!("{}_in.json", key.0)); - fs::hard_link(Self::input_cache_path(key), &input_link_path).unwrap(); - } -} + anyhow::Ok(()) + } + }); -impl EvalCache for RunCache { - fn read(&self, key: EvalCacheKey) -> Option { - let path = RunCache::output_cache_path(&key); + for ix in 0..repetition_count { + current_run_ix.store(ix, SeqCst); + let run_dir = if repetition_count > 1 { + run_dir.join(format!("{:03}", ix)) + } else { + run_dir.clone() + }; - if path.exists() { - let use_cache = match key.0 { - EvalCacheEntryKind::Search => self.cache_mode.use_cached_search_results(), - EvalCacheEntryKind::Context | EvalCacheEntryKind::Prediction => { - self.cache_mode.use_cached_llm_responses() - } - }; - if use_cache { - log::info!("Using cache entry: {}", path.display()); - self.link_to_run(&key); - Some(fs::read_to_string(path).unwrap()) + fs::create_dir_all(&run_dir)?; + if LATEST_EXAMPLE_RUN_DIR.is_symlink() { + fs::remove_file(&*LATEST_EXAMPLE_RUN_DIR)?; + } + #[cfg(unix)] + std::os::unix::fs::symlink(&run_dir, &*LATEST_EXAMPLE_RUN_DIR)?; + #[cfg(windows)] + std::os::windows::fs::symlink_dir(&run_dir, &*LATEST_EXAMPLE_RUN_DIR)?; + + updated_example + .lock() + .unwrap() + .predictions + .push(ExamplePrediction { + actual_patch: String::new(), + actual_output: String::new(), + provider, + }); + + let prediction = ep_store + .update(&mut cx, |store, cx| { + store.request_prediction( + &state.project, + &state.buffer, + state.cursor_position, + cloud_llm_client::PredictEditsRequestTrigger::Cli, + cx, + ) + })? + .await?; + + let actual_patch = prediction + .and_then(|prediction| { + let prediction = prediction.prediction.ok()?; + prediction.edit_preview.as_unified_diff(&prediction.edits) + }) + .unwrap_or_default(); + + let has_prediction = !actual_patch.is_empty(); + + updated_example + .lock() + .unwrap() + .predictions + .last_mut() + .unwrap() + .actual_patch = actual_patch; + + if ix == repetition_count - 1 { + let (info, style) = if has_prediction { + ("predicted", InfoStyle::Normal) } else { - log::trace!("Skipping cached entry: {}", path.display()); - None - } - } else if matches!(self.cache_mode, CacheMode::Force) { - panic!( - "No cached entry found for {:?}. Run without `--cache force` at least once.", - key.0 - ); - } else { - None + ("no prediction", InfoStyle::Warning) + }; + _step_progress.set_info(info, style); } } - fn write(&self, key: EvalCacheKey, input: &str, output: &str) { - fs::create_dir_all(&*CACHE_DIR).unwrap(); - - let input_path = RunCache::input_cache_path(&key); - fs::write(&input_path, input).unwrap(); - - let output_path = RunCache::output_cache_path(&key); - log::trace!("Writing cache entry: {}", output_path.display()); - fs::write(&output_path, output).unwrap(); + ep_store.update(&mut cx, |store, _| { + store.remove_project(&state.project); + })?; + debug_task.await?; - self.link_to_run(&key); - } + *example = Arc::into_inner(updated_example) + .ok_or_else(|| anyhow::anyhow!("Failed to unwrap Arc"))? + .into_inner() + .map_err(|_| anyhow::anyhow!("Failed to unwrap Mutex"))?; + Ok(()) } -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct PredictionDetails { - pub diff: String, - pub excerpts: Vec, - pub excerpts_text: String, // TODO: contains the worktree root path. Drop this field and compute it on the fly - pub retrieval_time: Duration, - pub prediction_time: Duration, - pub total_time: Duration, - pub run_example_dir: PathBuf, - pub prompt_len: usize, - pub generated_len: usize, -} +async fn predict_anthropic( + example: &mut Example, + _repetition_count: usize, + batched: bool, +) -> anyhow::Result<()> { + let llm_model_name = "claude-sonnet-4-5"; + let max_tokens = 16384; + let llm_client = if batched { + AnthropicClient::batch(&crate::paths::LLM_CACHE_DB.as_ref()) + } else { + AnthropicClient::plain() + }; + let llm_client = llm_client.context("Failed to create LLM client")?; + + let prompt = example.prompt.as_ref().context("Prompt is required")?; + + let messages = vec![anthropic::Message { + role: anthropic::Role::User, + content: vec![anthropic::RequestContent::Text { + text: prompt.input.clone(), + cache_control: None, + }], + }]; + + let Some(response) = llm_client + .generate(llm_model_name, max_tokens, messages) + .await? + else { + // Request stashed for batched processing + return Ok(()); + }; + + let actual_output = response + .content + .into_iter() + .filter_map(|content| match content { + anthropic::ResponseContent::Text { text } => Some(text), + _ => None, + }) + .collect::>() + .join("\n"); -impl PredictionDetails { - pub fn new(run_example_dir: PathBuf) -> Self { - Self { - diff: Default::default(), - excerpts: Default::default(), - excerpts_text: Default::default(), - retrieval_time: Default::default(), - prediction_time: Default::default(), - total_time: Default::default(), - run_example_dir, - prompt_len: 0, - generated_len: 0, - } - } + let actual_patch = TeacherPrompt::parse(example, &actual_output)?; - pub fn write(&self, format: PredictionsOutputFormat, mut out: impl Write) -> Result<()> { - let formatted = match format { - PredictionsOutputFormat::Md => self.to_markdown(), - PredictionsOutputFormat::Json => serde_json::to_string_pretty(self)?, - PredictionsOutputFormat::Diff => self.diff.clone(), - }; + let prediction = ExamplePrediction { + actual_patch, + actual_output, + provider: PredictionProvider::Teacher, + }; - Ok(out.write_all(formatted.as_bytes())?) - } + example.predictions.push(prediction); + Ok(()) +} - pub fn to_markdown(&self) -> String { - format!( - "## Excerpts\n\n\ - {}\n\n\ - ## Prediction\n\n\ - {}\n\n\ - ## Time\n\n\ - Retrieval: {}ms\n\ - Prediction: {}ms\n\n\ - Total: {}ms\n", - self.excerpts_text, - self.diff, - self.retrieval_time.as_millis(), - self.prediction_time.as_millis(), - self.total_time.as_millis(), - ) - } +pub async fn sync_batches(provider: &PredictionProvider) -> anyhow::Result<()> { + match provider { + PredictionProvider::Teacher => { + let cache_path = crate::paths::LLM_CACHE_DB.as_ref(); + let llm_client = + AnthropicClient::batch(cache_path).context("Failed to create LLM client")?; + llm_client + .sync_batches() + .await + .context("Failed to sync batches")?; + } + _ => (), + }; + Ok(()) } diff --git a/crates/edit_prediction_cli/src/progress.rs b/crates/edit_prediction_cli/src/progress.rs new file mode 100644 index 0000000000000000000000000000000000000000..ddc710f202cc98e5932c234cb6bebcc93b28171c --- /dev/null +++ b/crates/edit_prediction_cli/src/progress.rs @@ -0,0 +1,508 @@ +use std::{ + borrow::Cow, + collections::HashMap, + io::{IsTerminal, Write}, + sync::{Arc, Mutex, OnceLock}, + time::{Duration, Instant}, +}; + +use log::{Level, Log, Metadata, Record}; + +pub struct Progress { + inner: Mutex, +} + +struct ProgressInner { + completed: Vec, + in_progress: HashMap, + is_tty: bool, + terminal_width: usize, + max_example_name_len: usize, + status_lines_displayed: usize, + total_examples: usize, + failed_examples: usize, + last_line_is_logging: bool, +} + +#[derive(Clone)] +struct InProgressTask { + step: Step, + started_at: Instant, + substatus: Option, + info: Option<(String, InfoStyle)>, +} + +struct CompletedTask { + step: Step, + example_name: String, + duration: Duration, + info: Option<(String, InfoStyle)>, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Step { + LoadProject, + Context, + FormatPrompt, + Predict, + Score, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum InfoStyle { + Normal, + Warning, +} + +impl Step { + pub fn label(&self) -> &'static str { + match self { + Step::LoadProject => "Load", + Step::Context => "Context", + Step::FormatPrompt => "Format", + Step::Predict => "Predict", + Step::Score => "Score", + } + } + + fn color_code(&self) -> &'static str { + match self { + Step::LoadProject => "\x1b[33m", + Step::Context => "\x1b[35m", + Step::FormatPrompt => "\x1b[34m", + Step::Predict => "\x1b[32m", + Step::Score => "\x1b[31m", + } + } +} + +static GLOBAL: OnceLock> = OnceLock::new(); +static LOGGER: ProgressLogger = ProgressLogger; + +const MARGIN: usize = 4; +const MAX_STATUS_LINES: usize = 10; + +impl Progress { + /// Returns the global Progress instance, initializing it if necessary. + pub fn global() -> Arc { + GLOBAL + .get_or_init(|| { + let progress = Arc::new(Self { + inner: Mutex::new(ProgressInner { + completed: Vec::new(), + in_progress: HashMap::new(), + is_tty: std::io::stderr().is_terminal(), + terminal_width: get_terminal_width(), + max_example_name_len: 0, + status_lines_displayed: 0, + total_examples: 0, + failed_examples: 0, + last_line_is_logging: false, + }), + }); + let _ = log::set_logger(&LOGGER); + log::set_max_level(log::LevelFilter::Error); + progress + }) + .clone() + } + + pub fn set_total_examples(&self, total: usize) { + let mut inner = self.inner.lock().unwrap(); + inner.total_examples = total; + } + + pub fn increment_failed(&self) { + let mut inner = self.inner.lock().unwrap(); + inner.failed_examples += 1; + } + + /// Prints a message to stderr, clearing and redrawing status lines to avoid corruption. + /// This should be used for any output that needs to appear above the status lines. + fn log(&self, message: &str) { + let mut inner = self.inner.lock().unwrap(); + Self::clear_status_lines(&mut inner); + + if !inner.last_line_is_logging { + let reset = "\x1b[0m"; + let dim = "\x1b[2m"; + let divider = "─".repeat(inner.terminal_width.saturating_sub(MARGIN)); + eprintln!("{dim}{divider}{reset}"); + inner.last_line_is_logging = true; + } + + eprintln!("{}", message); + } + + pub fn start(self: &Arc, step: Step, example_name: &str) -> StepProgress { + let mut inner = self.inner.lock().unwrap(); + + Self::clear_status_lines(&mut inner); + + inner.max_example_name_len = inner.max_example_name_len.max(example_name.len()); + inner.in_progress.insert( + example_name.to_string(), + InProgressTask { + step, + started_at: Instant::now(), + substatus: None, + info: None, + }, + ); + + Self::print_status_lines(&mut inner); + + StepProgress { + progress: self.clone(), + step, + example_name: example_name.to_string(), + } + } + + fn finish(&self, step: Step, example_name: &str) { + let mut inner = self.inner.lock().unwrap(); + + let Some(task) = inner.in_progress.remove(example_name) else { + return; + }; + + if task.step == step { + inner.completed.push(CompletedTask { + step: task.step, + example_name: example_name.to_string(), + duration: task.started_at.elapsed(), + info: task.info, + }); + + Self::clear_status_lines(&mut inner); + Self::print_logging_closing_divider(&mut inner); + Self::print_completed(&inner, inner.completed.last().unwrap()); + Self::print_status_lines(&mut inner); + } else { + inner.in_progress.insert(example_name.to_string(), task); + } + } + + fn print_logging_closing_divider(inner: &mut ProgressInner) { + if inner.last_line_is_logging { + let reset = "\x1b[0m"; + let dim = "\x1b[2m"; + let divider = "─".repeat(inner.terminal_width.saturating_sub(MARGIN)); + eprintln!("{dim}{divider}{reset}"); + inner.last_line_is_logging = false; + } + } + + fn clear_status_lines(inner: &mut ProgressInner) { + if inner.is_tty && inner.status_lines_displayed > 0 { + // Move up and clear each line we previously displayed + for _ in 0..inner.status_lines_displayed { + eprint!("\x1b[A\x1b[K"); + } + let _ = std::io::stderr().flush(); + inner.status_lines_displayed = 0; + } + } + + fn print_completed(inner: &ProgressInner, task: &CompletedTask) { + let duration = format_duration(task.duration); + let name_width = inner.max_example_name_len; + + if inner.is_tty { + let reset = "\x1b[0m"; + let bold = "\x1b[1m"; + let dim = "\x1b[2m"; + + let yellow = "\x1b[33m"; + let info_part = task + .info + .as_ref() + .map(|(s, style)| { + if *style == InfoStyle::Warning { + format!("{yellow}{s}{reset}") + } else { + s.to_string() + } + }) + .unwrap_or_default(); + + let prefix = format!( + "{bold}{color}{label:>12}{reset} {name:12} {name: 0 { + format!(" {} failed ", failed_count) + } else { + String::new() + }; + + let range_label = format!( + " {}/{}/{} ", + done_count, in_progress_count, inner.total_examples + ); + + // Print a divider line with failed count on left, range label on right + let failed_visible_len = strip_ansi_len(&failed_label); + let range_visible_len = range_label.len(); + let middle_divider_len = inner + .terminal_width + .saturating_sub(MARGIN * 2) + .saturating_sub(failed_visible_len) + .saturating_sub(range_visible_len); + let left_divider = "─".repeat(MARGIN); + let middle_divider = "─".repeat(middle_divider_len); + let right_divider = "─".repeat(MARGIN); + eprintln!( + "{dim}{left_divider}{reset}{failed_label}{dim}{middle_divider}{reset}{range_label}{dim}{right_divider}{reset}" + ); + + let mut tasks: Vec<_> = inner.in_progress.iter().collect(); + tasks.sort_by_key(|(name, _)| *name); + + let total_tasks = tasks.len(); + let mut lines_printed = 0; + + for (name, task) in tasks.iter().take(MAX_STATUS_LINES) { + let elapsed = format_duration(task.started_at.elapsed()); + let substatus_part = task + .substatus + .as_ref() + .map(|s| truncate_with_ellipsis(s, 30)) + .unwrap_or_default(); + + let step_label = task.step.label(); + let step_color = task.step.color_code(); + let name_width = inner.max_example_name_len; + + let prefix = format!( + "{bold}{step_color}{step_label:>12}{reset} {name: MAX_STATUS_LINES { + let remaining = total_tasks - MAX_STATUS_LINES; + eprintln!("{:>12} +{remaining} more", ""); + lines_printed += 1; + } + + inner.status_lines_displayed = lines_printed + 1; // +1 for the divider line + let _ = std::io::stderr().flush(); + } + + pub fn finalize(&self) { + let mut inner = self.inner.lock().unwrap(); + Self::clear_status_lines(&mut inner); + + // Print summary if there were failures + if inner.failed_examples > 0 { + let total_processed = inner.completed.len() + inner.failed_examples; + let percentage = if total_processed > 0 { + inner.failed_examples as f64 / total_processed as f64 * 100.0 + } else { + 0.0 + }; + eprintln!( + "\n{} of {} examples failed ({:.1}%)", + inner.failed_examples, total_processed, percentage + ); + } + } +} + +pub struct StepProgress { + progress: Arc, + step: Step, + example_name: String, +} + +impl StepProgress { + pub fn set_substatus(&self, substatus: impl Into>) { + let mut inner = self.progress.inner.lock().unwrap(); + if let Some(task) = inner.in_progress.get_mut(&self.example_name) { + task.substatus = Some(substatus.into().into_owned()); + Progress::clear_status_lines(&mut inner); + Progress::print_status_lines(&mut inner); + } + } + + pub fn clear_substatus(&self) { + let mut inner = self.progress.inner.lock().unwrap(); + if let Some(task) = inner.in_progress.get_mut(&self.example_name) { + task.substatus = None; + Progress::clear_status_lines(&mut inner); + Progress::print_status_lines(&mut inner); + } + } + + pub fn set_info(&self, info: impl Into, style: InfoStyle) { + let mut inner = self.progress.inner.lock().unwrap(); + if let Some(task) = inner.in_progress.get_mut(&self.example_name) { + task.info = Some((info.into(), style)); + } + } +} + +impl Drop for StepProgress { + fn drop(&mut self) { + self.progress.finish(self.step, &self.example_name); + } +} + +struct ProgressLogger; + +impl Log for ProgressLogger { + fn enabled(&self, metadata: &Metadata) -> bool { + metadata.level() <= Level::Info + } + + fn log(&self, record: &Record) { + if !self.enabled(record.metadata()) { + return; + } + + let level_color = match record.level() { + Level::Error => "\x1b[31m", + Level::Warn => "\x1b[33m", + Level::Info => "\x1b[32m", + Level::Debug => "\x1b[34m", + Level::Trace => "\x1b[35m", + }; + let reset = "\x1b[0m"; + let bold = "\x1b[1m"; + + let level_label = match record.level() { + Level::Error => "Error", + Level::Warn => "Warn", + Level::Info => "Info", + Level::Debug => "Debug", + Level::Trace => "Trace", + }; + + let message = format!( + "{bold}{level_color}{level_label:>12}{reset} {}", + record.args() + ); + + if let Some(progress) = GLOBAL.get() { + progress.log(&message); + } else { + eprintln!("{}", message); + } + } + + fn flush(&self) { + let _ = std::io::stderr().flush(); + } +} + +#[cfg(unix)] +fn get_terminal_width() -> usize { + unsafe { + let mut winsize: libc::winsize = std::mem::zeroed(); + if libc::ioctl(libc::STDERR_FILENO, libc::TIOCGWINSZ, &mut winsize) == 0 + && winsize.ws_col > 0 + { + winsize.ws_col as usize + } else { + 80 + } + } +} + +#[cfg(not(unix))] +fn get_terminal_width() -> usize { + 80 +} + +fn strip_ansi_len(s: &str) -> usize { + let mut len = 0; + let mut in_escape = false; + for c in s.chars() { + if c == '\x1b' { + in_escape = true; + } else if in_escape { + if c == 'm' { + in_escape = false; + } + } else { + len += 1; + } + } + len +} + +fn truncate_with_ellipsis(s: &str, max_len: usize) -> String { + if s.len() <= max_len { + s.to_string() + } else { + format!("{}…", &s[..max_len.saturating_sub(1)]) + } +} + +fn format_duration(duration: Duration) -> String { + const MINUTE_IN_MILLIS: f32 = 60. * 1000.; + + let millis = duration.as_millis() as f32; + if millis < 1000.0 { + format!("{}ms", millis) + } else if millis < MINUTE_IN_MILLIS { + format!("{:.1}s", millis / 1_000.0) + } else { + format!("{:.1}m", millis / MINUTE_IN_MILLIS) + } +} diff --git a/crates/edit_prediction_cli/src/retrieve_context.rs b/crates/edit_prediction_cli/src/retrieve_context.rs new file mode 100644 index 0000000000000000000000000000000000000000..abba4504edc6c0733ffd8c0677e2e3304d8100fa --- /dev/null +++ b/crates/edit_prediction_cli/src/retrieve_context.rs @@ -0,0 +1,192 @@ +use crate::{ + example::{Example, ExampleContext}, + headless::EpAppState, + load_project::run_load_project, + progress::{InfoStyle, Progress, Step, StepProgress}, +}; +use anyhow::Context as _; +use collections::HashSet; +use edit_prediction::{DebugEvent, EditPredictionStore}; +use futures::{FutureExt as _, StreamExt as _, channel::mpsc}; +use gpui::{AsyncApp, Entity}; +use language::Buffer; +use project::Project; +use std::sync::Arc; +use std::time::Duration; + +pub async fn run_context_retrieval( + example: &mut Example, + app_state: Arc, + mut cx: AsyncApp, +) -> anyhow::Result<()> { + if example.context.is_some() { + return Ok(()); + } + + run_load_project(example, app_state.clone(), cx.clone()).await?; + + let step_progress: Arc = Progress::global() + .start(Step::Context, &example.spec.name) + .into(); + + let state = example.state.as_ref().unwrap(); + let project = state.project.clone(); + + let _lsp_handle = project.update(&mut cx, |project, cx| { + project.register_buffer_with_language_servers(&state.buffer, cx) + })?; + wait_for_language_servers_to_start(&project, &state.buffer, &step_progress, &mut cx).await?; + + let ep_store = cx.update(|cx| { + EditPredictionStore::try_global(cx).context("EditPredictionStore not initialized") + })??; + + let mut events = ep_store.update(&mut cx, |store, cx| { + store.register_buffer(&state.buffer, &project, cx); + store.set_use_context(true); + store.refresh_context(&project, &state.buffer, state.cursor_position, cx); + store.debug_info(&project, cx) + })?; + + while let Some(event) = events.next().await { + match event { + DebugEvent::ContextRetrievalFinished(_) => { + break; + } + _ => {} + } + } + + let context_files = + ep_store.update(&mut cx, |store, cx| store.context_for_project(&project, cx))?; + + let excerpt_count: usize = context_files.iter().map(|f| f.excerpts.len()).sum(); + step_progress.set_info(format!("{} excerpts", excerpt_count), InfoStyle::Normal); + + example.context = Some(ExampleContext { + files: context_files, + }); + Ok(()) +} + +async fn wait_for_language_servers_to_start( + project: &Entity, + buffer: &Entity, + step_progress: &Arc, + cx: &mut AsyncApp, +) -> anyhow::Result<()> { + let lsp_store = project.read_with(cx, |project, _| project.lsp_store())?; + + let (language_server_ids, mut starting_language_server_ids) = buffer + .update(cx, |buffer, cx| { + lsp_store.update(cx, |lsp_store, cx| { + let ids = lsp_store.language_servers_for_local_buffer(buffer, cx); + let starting_ids = ids + .iter() + .copied() + .filter(|id| !lsp_store.language_server_statuses.contains_key(&id)) + .collect::>(); + (ids, starting_ids) + }) + }) + .unwrap_or_default(); + + step_progress.set_substatus(format!("waiting for {} LSPs", language_server_ids.len())); + + let timeout = cx + .background_executor() + .timer(Duration::from_secs(60 * 5)) + .shared(); + + let (mut tx, mut rx) = mpsc::channel(language_server_ids.len()); + let added_subscription = cx.subscribe(project, { + let step_progress = step_progress.clone(); + move |_, event, _| match event { + project::Event::LanguageServerAdded(language_server_id, name, _) => { + step_progress.set_substatus(format!("LSP started: {}", name)); + tx.try_send(*language_server_id).ok(); + } + _ => {} + } + }); + + while !starting_language_server_ids.is_empty() { + futures::select! { + language_server_id = rx.next() => { + if let Some(id) = language_server_id { + starting_language_server_ids.remove(&id); + } + }, + _ = timeout.clone().fuse() => { + return Err(anyhow::anyhow!("LSP wait timed out after 5 minutes")); + } + } + } + + drop(added_subscription); + + if !language_server_ids.is_empty() { + project + .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))? + .detach(); + } + + let (mut tx, mut rx) = mpsc::channel(language_server_ids.len()); + let subscriptions = [ + cx.subscribe(&lsp_store, { + let step_progress = step_progress.clone(); + move |_, event, _| { + if let project::LspStoreEvent::LanguageServerUpdate { + message: + client::proto::update_language_server::Variant::WorkProgress( + client::proto::LspWorkProgress { + message: Some(message), + .. + }, + ), + .. + } = event + { + step_progress.set_substatus(message.clone()); + } + } + }), + cx.subscribe(project, { + let step_progress = step_progress.clone(); + move |_, event, cx| match event { + project::Event::DiskBasedDiagnosticsFinished { language_server_id } => { + let lsp_store = lsp_store.read(cx); + let name = lsp_store + .language_server_adapter_for_id(*language_server_id) + .unwrap() + .name(); + step_progress.set_substatus(format!("LSP idle: {}", name)); + tx.try_send(*language_server_id).ok(); + } + _ => {} + } + }), + ]; + + project + .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))? + .await?; + + let mut pending_language_server_ids = HashSet::from_iter(language_server_ids.into_iter()); + while !pending_language_server_ids.is_empty() { + futures::select! { + language_server_id = rx.next() => { + if let Some(id) = language_server_id { + pending_language_server_ids.remove(&id); + } + }, + _ = timeout.clone().fuse() => { + return Err(anyhow::anyhow!("LSP wait timed out after 5 minutes")); + } + } + } + + drop(subscriptions); + step_progress.clear_substatus(); + Ok(()) +} diff --git a/crates/edit_prediction_cli/src/score.rs b/crates/edit_prediction_cli/src/score.rs new file mode 100644 index 0000000000000000000000000000000000000000..7b507e6d19c943de92eb0b22c7d24d4026789fed --- /dev/null +++ b/crates/edit_prediction_cli/src/score.rs @@ -0,0 +1,123 @@ +use crate::{ + PredictArgs, + example::{Example, ExampleScore}, + headless::EpAppState, + metrics::{self, ClassificationMetrics}, + predict::run_prediction, + progress::{Progress, Step}, +}; +use edit_prediction::udiff::DiffLine; +use gpui::AsyncApp; +use std::sync::Arc; + +pub async fn run_scoring( + example: &mut Example, + args: &PredictArgs, + app_state: Arc, + cx: AsyncApp, +) -> anyhow::Result<()> { + run_prediction( + example, + Some(args.provider), + args.repetitions, + app_state, + cx, + ) + .await?; + + let _progress = Progress::global().start(Step::Score, &example.spec.name); + + let expected_patch = parse_patch(&example.spec.expected_patch); + + let mut scores = vec![]; + + for pred in &example.predictions { + let actual_patch = parse_patch(&pred.actual_patch); + let line_match = metrics::line_match_score(&expected_patch, &actual_patch); + let delta_chr_f = metrics::delta_chr_f(&expected_patch, &actual_patch) as f32; + + scores.push(ExampleScore { + delta_chr_f, + line_match, + }); + } + + example.score = scores; + Ok(()) +} + +fn parse_patch(patch: &str) -> Vec> { + patch.lines().map(DiffLine::parse).collect() +} + +pub fn print_report(examples: &[Example]) { + eprintln!( + "──────────────────────────────────────────────────────────────────────────────────────" + ); + eprintln!( + "{:<30} {:>4} {:>4} {:>4} {:>10} {:>8} {:>8} {:>10}", + "Example name", "TP", "FP", "FN", "Precision", "Recall", "F1", "DeltaChrF" + ); + eprintln!( + "──────────────────────────────────────────────────────────────────────────────────────" + ); + + let mut all_line_match_scores = Vec::new(); + let mut all_delta_chr_f_scores = Vec::new(); + + for example in examples { + for score in example.score.iter() { + let line_match = &score.line_match; + + eprintln!( + "{:<30} {:>4} {:>4} {:>4} {:>9.2}% {:>7.2}% {:>7.2}% {:>9.2}", + truncate_name(&example.spec.name, 30), + line_match.true_positives, + line_match.false_positives, + line_match.false_negatives, + line_match.precision() * 100.0, + line_match.recall() * 100.0, + line_match.f1_score() * 100.0, + score.delta_chr_f + ); + + all_line_match_scores.push(line_match.clone()); + all_delta_chr_f_scores.push(score.delta_chr_f); + } + } + + eprintln!( + "──────────────────────────────────────────────────────────────────────────────────────" + ); + + if !all_line_match_scores.is_empty() { + let total_line_match = ClassificationMetrics::aggregate(all_line_match_scores.iter()); + let avg_delta_chr_f: f32 = + all_delta_chr_f_scores.iter().sum::() / all_delta_chr_f_scores.len() as f32; + + eprintln!( + "{:<30} {:>4} {:>4} {:>4} {:>9.2}% {:>7.2}% {:>7.2}% {:>9.2}", + "TOTAL", + total_line_match.true_positives, + total_line_match.false_positives, + total_line_match.false_negatives, + total_line_match.precision() * 100.0, + total_line_match.recall() * 100.0, + total_line_match.f1_score() * 100.0, + avg_delta_chr_f + ); + eprintln!( + "──────────────────────────────────────────────────────────────────────────────────────" + ); + } + + eprintln!("\n"); +} + +fn truncate_name(name: &str, max_len: usize) -> String { + if name.len() <= max_len { + name.to_string() + } else { + format!("{}...", &name[..max_len - 3]) + } +} diff --git a/crates/edit_prediction_cli/src/source_location.rs b/crates/edit_prediction_cli/src/source_location.rs deleted file mode 100644 index 3438675e78ac4d8bba6f58f7ce8a9016aed6c0c7..0000000000000000000000000000000000000000 --- a/crates/edit_prediction_cli/src/source_location.rs +++ /dev/null @@ -1,70 +0,0 @@ -use std::{fmt, fmt::Display, path::Path, str::FromStr, sync::Arc}; - -use ::util::{paths::PathStyle, rel_path::RelPath}; -use anyhow::{Result, anyhow}; -use language::Point; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; - -#[derive(Debug, Clone, Hash, Eq, PartialEq)] -pub struct SourceLocation { - pub path: Arc, - pub point: Point, -} - -impl Serialize for SourceLocation { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - serializer.serialize_str(&self.to_string()) - } -} - -impl<'de> Deserialize<'de> for SourceLocation { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let s = String::deserialize(deserializer)?; - s.parse().map_err(serde::de::Error::custom) - } -} - -impl Display for SourceLocation { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "{}:{}:{}", - self.path.display(PathStyle::Posix), - self.point.row + 1, - self.point.column + 1 - ) - } -} - -impl FromStr for SourceLocation { - type Err = anyhow::Error; - - fn from_str(s: &str) -> Result { - let parts: Vec<&str> = s.split(':').collect(); - if parts.len() != 3 { - return Err(anyhow!( - "Invalid source location. Expected 'file.rs:line:column', got '{}'", - s - )); - } - - let path = RelPath::new(Path::new(&parts[0]), PathStyle::local())?.into_arc(); - let line: u32 = parts[1] - .parse() - .map_err(|_| anyhow!("Invalid line number: '{}'", parts[1]))?; - let column: u32 = parts[2] - .parse() - .map_err(|_| anyhow!("Invalid column number: '{}'", parts[2]))?; - - // Convert from 1-based to 0-based indexing - let point = Point::new(line.saturating_sub(1), column.saturating_sub(1)); - - Ok(SourceLocation { path, point }) - } -} diff --git a/crates/edit_prediction_cli/src/training/teacher.prompt.md b/crates/edit_prediction_cli/src/teacher.prompt.md similarity index 95% rename from crates/edit_prediction_cli/src/training/teacher.prompt.md rename to crates/edit_prediction_cli/src/teacher.prompt.md index af67c871ef31a21a8744bf71375a50128d9699b6..d629152da6739ec1d603857f6a9ee556c8986fe8 100644 --- a/crates/edit_prediction_cli/src/training/teacher.prompt.md +++ b/crates/edit_prediction_cli/src/teacher.prompt.md @@ -18,6 +18,7 @@ Focus on: Rules: - Do not just mechanically apply patterns - reason about what changes make sense given the context and the programmer's apparent goals. - Do not just fix syntax errors - look for the broader refactoring pattern and apply it systematically throughout the code. +- Keep existing formatting unless it's absolutely necessary Input format: - You receive small code fragments called context (structs, field definitions, function signatures, etc.). They may or may not be relevant. @@ -46,3 +47,7 @@ Output example: ## Code Context {{context}} + +## Editable region + +{{editable_region}} diff --git a/crates/edit_prediction_cli/src/training/context.rs b/crates/edit_prediction_cli/src/training/context.rs deleted file mode 100644 index 7b6d9cc19c1c3750bbf03158ceec5c79a9df0340..0000000000000000000000000000000000000000 --- a/crates/edit_prediction_cli/src/training/context.rs +++ /dev/null @@ -1,89 +0,0 @@ -use std::path::Path; - -use crate::{source_location::SourceLocation, training::teacher::TeacherModel}; - -#[derive(Debug, Clone, Default, clap::ValueEnum)] -pub enum ContextType { - #[default] - CurrentFile, -} - -const MAX_CONTEXT_SIZE: usize = 32768; - -pub fn collect_context( - context_type: &ContextType, - worktree_dir: &Path, - cursor: SourceLocation, -) -> String { - let context = match context_type { - ContextType::CurrentFile => { - let file_path = worktree_dir.join(cursor.path.as_std_path()); - let context = std::fs::read_to_string(&file_path).unwrap_or_default(); - - let context = add_special_tags(&context, worktree_dir, cursor); - context - } - }; - - let region_end_offset = context.find(TeacherModel::REGION_END); - - if context.len() <= MAX_CONTEXT_SIZE { - return context; - } - - if let Some(region_end_offset) = region_end_offset - && region_end_offset + TeacherModel::REGION_END.len() > MAX_CONTEXT_SIZE - { - let to_truncate = context.len() - MAX_CONTEXT_SIZE; - format!( - "[...{} bytes truncated]\n{}\n", - to_truncate, - &context[to_truncate..] - ) - } else { - format!( - "{}\n[...{} bytes truncated]\n", - &context[..MAX_CONTEXT_SIZE], - context.len() - MAX_CONTEXT_SIZE - ) - } -} - -/// Add <|editable_region_start/end|> tags -fn add_special_tags(context: &str, worktree_dir: &Path, cursor: SourceLocation) -> String { - let path = worktree_dir.join(cursor.path.as_std_path()); - let file = std::fs::read_to_string(&path).unwrap_or_default(); - let lines = file.lines().collect::>(); - let cursor_row = cursor.point.row as usize; - let start_line = cursor_row.saturating_sub(TeacherModel::LEFT_CONTEXT_SIZE); - let end_line = (cursor_row + TeacherModel::RIGHT_CONTEXT_SIZE).min(lines.len()); - - let snippet = lines[start_line..end_line].join("\n"); - - if context.contains(&snippet) { - let mut cursor_line = lines[cursor_row].to_string(); - cursor_line.insert_str(cursor.point.column as usize, TeacherModel::USER_CURSOR); - - let mut snippet_with_tags_lines = vec![]; - snippet_with_tags_lines.push(TeacherModel::REGION_START); - snippet_with_tags_lines.extend(&lines[start_line..cursor_row]); - snippet_with_tags_lines.push(&cursor_line); - snippet_with_tags_lines.extend(&lines[cursor_row + 1..end_line]); - snippet_with_tags_lines.push(TeacherModel::REGION_END); - let snippet_with_tags = snippet_with_tags_lines.join("\n"); - - context.replace(&snippet, &snippet_with_tags) - } else { - log::warn!( - "Can't find area around the cursor in the context; proceeding without special tags" - ); - context.to_string() - } -} - -pub fn strip_special_tags(context: &str) -> String { - context - .replace(TeacherModel::REGION_START, "") - .replace(TeacherModel::REGION_END, "") - .replace(TeacherModel::USER_CURSOR, "") -} diff --git a/crates/edit_prediction_cli/src/training/distill.rs b/crates/edit_prediction_cli/src/training/distill.rs deleted file mode 100644 index 277e35551a9fbce43982de832de5ccecf8d6e92e..0000000000000000000000000000000000000000 --- a/crates/edit_prediction_cli/src/training/distill.rs +++ /dev/null @@ -1,94 +0,0 @@ -use serde::Deserialize; -use std::sync::Arc; - -use crate::{ - DistillArguments, - example::Example, - source_location::SourceLocation, - training::{ - context::ContextType, - llm_client::LlmClient, - teacher::{TeacherModel, TeacherOutput}, - }, -}; -use anyhow::Result; -use reqwest_client::ReqwestClient; - -#[derive(Debug, Deserialize)] -pub struct SplitCommit { - repo_url: String, - commit_sha: String, - edit_history: String, - expected_patch: String, - cursor_position: String, -} - -pub async fn run_distill(arguments: DistillArguments) -> Result<()> { - let split_commits: Vec = std::fs::read_to_string(&arguments.split_commit_dataset) - .expect("Failed to read split commit dataset") - .lines() - .map(|line| serde_json::from_str(line).expect("Failed to parse JSON line")) - .collect(); - - let http_client: Arc = Arc::new(ReqwestClient::new()); - - let llm_client = if let Some(cache_path) = arguments.batch { - LlmClient::batch(&cache_path, http_client)? - } else { - LlmClient::plain(http_client)? - }; - - let mut teacher = TeacherModel::new( - "claude-sonnet-4-5".to_string(), - ContextType::CurrentFile, - llm_client, - ); - - let mut num_marked_for_batching = 0; - - for commit in split_commits { - if let Some(distilled) = distill_one(&mut teacher, commit).await? { - println!("{}", serde_json::to_string(&distilled)?); - } else { - if num_marked_for_batching == 0 { - log::warn!("Marked for batching"); - } - num_marked_for_batching += 1; - } - } - - eprintln!( - "{} requests are marked for batching", - num_marked_for_batching - ); - let llm_client = teacher.client; - llm_client.sync_batches().await?; - - Ok(()) -} - -pub async fn distill_one( - teacher: &mut TeacherModel, - commit: SplitCommit, -) -> Result> { - let cursor: SourceLocation = commit - .cursor_position - .parse() - .expect("Failed to parse cursor position"); - - let path = cursor.path.to_rel_path_buf(); - - let example = Example { - repository_url: commit.repo_url, - revision: commit.commit_sha, - uncommitted_diff: commit.edit_history.clone(), - cursor_path: path.as_std_path().to_path_buf(), - cursor_position: commit.cursor_position, - edit_history: commit.edit_history, // todo: trim - expected_patch: commit.expected_patch, - }; - - let prediction = teacher.predict(example).await; - - prediction -} diff --git a/crates/edit_prediction_cli/src/training/mod.rs b/crates/edit_prediction_cli/src/training/mod.rs deleted file mode 100644 index dc564c4dc86c8e095e8e93ccbdfb29d3313e922a..0000000000000000000000000000000000000000 --- a/crates/edit_prediction_cli/src/training/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod context; -pub mod distill; -pub mod llm_client; -pub mod teacher; diff --git a/crates/edit_prediction_cli/src/training/teacher.rs b/crates/edit_prediction_cli/src/training/teacher.rs deleted file mode 100644 index 99672db8f99a87b99a43c8876db2fd0c2f307b21..0000000000000000000000000000000000000000 --- a/crates/edit_prediction_cli/src/training/teacher.rs +++ /dev/null @@ -1,266 +0,0 @@ -use crate::{ - example::Example, - source_location::SourceLocation, - training::{ - context::{ContextType, collect_context, strip_special_tags}, - llm_client::LlmClient, - }, -}; -use anthropic::{Message, RequestContent, ResponseContent, Role}; -use anyhow::Result; - -pub struct TeacherModel { - pub llm_name: String, - pub context: ContextType, - pub client: LlmClient, -} - -#[derive(Debug, serde::Serialize)] -pub struct TeacherOutput { - parsed_output: String, - prompt: String, - raw_llm_response: String, - context: String, - diff: String, -} - -impl TeacherModel { - const PROMPT: &str = include_str!("teacher.prompt.md"); - pub(crate) const REGION_START: &str = "<|editable_region_start|>\n"; - pub(crate) const REGION_END: &str = "<|editable_region_end|>"; - pub(crate) const USER_CURSOR: &str = "<|user_cursor|>"; - - /// Number of lines to include before the cursor position - pub(crate) const LEFT_CONTEXT_SIZE: usize = 5; - - /// Number of lines to include after the cursor position - pub(crate) const RIGHT_CONTEXT_SIZE: usize = 5; - - /// Truncate edit history to this number of last lines - const MAX_HISTORY_LINES: usize = 128; - - pub fn new(llm_name: String, context: ContextType, client: LlmClient) -> Self { - TeacherModel { - llm_name, - context, - client, - } - } - - pub async fn predict(&self, input: Example) -> Result> { - let name = input.unique_name(); - let worktree_dir = input.setup_worktree(name).await?; - let cursor: SourceLocation = input - .cursor_position - .parse() - .expect("Failed to parse cursor position"); - - let context = collect_context(&self.context, &worktree_dir, cursor.clone()); - let edit_history = Self::format_edit_history(&input.edit_history); - - let prompt = Self::PROMPT - .replace("{{context}}", &context) - .replace("{{edit_history}}", &edit_history); - - let messages = vec![Message { - role: Role::User, - content: vec![RequestContent::Text { - text: prompt.clone(), - cache_control: None, - }], - }]; - - let Some(response) = self - .client - .generate(self.llm_name.clone(), 16384, messages) - .await? - else { - return Ok(None); - }; - - let response_text = response - .content - .into_iter() - .filter_map(|content| match content { - ResponseContent::Text { text } => Some(text), - _ => None, - }) - .collect::>() - .join("\n"); - - let parsed_output = self.parse_response(&response_text); - - let original_editable_region = Self::extract_editable_region(&context); - let context_after_edit = context.replace(&original_editable_region, &parsed_output); - let context_after_edit = strip_special_tags(&context_after_edit); - let context_before_edit = strip_special_tags(&context); - let diff = language::unified_diff(&context_before_edit, &context_after_edit); - - // zeta distill --batch batch_results.txt - // zeta distill - // 1. Run `zeta distill <2000 examples <- all examples>` for the first time - // - store LLM requests in a batch, don't actual send the request - // - send the batch (2000 requests) after all inputs are processed - // 2. `zeta send-batches` - // - upload the batch to Anthropic - - // https://platform.claude.com/docs/en/build-with-claude/batch-processing - // https://crates.io/crates/anthropic-sdk-rust - - // - poll for results - // - when ready, store results in cache (a database) - // 3. `zeta distill` again - // - use the cached results this time - - Ok(Some(TeacherOutput { - parsed_output, - prompt, - raw_llm_response: response_text, - context, - diff, - })) - } - - fn parse_response(&self, content: &str) -> String { - let codeblock = Self::extract_last_codeblock(content); - let editable_region = Self::extract_editable_region(&codeblock); - - editable_region - } - - /// Extract content from the last code-fenced block if any, or else return content as is - fn extract_last_codeblock(text: &str) -> String { - let mut last_block = None; - let mut search_start = 0; - - while let Some(start) = text[search_start..].find("```") { - let start = start + search_start; - let bytes = text.as_bytes(); - let mut backtick_end = start; - - while backtick_end < bytes.len() && bytes[backtick_end] == b'`' { - backtick_end += 1; - } - - let backtick_count = backtick_end - start; - let closing_backticks = "`".repeat(backtick_count); - - if let Some(end_pos) = text[backtick_end..].find(&closing_backticks) { - let code_block = &text[backtick_end + 1..backtick_end + end_pos - 1]; - last_block = Some(code_block.to_string()); - search_start = backtick_end + end_pos + backtick_count; - } else { - break; - } - } - - last_block.unwrap_or_else(|| text.to_string()) - } - - fn extract_editable_region(text: &str) -> String { - let start = text - .find(Self::REGION_START) - .map_or(0, |pos| pos + Self::REGION_START.len()); - let end = text.find(Self::REGION_END).unwrap_or(text.len()); - - text[start..end].to_string() - } - - /// Truncates edit history to a maximum length and removes comments (unified diff garbage lines) - fn format_edit_history(edit_history: &str) -> String { - let lines = edit_history - .lines() - .filter(|&s| Self::is_content_line(s)) - .collect::>(); - - let history_lines = if lines.len() > Self::MAX_HISTORY_LINES { - &lines[lines.len() - Self::MAX_HISTORY_LINES..] - } else { - &lines - }; - history_lines.join("\n") - } - - fn is_content_line(s: &str) -> bool { - s.starts_with("-") - || s.starts_with("+") - || s.starts_with(" ") - || s.starts_with("---") - || s.starts_with("+++") - || s.starts_with("@@") - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_response() { - let teacher = TeacherModel::new( - "test".to_string(), - ContextType::CurrentFile, - LlmClient::dummy(), - ); - let response = "This is a test response."; - let parsed = teacher.parse_response(response); - assert_eq!(parsed, response.to_string()); - - let response = indoc::indoc! {" - Some thinking - - ````` - actual response - ````` - "}; - let parsed = teacher.parse_response(response); - assert_eq!(parsed, "actual response"); - } - - #[test] - fn test_extract_last_code_block() { - let text = indoc::indoc! {" - Some thinking - - ``` - first block - ``` - - ````` - last block - ````` - "}; - let last_block = TeacherModel::extract_last_codeblock(text); - assert_eq!(last_block, "last block"); - } - - #[test] - fn test_extract_editable_region() { - let teacher = TeacherModel::new( - "test".to_string(), - ContextType::CurrentFile, - LlmClient::dummy(), - ); - let response = indoc::indoc! {" - some lines - are - here - <|editable_region_start|> - one - two three - - <|editable_region_end|> - more - lines here - "}; - let parsed = teacher.parse_response(response); - assert_eq!( - parsed, - indoc::indoc! {" - one - two three - - "} - ); - } -} diff --git a/crates/edit_prediction_cli/src/util.rs b/crates/edit_prediction_cli/src/util.rs deleted file mode 100644 index f4a51d94585f82da008ac832dc62392c365738fd..0000000000000000000000000000000000000000 --- a/crates/edit_prediction_cli/src/util.rs +++ /dev/null @@ -1,198 +0,0 @@ -use anyhow::{Result, anyhow}; -use futures::channel::mpsc; -use futures::{FutureExt as _, StreamExt as _}; -use gpui::{AsyncApp, Entity, Task}; -use language::{Buffer, LanguageId, LanguageNotFound, LanguageServerId, ParseStatus}; -use project::lsp_store::OpenLspBufferHandle; -use project::{Project, ProjectPath, Worktree}; -use std::collections::HashSet; -use std::sync::Arc; -use std::time::Duration; -use util::rel_path::RelPath; - -pub fn open_buffer( - project: Entity, - worktree: Entity, - path: Arc, - cx: &AsyncApp, -) -> Task>> { - cx.spawn(async move |cx| { - let project_path = worktree.read_with(cx, |worktree, _cx| ProjectPath { - worktree_id: worktree.id(), - path, - })?; - - let buffer = project - .update(cx, |project, cx| project.open_buffer(project_path, cx))? - .await?; - - let mut parse_status = buffer.read_with(cx, |buffer, _cx| buffer.parse_status())?; - while *parse_status.borrow() != ParseStatus::Idle { - parse_status.changed().await?; - } - - Ok(buffer) - }) -} - -pub async fn open_buffer_with_language_server( - project: Entity, - worktree: Entity, - path: Arc, - ready_languages: &mut HashSet, - cx: &mut AsyncApp, -) -> Result<(OpenLspBufferHandle, LanguageServerId, Entity)> { - let buffer = open_buffer(project.clone(), worktree, path.clone(), cx).await?; - - let (lsp_open_handle, path_style) = project.update(cx, |project, cx| { - ( - project.register_buffer_with_language_servers(&buffer, cx), - project.path_style(cx), - ) - })?; - - let language_registry = project.read_with(cx, |project, _| project.languages().clone())?; - let result = language_registry - .load_language_for_file_path(path.as_std_path()) - .await; - - if let Err(error) = result - && !error.is::() - { - anyhow::bail!(error); - } - - let Some(language_id) = buffer.read_with(cx, |buffer, _cx| { - buffer.language().map(|language| language.id()) - })? - else { - return Err(anyhow!("No language for {}", path.display(path_style))); - }; - - let log_prefix = format!("{} | ", path.display(path_style)); - if !ready_languages.contains(&language_id) { - wait_for_lang_server(&project, &buffer, log_prefix, cx).await?; - ready_languages.insert(language_id); - } - - let lsp_store = project.read_with(cx, |project, _cx| project.lsp_store())?; - - // hacky wait for buffer to be registered with the language server - for _ in 0..100 { - let Some(language_server_id) = lsp_store.update(cx, |lsp_store, cx| { - buffer.update(cx, |buffer, cx| { - lsp_store - .language_servers_for_local_buffer(&buffer, cx) - .next() - .map(|(_, language_server)| language_server.server_id()) - }) - })? - else { - cx.background_executor() - .timer(Duration::from_millis(10)) - .await; - continue; - }; - - return Ok((lsp_open_handle, language_server_id, buffer)); - } - - return Err(anyhow!("No language server found for buffer")); -} - -// TODO: Dedupe with similar function in crates/eval/src/instance.rs -pub fn wait_for_lang_server( - project: &Entity, - buffer: &Entity, - log_prefix: String, - cx: &mut AsyncApp, -) -> Task> { - eprintln!("{}⏵ Waiting for language server", log_prefix); - - let (mut tx, mut rx) = mpsc::channel(1); - - let lsp_store = project - .read_with(cx, |project, _| project.lsp_store()) - .unwrap(); - - let has_lang_server = buffer - .update(cx, |buffer, cx| { - lsp_store.update(cx, |lsp_store, cx| { - lsp_store - .language_servers_for_local_buffer(buffer, cx) - .next() - .is_some() - }) - }) - .unwrap_or(false); - - if has_lang_server { - project - .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)) - .unwrap() - .detach(); - } - let (mut added_tx, mut added_rx) = mpsc::channel(1); - - let subscriptions = [ - cx.subscribe(&lsp_store, { - let log_prefix = log_prefix.clone(); - move |_, event, _| { - if let project::LspStoreEvent::LanguageServerUpdate { - message: - client::proto::update_language_server::Variant::WorkProgress( - client::proto::LspWorkProgress { - message: Some(message), - .. - }, - ), - .. - } = event - { - eprintln!("{}⟲ {message}", log_prefix) - } - } - }), - cx.subscribe(project, { - let buffer = buffer.clone(); - move |project, event, cx| match event { - project::Event::LanguageServerAdded(_, _, _) => { - let buffer = buffer.clone(); - project - .update(cx, |project, cx| project.save_buffer(buffer, cx)) - .detach(); - added_tx.try_send(()).ok(); - } - project::Event::DiskBasedDiagnosticsFinished { .. } => { - tx.try_send(()).ok(); - } - _ => {} - } - }), - ]; - - cx.spawn(async move |cx| { - if !has_lang_server { - // some buffers never have a language server, so this aborts quickly in that case. - let timeout = cx.background_executor().timer(Duration::from_secs(500)); - futures::select! { - _ = added_rx.next() => {}, - _ = timeout.fuse() => { - anyhow::bail!("Waiting for language server add timed out after 5 seconds"); - } - }; - } - let timeout = cx.background_executor().timer(Duration::from_secs(60 * 5)); - let result = futures::select! { - _ = rx.next() => { - eprintln!("{}⚑ Language server idle", log_prefix); - anyhow::Ok(()) - }, - _ = timeout.fuse() => { - anyhow::bail!("LSP wait timed out after 5 minutes"); - } - }; - drop(subscriptions); - result - }) -} diff --git a/crates/edit_prediction_context/Cargo.toml b/crates/edit_prediction_context/Cargo.toml index f113c3c46075ca70e61d8d07947d37502e8528e8..731ffc85d159e285ad497c29fba2f74179d4149b 100644 --- a/crates/edit_prediction_context/Cargo.toml +++ b/crates/edit_prediction_context/Cargo.toml @@ -26,6 +26,7 @@ serde.workspace = true smallvec.workspace = true tree-sitter.workspace = true util.workspace = true +zeta_prompt.workspace = true [dev-dependencies] env_logger.workspace = true diff --git a/crates/edit_prediction_context/src/assemble_excerpts.rs b/crates/edit_prediction_context/src/assemble_excerpts.rs index 15f4c03d653429af671c22d6b5abc652d282a38e..e337211cf90f0e4fbcb481f836e512b1ceb6477f 100644 --- a/crates/edit_prediction_context/src/assemble_excerpts.rs +++ b/crates/edit_prediction_context/src/assemble_excerpts.rs @@ -1,6 +1,6 @@ -use crate::RelatedExcerpt; use language::{BufferSnapshot, OffsetRangeExt as _, Point}; use std::ops::Range; +use zeta_prompt::RelatedExcerpt; #[cfg(not(test))] const MAX_OUTLINE_ITEM_BODY_SIZE: usize = 512; @@ -76,14 +76,9 @@ pub fn assemble_excerpts( input_ranges .into_iter() - .map(|range| { - let offset_range = range.to_offset(buffer); - RelatedExcerpt { - point_range: range, - anchor_range: buffer.anchor_before(offset_range.start) - ..buffer.anchor_after(offset_range.end), - text: buffer.as_rope().slice(offset_range), - } + .map(|range| RelatedExcerpt { + row_range: range.start.row..range.end.row, + text: buffer.text_for_range(range).collect(), }) .collect() } diff --git a/crates/edit_prediction_context/src/edit_prediction_context.rs b/crates/edit_prediction_context/src/edit_prediction_context.rs index 475050fabb8b17ad76c34234094cf798e36a76ab..15576a835d9b4b0781b1e3979edbed443fa40f62 100644 --- a/crates/edit_prediction_context/src/edit_prediction_context.rs +++ b/crates/edit_prediction_context/src/edit_prediction_context.rs @@ -3,13 +3,13 @@ use anyhow::Result; use collections::HashMap; use futures::{FutureExt, StreamExt as _, channel::mpsc, future}; use gpui::{App, AppContext, AsyncApp, Context, Entity, EventEmitter, Task, WeakEntity}; -use language::{Anchor, Buffer, BufferSnapshot, OffsetRangeExt as _, Point, Rope, ToOffset as _}; +use language::{Anchor, Buffer, BufferSnapshot, OffsetRangeExt as _, Point, ToOffset as _}; use project::{LocationLink, Project, ProjectPath}; -use serde::{Serialize, Serializer}; use smallvec::SmallVec; use std::{ collections::hash_map, ops::Range, + path::Path, sync::Arc, time::{Duration, Instant}, }; @@ -24,12 +24,14 @@ mod fake_definition_lsp; pub use cloud_llm_client::predict_edits_v3::Line; pub use excerpt::{EditPredictionExcerpt, EditPredictionExcerptOptions, EditPredictionExcerptText}; +pub use zeta_prompt::{RelatedExcerpt, RelatedFile}; const IDENTIFIER_LINE_COUNT: u32 = 3; pub struct RelatedExcerptStore { project: WeakEntity, - related_files: Vec, + related_files: Arc<[RelatedFile]>, + related_file_buffers: Vec>, cache: HashMap>, update_tx: mpsc::UnboundedSender<(Entity, Anchor)>, identifier_line_count: u32, @@ -68,82 +70,6 @@ struct CachedDefinition { anchor_range: Range, } -#[derive(Clone, Debug, Serialize)] -pub struct RelatedFile { - #[serde(serialize_with = "serialize_project_path")] - pub path: ProjectPath, - #[serde(skip)] - pub buffer: WeakEntity, - pub excerpts: Vec, - pub max_row: u32, -} - -impl RelatedFile { - pub fn merge_excerpts(&mut self) { - self.excerpts.sort_unstable_by(|a, b| { - a.point_range - .start - .cmp(&b.point_range.start) - .then(b.point_range.end.cmp(&a.point_range.end)) - }); - - let mut index = 1; - while index < self.excerpts.len() { - if self.excerpts[index - 1] - .point_range - .end - .cmp(&self.excerpts[index].point_range.start) - .is_ge() - { - let removed = self.excerpts.remove(index); - if removed - .point_range - .end - .cmp(&self.excerpts[index - 1].point_range.end) - .is_gt() - { - self.excerpts[index - 1].point_range.end = removed.point_range.end; - self.excerpts[index - 1].anchor_range.end = removed.anchor_range.end; - } - } else { - index += 1; - } - } - } -} - -#[derive(Clone, Debug, Serialize)] -pub struct RelatedExcerpt { - #[serde(skip)] - pub anchor_range: Range, - #[serde(serialize_with = "serialize_point_range")] - pub point_range: Range, - #[serde(serialize_with = "serialize_rope")] - pub text: Rope, -} - -fn serialize_project_path( - project_path: &ProjectPath, - serializer: S, -) -> Result { - project_path.path.serialize(serializer) -} - -fn serialize_rope(rope: &Rope, serializer: S) -> Result { - rope.to_string().serialize(serializer) -} - -fn serialize_point_range( - range: &Range, - serializer: S, -) -> Result { - [ - [range.start.row, range.start.column], - [range.end.row, range.end.column], - ] - .serialize(serializer) -} - const DEBOUNCE_DURATION: Duration = Duration::from_millis(100); impl EventEmitter for RelatedExcerptStore {} @@ -179,7 +105,8 @@ impl RelatedExcerptStore { RelatedExcerptStore { project: project.downgrade(), update_tx, - related_files: Vec::new(), + related_files: Vec::new().into(), + related_file_buffers: Vec::new(), cache: Default::default(), identifier_line_count: IDENTIFIER_LINE_COUNT, } @@ -193,8 +120,21 @@ impl RelatedExcerptStore { self.update_tx.unbounded_send((buffer, position)).ok(); } - pub fn related_files(&self) -> &[RelatedFile] { - &self.related_files + pub fn related_files(&self) -> Arc<[RelatedFile]> { + self.related_files.clone() + } + + pub fn related_files_with_buffers( + &self, + ) -> impl Iterator)> { + self.related_files + .iter() + .cloned() + .zip(self.related_file_buffers.iter().cloned()) + } + + pub fn set_related_files(&mut self, files: Vec) { + self.related_files = files.into(); } async fn fetch_excerpts( @@ -297,7 +237,8 @@ impl RelatedExcerptStore { } mean_definition_latency /= cache_miss_count.max(1) as u32; - let (new_cache, related_files) = rebuild_related_files(new_cache, cx).await?; + let (new_cache, related_files, related_file_buffers) = + rebuild_related_files(&project, new_cache, cx).await?; if let Some(file) = &file { log::debug!( @@ -309,7 +250,8 @@ impl RelatedExcerptStore { this.update(cx, |this, cx| { this.cache = new_cache; - this.related_files = related_files; + this.related_files = related_files.into(); + this.related_file_buffers = related_file_buffers; cx.emit(RelatedExcerptStoreEvent::FinishedRefresh { cache_hit_count, cache_miss_count, @@ -323,10 +265,16 @@ impl RelatedExcerptStore { } async fn rebuild_related_files( + project: &Entity, new_entries: HashMap>, cx: &mut AsyncApp, -) -> Result<(HashMap>, Vec)> { +) -> Result<( + HashMap>, + Vec, + Vec>, +)> { let mut snapshots = HashMap::default(); + let mut worktree_root_names = HashMap::default(); for entry in new_entries.values() { for definition in &entry.definitions { if let hash_map::Entry::Vacant(e) = snapshots.entry(definition.buffer.entity_id()) { @@ -340,12 +288,22 @@ async fn rebuild_related_files( .read_with(cx, |buffer, _| buffer.snapshot())?, ); } + let worktree_id = definition.path.worktree_id; + if let hash_map::Entry::Vacant(e) = + worktree_root_names.entry(definition.path.worktree_id) + { + project.read_with(cx, |project, cx| { + if let Some(worktree) = project.worktree_for_id(worktree_id, cx) { + e.insert(worktree.read(cx).root_name().as_unix_str().to_string()); + } + })?; + } } } Ok(cx .background_spawn(async move { - let mut files = Vec::::new(); + let mut files = Vec::new(); let mut ranges_by_buffer = HashMap::<_, Vec>>::default(); let mut paths_by_buffer = HashMap::default(); for entry in new_entries.values() { @@ -369,20 +327,37 @@ async fn rebuild_related_files( continue; }; let excerpts = assemble_excerpts(snapshot, ranges); - files.push(RelatedFile { - path: project_path.clone(), - buffer: buffer.downgrade(), - excerpts, - max_row: snapshot.max_point().row, - }); + let Some(root_name) = worktree_root_names.get(&project_path.worktree_id) else { + continue; + }; + + let path = Path::new(&format!( + "{}/{}", + root_name, + project_path.path.as_unix_str() + )) + .into(); + + files.push(( + buffer, + RelatedFile { + path, + excerpts, + max_row: snapshot.max_point().row, + }, + )); } - files.sort_by_key(|file| file.path.clone()); - (new_entries, files) + files.sort_by_key(|(_, file)| file.path.clone()); + let (related_buffers, related_files) = files.into_iter().unzip(); + + (new_entries, related_files, related_buffers) }) .await) } +const MAX_TARGET_LEN: usize = 128; + fn process_definition( location: LocationLink, project: &Entity, @@ -395,6 +370,15 @@ fn process_definition( if worktree.read(cx).is_single_file() { return None; } + + // If the target range is large, it likely means we requested the definition of an entire module. + // For individual definitions, the target range should be small as it only covers the symbol. + let buffer = location.target.buffer.read(cx); + let target_len = anchor_range.to_offset(&buffer).len(); + if target_len > MAX_TARGET_LEN { + return None; + } + Some(CachedDefinition { path: ProjectPath { worktree_id: file.worktree_id(cx), diff --git a/crates/edit_prediction_context/src/edit_prediction_context_tests.rs b/crates/edit_prediction_context/src/edit_prediction_context_tests.rs index dba8d89e593ccb60e7eae5d091708e82debef0f5..d93a66081164a3fc70f7e1072d91a02bd9adbd37 100644 --- a/crates/edit_prediction_context/src/edit_prediction_context_tests.rs +++ b/crates/edit_prediction_context/src/edit_prediction_context_tests.rs @@ -48,7 +48,7 @@ async fn test_edit_prediction_context(cx: &mut TestAppContext) { &excerpts, &[ ( - "src/company.rs", + "root/src/company.rs", &[indoc! {" pub struct Company { owner: Arc, @@ -56,7 +56,7 @@ async fn test_edit_prediction_context(cx: &mut TestAppContext) { }"}], ), ( - "src/main.rs", + "root/src/main.rs", &[ indoc! {" pub struct Session { @@ -71,7 +71,7 @@ async fn test_edit_prediction_context(cx: &mut TestAppContext) { ], ), ( - "src/person.rs", + "root/src/person.rs", &[ indoc! {" impl Person { @@ -446,7 +446,7 @@ fn assert_related_files(actual_files: &[RelatedFile], expected_files: &[(&str, & .iter() .map(|excerpt| excerpt.text.to_string()) .collect::>(); - (file.path.path.as_unix_str(), excerpts) + (file.path.to_str().unwrap(), excerpts) }) .collect::>(); let expected_excerpts = expected_files @@ -492,10 +492,10 @@ fn format_excerpts(buffer: &Buffer, excerpts: &[RelatedExcerpt]) -> String { if excerpt.text.is_empty() { continue; } - if current_row < excerpt.point_range.start.row { + if current_row < excerpt.row_range.start { writeln!(&mut output, "…").unwrap(); } - current_row = excerpt.point_range.start.row; + current_row = excerpt.row_range.start; for line in excerpt.text.to_string().lines() { output.push_str(line); diff --git a/crates/edit_prediction_types/src/edit_prediction_types.rs b/crates/edit_prediction_types/src/edit_prediction_types.rs index fbcb3c4c00edbc5fb77f04d1fcaaf4b6129c43db..5a37aba59923598b20becd91f07633e409b2bdb7 100644 --- a/crates/edit_prediction_types/src/edit_prediction_types.rs +++ b/crates/edit_prediction_types/src/edit_prediction_types.rs @@ -95,13 +95,6 @@ pub trait EditPredictionDelegate: 'static + Sized { debounce: bool, cx: &mut Context, ); - fn cycle( - &mut self, - buffer: Entity, - cursor_position: language::Anchor, - direction: Direction, - cx: &mut Context, - ); fn accept(&mut self, cx: &mut Context); fn discard(&mut self, cx: &mut Context); fn did_show(&mut self, _cx: &mut Context) {} @@ -136,13 +129,6 @@ pub trait EditPredictionDelegateHandle { debounce: bool, cx: &mut App, ); - fn cycle( - &self, - buffer: Entity, - cursor_position: language::Anchor, - direction: Direction, - cx: &mut App, - ); fn did_show(&self, cx: &mut App); fn accept(&self, cx: &mut App); fn discard(&self, cx: &mut App); @@ -215,18 +201,6 @@ where }) } - fn cycle( - &self, - buffer: Entity, - cursor_position: language::Anchor, - direction: Direction, - cx: &mut App, - ) { - self.update(cx, |this, cx| { - this.cycle(buffer, cursor_position, direction, cx) - }) - } - fn accept(&self, cx: &mut App) { self.update(cx, |this, cx| this.accept(cx)) } @@ -249,6 +223,12 @@ where } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EditPredictionGranularity { + Word, + Line, + Full, +} /// Returns edits updated based on user edits since the old snapshot. None is returned if any user /// edit is not a prefix of a predicted insertion. pub fn interpolate_edits( diff --git a/crates/edit_prediction_ui/Cargo.toml b/crates/edit_prediction_ui/Cargo.toml index fb846f35d76ae2f6478ef675f246e4d06fe5f469..b406a450601bef908c27a48be14fe9b1f2204c08 100644 --- a/crates/edit_prediction_ui/Cargo.toml +++ b/crates/edit_prediction_ui/Cargo.toml @@ -15,14 +15,16 @@ doctest = false [dependencies] anyhow.workspace = true buffer_diff.workspace = true +git.workspace = true +log.workspace = true +time.workspace = true client.workspace = true cloud_llm_client.workspace = true -cloud_zeta2_prompt.workspace = true codestral.workspace = true command_palette_hooks.workspace = true copilot.workspace = true -edit_prediction.workspace = true edit_prediction_types.workspace = true +edit_prediction.workspace = true editor.workspace = true feature_flags.workspace = true fs.workspace = true @@ -42,10 +44,10 @@ telemetry.workspace = true text.workspace = true theme.workspace = true ui.workspace = true -ui_input.workspace = true util.workspace = true workspace.workspace = true zed_actions.workspace = true +zeta_prompt.workspace = true [dev-dependencies] copilot = { workspace = true, features = ["test-support"] } diff --git a/crates/edit_prediction_ui/src/edit_prediction_button.rs b/crates/edit_prediction_ui/src/edit_prediction_button.rs index 04c7614689c5fdc076ab0aa9c4b4fe7d68e2f582..0dcea477200eef9d1eeb6adeff98f47332d751ca 100644 --- a/crates/edit_prediction_ui/src/edit_prediction_button.rs +++ b/crates/edit_prediction_ui/src/edit_prediction_button.rs @@ -3,7 +3,9 @@ use client::{Client, UserStore, zed_urls}; use cloud_llm_client::UsageLimit; use codestral::CodestralEditPredictionDelegate; use copilot::{Copilot, Status}; -use edit_prediction::{MercuryFeatureFlag, SweepFeatureFlag, Zeta2FeatureFlag}; +use edit_prediction::{ + EditPredictionStore, MercuryFeatureFlag, SweepFeatureFlag, Zeta2FeatureFlag, +}; use edit_prediction_types::EditPredictionDelegateHandle; use editor::{ Editor, MultiBufferOffset, SelectionEffects, actions::ShowEditPrediction, scroll::Autoscroll, @@ -42,11 +44,10 @@ use workspace::{ StatusItemView, Toast, Workspace, create_and_open_local_file, item::ItemHandle, notifications::NotificationId, }; -use zed_actions::OpenBrowser; +use zed_actions::{OpenBrowser, OpenSettingsAt}; use crate::{ - ExternalProviderApiKeyModal, RatePredictions, - rate_prediction_modal::PredictEditsRatePredictionsFeatureFlag, + CaptureExample, RatePredictions, rate_prediction_modal::PredictEditsRatePredictionsFeatureFlag, }; actions!( @@ -248,45 +249,21 @@ impl Render for EditPredictionButton { EditPredictionProvider::Codestral => { let enabled = self.editor_enabled.unwrap_or(true); let has_api_key = CodestralEditPredictionDelegate::has_api_key(cx); - let fs = self.fs.clone(); let this = cx.weak_entity(); + let tooltip_meta = if has_api_key { + "Powered by Codestral" + } else { + "Missing API key for Codestral" + }; + div().child( PopoverMenu::new("codestral") .menu(move |window, cx| { - if has_api_key { - this.update(cx, |this, cx| { - this.build_codestral_context_menu(window, cx) - }) - .ok() - } else { - Some(ContextMenu::build(window, cx, |menu, _, _| { - let fs = fs.clone(); - - menu.entry( - "Configure Codestral API Key", - None, - move |window, cx| { - window.dispatch_action( - zed_actions::agent::OpenSettings.boxed_clone(), - cx, - ); - }, - ) - .separator() - .entry( - "Use Zed AI instead", - None, - move |_, cx| { - set_completion_provider( - fs.clone(), - cx, - EditPredictionProvider::Zed, - ) - }, - ) - })) - } + this.update(cx, |this, cx| { + this.build_codestral_context_menu(window, cx) + }) + .ok() }) .anchor(Corner::BottomRight) .trigger_with_tooltip( @@ -304,7 +281,14 @@ impl Render for EditPredictionButton { cx.theme().colors().status_bar_background, )) }), - move |_window, cx| Tooltip::for_action("Codestral", &ToggleMenu, cx), + move |_window, cx| { + Tooltip::with_meta( + "Edit Prediction", + Some(&ToggleMenu), + tooltip_meta, + cx, + ) + }, ) .with_handle(self.popover_menu_handle.clone()), ) @@ -313,6 +297,7 @@ impl Render for EditPredictionButton { let enabled = self.editor_enabled.unwrap_or(true); let ep_icon; + let tooltip_meta; let mut missing_token = false; match provider { @@ -320,15 +305,25 @@ impl Render for EditPredictionButton { EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME, ) => { ep_icon = IconName::SweepAi; + tooltip_meta = if missing_token { + "Missing API key for Sweep" + } else { + "Powered by Sweep" + }; missing_token = edit_prediction::EditPredictionStore::try_global(cx) - .is_some_and(|ep_store| !ep_store.read(cx).has_sweep_api_token()); + .is_some_and(|ep_store| !ep_store.read(cx).has_sweep_api_token(cx)); } EditPredictionProvider::Experimental( EXPERIMENTAL_MERCURY_EDIT_PREDICTION_PROVIDER_NAME, ) => { ep_icon = IconName::Inception; missing_token = edit_prediction::EditPredictionStore::try_global(cx) - .is_some_and(|ep_store| !ep_store.read(cx).has_mercury_api_token()); + .is_some_and(|ep_store| !ep_store.read(cx).has_mercury_api_token(cx)); + tooltip_meta = if missing_token { + "Missing API key for Mercury" + } else { + "Powered by Mercury" + }; } _ => { ep_icon = if enabled { @@ -336,6 +331,7 @@ impl Render for EditPredictionButton { } else { IconName::ZedPredictDisabled }; + tooltip_meta = "Powered by Zeta" } }; @@ -400,33 +396,26 @@ impl Render for EditPredictionButton { }) .when(!self.popover_menu_handle.is_deployed(), |element| { let user = user.clone(); + element.tooltip(move |_window, cx| { - if enabled { + let description = if enabled { if show_editor_predictions { - Tooltip::for_action("Edit Prediction", &ToggleMenu, cx) + tooltip_meta } else if user.is_none() { - Tooltip::with_meta( - "Edit Prediction", - Some(&ToggleMenu), - "Sign In To Use", - cx, - ) + "Sign In To Use" } else { - Tooltip::with_meta( - "Edit Prediction", - Some(&ToggleMenu), - "Hidden For This File", - cx, - ) + "Hidden For This File" } } else { - Tooltip::with_meta( - "Edit Prediction", - Some(&ToggleMenu), - "Disabled For This File", - cx, - ) - } + "Disabled For This File" + }; + + Tooltip::with_meta( + "Edit Prediction", + Some(&ToggleMenu), + description, + cx, + ) }) }); @@ -498,6 +487,21 @@ impl EditPredictionButton { cx.observe_global::(move |_, cx| cx.notify()) .detach(); + cx.observe_global::(move |_, cx| cx.notify()) + .detach(); + + let sweep_api_token_task = edit_prediction::sweep_ai::load_sweep_api_token(cx); + let mercury_api_token_task = edit_prediction::mercury::load_mercury_api_token(cx); + + cx.spawn(async move |this, cx| { + _ = futures::join!(sweep_api_token_task, mercury_api_token_task); + this.update(cx, |_, cx| { + cx.notify(); + }) + .ok(); + }) + .detach(); + CodestralEditPredictionDelegate::ensure_api_key_loaded(client.http_client(), cx); Self { @@ -514,11 +518,17 @@ impl EditPredictionButton { } } - fn get_available_providers(&self, cx: &App) -> Vec { + fn get_available_providers(&self, cx: &mut App) -> Vec { let mut providers = Vec::new(); providers.push(EditPredictionProvider::Zed); + if cx.has_flag::() { + providers.push(EditPredictionProvider::Experimental( + EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME, + )); + } + if let Some(copilot) = Copilot::global(cx) { if matches!(copilot.read(cx).status(), Status::Authorized) { providers.push(EditPredictionProvider::Copilot); @@ -537,24 +547,26 @@ impl EditPredictionButton { providers.push(EditPredictionProvider::Codestral); } - if cx.has_flag::() { + if cx.has_flag::() + && edit_prediction::sweep_ai::sweep_api_token(cx) + .read(cx) + .has_key() + { providers.push(EditPredictionProvider::Experimental( EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME, )); } - if cx.has_flag::() { + if cx.has_flag::() + && edit_prediction::mercury::mercury_api_token(cx) + .read(cx) + .has_key() + { providers.push(EditPredictionProvider::Experimental( EXPERIMENTAL_MERCURY_EDIT_PREDICTION_PROVIDER_NAME, )); } - if cx.has_flag::() { - providers.push(EditPredictionProvider::Experimental( - EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME, - )); - } - providers } @@ -562,13 +574,10 @@ impl EditPredictionButton { &self, mut menu: ContextMenu, current_provider: EditPredictionProvider, - cx: &App, + cx: &mut App, ) -> ContextMenu { let available_providers = self.get_available_providers(cx); - const ZED_AI_CALLOUT: &str = - "Zed's edit prediction is powered by Zeta, an open-source, dataset mode."; - let providers: Vec<_> = available_providers .into_iter() .filter(|p| *p != EditPredictionProvider::None) @@ -581,153 +590,32 @@ impl EditPredictionButton { let is_current = provider == current_provider; let fs = self.fs.clone(); - menu = match provider { - EditPredictionProvider::Zed => menu.item( - ContextMenuEntry::new("Zed AI") - .toggleable(IconPosition::Start, is_current) - .documentation_aside( - DocumentationSide::Left, - DocumentationEdge::Bottom, - |_| Label::new(ZED_AI_CALLOUT).into_any_element(), - ) - .handler(move |_, cx| { - set_completion_provider(fs.clone(), cx, provider); - }), - ), - EditPredictionProvider::Copilot => menu.item( - ContextMenuEntry::new("GitHub Copilot") - .toggleable(IconPosition::Start, is_current) - .handler(move |_, cx| { - set_completion_provider(fs.clone(), cx, provider); - }), - ), - EditPredictionProvider::Supermaven => menu.item( - ContextMenuEntry::new("Supermaven") - .toggleable(IconPosition::Start, is_current) - .handler(move |_, cx| { - set_completion_provider(fs.clone(), cx, provider); - }), - ), - EditPredictionProvider::Codestral => menu.item( - ContextMenuEntry::new("Codestral") - .toggleable(IconPosition::Start, is_current) - .handler(move |_, cx| { - set_completion_provider(fs.clone(), cx, provider); - }), - ), + let name = match provider { + EditPredictionProvider::Zed => "Zed AI", + EditPredictionProvider::Copilot => "GitHub Copilot", + EditPredictionProvider::Supermaven => "Supermaven", + EditPredictionProvider::Codestral => "Codestral", EditPredictionProvider::Experimental( EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME, - ) => { - let has_api_token = edit_prediction::EditPredictionStore::try_global(cx) - .map_or(false, |ep_store| ep_store.read(cx).has_sweep_api_token()); - - let should_open_modal = !has_api_token || is_current; - - let entry = if has_api_token { - ContextMenuEntry::new("Sweep") - .toggleable(IconPosition::Start, is_current) - } else { - ContextMenuEntry::new("Sweep") - .icon(IconName::XCircle) - .icon_color(Color::Error) - .documentation_aside( - DocumentationSide::Left, - DocumentationEdge::Bottom, - |_| { - Label::new("Click to configure your Sweep API token") - .into_any_element() - }, - ) - }; - - let entry = entry.handler(move |window, cx| { - if should_open_modal { - if let Some(workspace) = window.root::().flatten() { - workspace.update(cx, |workspace, cx| { - workspace.toggle_modal(window, cx, |window, cx| { - ExternalProviderApiKeyModal::new( - window, - cx, - |api_key, store, cx| { - store - .sweep_ai - .set_api_token(api_key, cx) - .detach_and_log_err(cx); - }, - ) - }); - }); - }; - } else { - set_completion_provider(fs.clone(), cx, provider); - } - }); - - menu.item(entry) - } + ) => "Sweep", EditPredictionProvider::Experimental( EXPERIMENTAL_MERCURY_EDIT_PREDICTION_PROVIDER_NAME, - ) => { - let has_api_token = edit_prediction::EditPredictionStore::try_global(cx) - .map_or(false, |ep_store| ep_store.read(cx).has_mercury_api_token()); - - let should_open_modal = !has_api_token || is_current; - - let entry = if has_api_token { - ContextMenuEntry::new("Mercury") - .toggleable(IconPosition::Start, is_current) - } else { - ContextMenuEntry::new("Mercury") - .icon(IconName::XCircle) - .icon_color(Color::Error) - .documentation_aside( - DocumentationSide::Left, - DocumentationEdge::Bottom, - |_| { - Label::new("Click to configure your Mercury API token") - .into_any_element() - }, - ) - }; - - let entry = entry.handler(move |window, cx| { - if should_open_modal { - if let Some(workspace) = window.root::().flatten() { - workspace.update(cx, |workspace, cx| { - workspace.toggle_modal(window, cx, |window, cx| { - ExternalProviderApiKeyModal::new( - window, - cx, - |api_key, store, cx| { - store - .mercury - .set_api_token(api_key, cx) - .detach_and_log_err(cx); - }, - ) - }); - }); - }; - } else { - set_completion_provider(fs.clone(), cx, provider); - } - }); - - menu.item(entry) - } + ) => "Mercury", EditPredictionProvider::Experimental( EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME, - ) => menu.item( - ContextMenuEntry::new("Zeta2") - .toggleable(IconPosition::Start, is_current) - .handler(move |_, cx| { - set_completion_provider(fs.clone(), cx, provider); - }), - ), + ) => "Zeta2", EditPredictionProvider::None | EditPredictionProvider::Experimental(_) => { continue; } }; + + menu = menu.item( + ContextMenuEntry::new(name) + .toggleable(IconPosition::Start, is_current) + .handler(move |_, cx| { + set_completion_provider(fs.clone(), cx, provider); + }), + ) } } @@ -832,14 +720,7 @@ impl EditPredictionButton { let subtle_mode = matches!(current_mode, EditPredictionsMode::Subtle); let eager_mode = matches!(current_mode, EditPredictionsMode::Eager); - if matches!( - provider, - EditPredictionProvider::Zed - | EditPredictionProvider::Copilot - | EditPredictionProvider::Supermaven - | EditPredictionProvider::Codestral - ) { - menu = menu + menu = menu .separator() .header("Display Modes") .item( @@ -868,104 +749,111 @@ impl EditPredictionButton { } }), ); - } menu = menu.separator().header("Privacy"); - if let Some(provider) = &self.edit_prediction_provider { - let data_collection = provider.data_collection_state(cx); - - if data_collection.is_supported() { - let provider = provider.clone(); - let enabled = data_collection.is_enabled(); - let is_open_source = data_collection.is_project_open_source(); - let is_collecting = data_collection.is_enabled(); - let (icon_name, icon_color) = if is_open_source && is_collecting { - (IconName::Check, Color::Success) - } else { - (IconName::Check, Color::Accent) - }; - - menu = menu.item( - ContextMenuEntry::new("Training Data Collection") - .toggleable(IconPosition::Start, data_collection.is_enabled()) - .icon(icon_name) - .icon_color(icon_color) - .documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, move |cx| { - let (msg, label_color, icon_name, icon_color) = match (is_open_source, is_collecting) { - (true, true) => ( - "Project identified as open source, and you're sharing data.", - Color::Default, - IconName::Check, - Color::Success, - ), - (true, false) => ( - "Project identified as open source, but you're not sharing data.", - Color::Muted, - IconName::Close, - Color::Muted, - ), - (false, true) => ( - "Project not identified as open source. No data captured.", - Color::Muted, - IconName::Close, - Color::Muted, - ), - (false, false) => ( - "Project not identified as open source, and setting turned off.", - Color::Muted, - IconName::Close, - Color::Muted, - ), - }; - v_flex() - .gap_2() - .child( - Label::new(indoc!{ - "Help us improve our open dataset model by sharing data from open source repositories. \ - Zed must detect a license file in your repo for this setting to take effect. \ - Files with sensitive data and secrets are excluded by default." - }) - ) - .child( - h_flex() - .items_start() - .pt_2() - .pr_1() - .flex_1() - .gap_1p5() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .child(h_flex().flex_shrink_0().h(line_height).child(Icon::new(icon_name).size(IconSize::XSmall).color(icon_color))) - .child(div().child(msg).w_full().text_sm().text_color(label_color.color(cx))) - ) - .into_any_element() - }) - .handler(move |_, cx| { - provider.toggle_data_collection(cx); - - if !enabled { - telemetry::event!( - "Data Collection Enabled", - source = "Edit Prediction Status Menu" - ); - } else { - telemetry::event!( - "Data Collection Disabled", - source = "Edit Prediction Status Menu" - ); - } - }) - ); + if matches!( + provider, + EditPredictionProvider::Zed + | EditPredictionProvider::Experimental( + EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME, + ) + ) { + if let Some(provider) = &self.edit_prediction_provider { + let data_collection = provider.data_collection_state(cx); + + if data_collection.is_supported() { + let provider = provider.clone(); + let enabled = data_collection.is_enabled(); + let is_open_source = data_collection.is_project_open_source(); + let is_collecting = data_collection.is_enabled(); + let (icon_name, icon_color) = if is_open_source && is_collecting { + (IconName::Check, Color::Success) + } else { + (IconName::Check, Color::Accent) + }; - if is_collecting && !is_open_source { menu = menu.item( - ContextMenuEntry::new("No data captured.") - .disabled(true) - .icon(IconName::Close) - .icon_color(Color::Error) - .icon_size(IconSize::Small), + ContextMenuEntry::new("Training Data Collection") + .toggleable(IconPosition::Start, data_collection.is_enabled()) + .icon(icon_name) + .icon_color(icon_color) + .documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, move |cx| { + let (msg, label_color, icon_name, icon_color) = match (is_open_source, is_collecting) { + (true, true) => ( + "Project identified as open source, and you're sharing data.", + Color::Default, + IconName::Check, + Color::Success, + ), + (true, false) => ( + "Project identified as open source, but you're not sharing data.", + Color::Muted, + IconName::Close, + Color::Muted, + ), + (false, true) => ( + "Project not identified as open source. No data captured.", + Color::Muted, + IconName::Close, + Color::Muted, + ), + (false, false) => ( + "Project not identified as open source, and setting turned off.", + Color::Muted, + IconName::Close, + Color::Muted, + ), + }; + v_flex() + .gap_2() + .child( + Label::new(indoc!{ + "Help us improve our open dataset model by sharing data from open source repositories. \ + Zed must detect a license file in your repo for this setting to take effect. \ + Files with sensitive data and secrets are excluded by default." + }) + ) + .child( + h_flex() + .items_start() + .pt_2() + .pr_1() + .flex_1() + .gap_1p5() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child(h_flex().flex_shrink_0().h(line_height).child(Icon::new(icon_name).size(IconSize::XSmall).color(icon_color))) + .child(div().child(msg).w_full().text_sm().text_color(label_color.color(cx))) + ) + .into_any_element() + }) + .handler(move |_, cx| { + provider.toggle_data_collection(cx); + + if !enabled { + telemetry::event!( + "Data Collection Enabled", + source = "Edit Prediction Status Menu" + ); + } else { + telemetry::event!( + "Data Collection Disabled", + source = "Edit Prediction Status Menu" + ); + } + }) ); + + if is_collecting && !is_open_source { + menu = menu.item( + ContextMenuEntry::new("No data captured.") + .disabled(true) + .icon(IconName::Close) + .icon_color(Color::Error) + .icon_size(IconSize::Small), + ); + } } } } @@ -1026,7 +914,13 @@ impl EditPredictionButton { .context(editor_focus_handle) .when( cx.has_flag::(), - |this| this.action("Rate Predictions", RatePredictions.boxed_clone()), + |this| { + this.action( + "Capture Edit Prediction Example", + CaptureExample.boxed_clone(), + ) + .action("Rate Predictions", RatePredictions.boxed_clone()) + }, ); } @@ -1087,10 +981,7 @@ impl EditPredictionButton { let menu = self.add_provider_switching_section(menu, EditPredictionProvider::Codestral, cx); - menu.separator() - .entry("Configure Codestral API Key", None, move |window, cx| { - window.dispatch_action(zed_actions::agent::OpenSettings.boxed_clone(), cx); - }) + menu }) } @@ -1210,6 +1101,22 @@ impl EditPredictionButton { } menu = self.add_provider_switching_section(menu, provider, cx); + menu = menu.separator().item( + ContextMenuEntry::new("Configure Providers") + .icon(IconName::Settings) + .icon_position(IconPosition::Start) + .icon_color(Color::Muted) + .handler(move |window, cx| { + window.dispatch_action( + OpenSettingsAt { + path: "edit_predictions.providers".to_string(), + } + .boxed_clone(), + cx, + ); + }), + ); + menu }) } diff --git a/crates/edit_prediction_ui/src/edit_prediction_context_view.rs b/crates/edit_prediction_ui/src/edit_prediction_context_view.rs index 0e343fe3fcb8ed7bb6bf3e8481927344d63133ee..92d66d2bec3a7a3b35678f1d4da92fae6b071633 100644 --- a/crates/edit_prediction_ui/src/edit_prediction_context_view.rs +++ b/crates/edit_prediction_ui/src/edit_prediction_context_view.rs @@ -17,7 +17,7 @@ use gpui::{ }; use multi_buffer::MultiBuffer; use project::Project; -use text::OffsetRangeExt; +use text::Point; use ui::{ ButtonCommon, Clickable, Disableable, FluentBuilder as _, IconButton, IconName, StyledTypography as _, h_flex, v_flex, @@ -66,7 +66,7 @@ impl EditPredictionContextView { ) -> Self { let store = EditPredictionStore::global(client, user_store, cx); - let mut debug_rx = store.update(cx, |store, _| store.debug_info()); + let mut debug_rx = store.update(cx, |store, cx| store.debug_info(&project, cx)); let _update_task = cx.spawn_in(window, async move |this, cx| { while let Some(event) = debug_rx.next().await { this.update_in(cx, |this, window, cx| { @@ -103,7 +103,8 @@ impl EditPredictionContextView { self.handle_context_retrieval_finished(info, window, cx); } } - DebugEvent::EditPredictionRequested(_) => {} + DebugEvent::EditPredictionStarted(_) => {} + DebugEvent::EditPredictionFinished(_) => {} } } @@ -152,12 +153,11 @@ impl EditPredictionContextView { run.finished_at = Some(info.timestamp); run.metadata = info.metadata; - let project = self.project.clone(); let related_files = self .store .read(cx) - .context_for_project(&self.project, cx) - .to_vec(); + .context_for_project_with_buffers(&self.project, cx) + .map_or(Vec::new(), |files| files.collect()); let editor = run.editor.clone(); let multibuffer = run.editor.read(cx).buffer().clone(); @@ -168,33 +168,14 @@ impl EditPredictionContextView { cx.spawn_in(window, async move |this, cx| { let mut paths = Vec::new(); - for related_file in related_files { - let (buffer, point_ranges): (_, Vec<_>) = - if let Some(buffer) = related_file.buffer.upgrade() { - let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot())?; - - ( - buffer, - related_file - .excerpts - .iter() - .map(|excerpt| excerpt.anchor_range.to_point(&snapshot)) - .collect(), - ) - } else { - ( - project - .update(cx, |project, cx| { - project.open_buffer(related_file.path.clone(), cx) - })? - .await?, - related_file - .excerpts - .iter() - .map(|excerpt| excerpt.point_range.clone()) - .collect(), - ) - }; + for (related_file, buffer) in related_files { + let point_ranges = related_file + .excerpts + .iter() + .map(|excerpt| { + Point::new(excerpt.row_range.start, 0)..Point::new(excerpt.row_range.end, 0) + }) + .collect::>(); cx.update(|_, cx| { let path = PathKey::for_buffer(&buffer, cx); paths.push((path, buffer, point_ranges)); diff --git a/crates/edit_prediction_ui/src/edit_prediction_ui.rs b/crates/edit_prediction_ui/src/edit_prediction_ui.rs index c177b5233c33feb4f5ff82f60bf3fb6981cf3ee8..a762fd22aa7c32779a096fa97b2ea20ef3c9b744 100644 --- a/crates/edit_prediction_ui/src/edit_prediction_ui.rs +++ b/crates/edit_prediction_ui/src/edit_prediction_ui.rs @@ -1,23 +1,30 @@ mod edit_prediction_button; mod edit_prediction_context_view; -mod external_provider_api_token_modal; mod rate_prediction_modal; use std::any::{Any as _, TypeId}; +use std::path::Path; +use std::sync::Arc; use command_palette_hooks::CommandPaletteFilter; -use edit_prediction::{ResetOnboarding, Zeta2FeatureFlag}; +use edit_prediction::{ + EditPredictionStore, ResetOnboarding, Zeta2FeatureFlag, example_spec::ExampleSpec, +}; use edit_prediction_context_view::EditPredictionContextView; +use editor::Editor; use feature_flags::FeatureFlagAppExt as _; -use gpui::actions; +use git::repository::DiffType; +use gpui::{Window, actions}; +use language::ToPoint as _; +use log; use project::DisableAiSettings; use rate_prediction_modal::RatePredictionsModal; use settings::{Settings as _, SettingsStore}; +use text::ToOffset as _; use ui::{App, prelude::*}; use workspace::{SplitDirection, Workspace}; pub use edit_prediction_button::{EditPredictionButton, ToggleMenu}; -pub use external_provider_api_token_modal::ExternalProviderApiKeyModal; use crate::rate_prediction_modal::PredictEditsRatePredictionsFeatureFlag; @@ -34,6 +41,8 @@ actions!( [ /// Opens the rate completions modal. RatePredictions, + /// Captures an ExampleSpec from the current editing session and opens it as Markdown. + CaptureExample, ] ); @@ -47,6 +56,7 @@ pub fn init(cx: &mut App) { } }); + workspace.register_action(capture_edit_prediction_example); workspace.register_action_renderer(|div, _, _, cx| { let has_flag = cx.has_flag::(); div.when(has_flag, |div| { @@ -80,6 +90,7 @@ fn feature_gate_predict_edits_actions(cx: &mut App) { let reset_onboarding_action_types = [TypeId::of::()]; let all_action_types = [ TypeId::of::(), + TypeId::of::(), TypeId::of::(), zed_actions::OpenZedPredictOnboarding.type_id(), TypeId::of::(), @@ -126,3 +137,194 @@ fn feature_gate_predict_edits_actions(cx: &mut App) { }) .detach(); } + +fn capture_edit_prediction_example( + workspace: &mut Workspace, + _: &CaptureExample, + window: &mut Window, + cx: &mut Context, +) { + let Some(ep_store) = EditPredictionStore::try_global(cx) else { + return; + }; + + let project = workspace.project().clone(); + + let (worktree_root, repository) = { + let project_ref = project.read(cx); + let worktree_root = project_ref + .visible_worktrees(cx) + .next() + .map(|worktree| worktree.read(cx).abs_path()); + let repository = project_ref.active_repository(cx); + (worktree_root, repository) + }; + + let (Some(worktree_root), Some(repository)) = (worktree_root, repository) else { + log::error!("CaptureExampleSpec: missing worktree or active repository"); + return; + }; + + let repository_snapshot = repository.read(cx).snapshot(); + if worktree_root.as_ref() != repository_snapshot.work_directory_abs_path.as_ref() { + log::error!( + "repository is not at worktree root (repo={:?}, worktree={:?})", + repository_snapshot.work_directory_abs_path, + worktree_root + ); + return; + } + + let Some(repository_url) = repository_snapshot + .remote_origin_url + .clone() + .or_else(|| repository_snapshot.remote_upstream_url.clone()) + else { + log::error!("active repository has no origin/upstream remote url"); + return; + }; + + let Some(revision) = repository_snapshot + .head_commit + .as_ref() + .map(|commit| commit.sha.to_string()) + else { + log::error!("active repository has no head commit"); + return; + }; + + let mut events = ep_store.update(cx, |store, cx| { + store.edit_history_for_project_with_pause_split_last_event(&project, cx) + }); + + let Some(editor) = workspace.active_item_as::(cx) else { + log::error!("no active editor"); + return; + }; + + let Some(project_path) = editor.read(cx).project_path(cx) else { + log::error!("active editor has no project path"); + return; + }; + + let Some((buffer, cursor_anchor)) = editor + .read(cx) + .buffer() + .read(cx) + .text_anchor_for_position(editor.read(cx).selections.newest_anchor().head(), cx) + else { + log::error!("failed to resolve cursor buffer/anchor"); + return; + }; + + let snapshot = buffer.read(cx).snapshot(); + let cursor_point = cursor_anchor.to_point(&snapshot); + let (_editable_range, context_range) = + edit_prediction::cursor_excerpt::editable_and_context_ranges_for_cursor_position( + cursor_point, + &snapshot, + 100, + 50, + ); + + let cursor_path: Arc = repository + .read(cx) + .project_path_to_repo_path(&project_path, cx) + .map(|repo_path| Path::new(repo_path.as_unix_str()).into()) + .unwrap_or_else(|| Path::new(project_path.path.as_unix_str()).into()); + + let cursor_position = { + let context_start_offset = context_range.start.to_offset(&snapshot); + let cursor_offset = cursor_anchor.to_offset(&snapshot); + let cursor_offset_in_excerpt = cursor_offset.saturating_sub(context_start_offset); + let mut excerpt = snapshot.text_for_range(context_range).collect::(); + if cursor_offset_in_excerpt <= excerpt.len() { + excerpt.insert_str(cursor_offset_in_excerpt, zeta_prompt::CURSOR_MARKER); + } + excerpt + }; + + let markdown_language = workspace + .app_state() + .languages + .language_for_name("Markdown"); + + cx.spawn_in(window, async move |workspace_entity, cx| { + let markdown_language = markdown_language.await?; + + let uncommitted_diff_rx = repository.update(cx, |repository, cx| { + repository.diff(DiffType::HeadToWorktree, cx) + })?; + + let uncommitted_diff = match uncommitted_diff_rx.await { + Ok(Ok(diff)) => diff, + Ok(Err(error)) => { + log::error!("failed to compute uncommitted diff: {error:#}"); + return Ok(()); + } + Err(error) => { + log::error!("uncommitted diff channel dropped: {error:#}"); + return Ok(()); + } + }; + + let mut edit_history = String::new(); + let mut expected_patch = String::new(); + if let Some(last_event) = events.pop() { + for event in &events { + zeta_prompt::write_event(&mut edit_history, event); + if !edit_history.ends_with('\n') { + edit_history.push('\n'); + } + edit_history.push('\n'); + } + + zeta_prompt::write_event(&mut expected_patch, &last_event); + } + + let format = + time::format_description::parse("[year]-[month]-[day] [hour]:[minute]:[second]"); + let name = match format { + Ok(format) => { + let now = time::OffsetDateTime::now_local() + .unwrap_or_else(|_| time::OffsetDateTime::now_utc()); + now.format(&format) + .unwrap_or_else(|_| "unknown-time".to_string()) + } + Err(_) => "unknown-time".to_string(), + }; + + let markdown = ExampleSpec { + name, + repository_url, + revision, + uncommitted_diff, + cursor_path, + cursor_position, + edit_history, + expected_patch, + } + .to_markdown(); + + let buffer = project + .update(cx, |project, cx| project.create_buffer(false, cx))? + .await?; + buffer.update(cx, |buffer, cx| { + buffer.set_text(markdown, cx); + buffer.set_language(Some(markdown_language), cx); + })?; + + workspace_entity.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane( + Box::new( + cx.new(|cx| Editor::for_buffer(buffer, Some(project.clone()), window, cx)), + ), + None, + true, + window, + cx, + ); + }) + }) + .detach_and_log_err(cx); +} diff --git a/crates/edit_prediction_ui/src/external_provider_api_token_modal.rs b/crates/edit_prediction_ui/src/external_provider_api_token_modal.rs deleted file mode 100644 index bc312836e9fdd30237156ac532a055d1e23a2589..0000000000000000000000000000000000000000 --- a/crates/edit_prediction_ui/src/external_provider_api_token_modal.rs +++ /dev/null @@ -1,86 +0,0 @@ -use edit_prediction::EditPredictionStore; -use gpui::{ - DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, ParentElement, Render, -}; -use ui::{Button, ButtonStyle, Clickable, Headline, HeadlineSize, prelude::*}; -use ui_input::InputField; -use workspace::ModalView; - -pub struct ExternalProviderApiKeyModal { - api_key_input: Entity, - focus_handle: FocusHandle, - on_confirm: Box, &mut EditPredictionStore, &mut App)>, -} - -impl ExternalProviderApiKeyModal { - pub fn new( - window: &mut Window, - cx: &mut Context, - on_confirm: impl Fn(Option, &mut EditPredictionStore, &mut App) + 'static, - ) -> Self { - let api_key_input = cx.new(|cx| InputField::new(window, cx, "Enter your API key")); - - Self { - api_key_input, - focus_handle: cx.focus_handle(), - on_confirm: Box::new(on_confirm), - } - } - - fn cancel(&mut self, _: &menu::Cancel, _window: &mut Window, cx: &mut Context) { - cx.emit(DismissEvent); - } - - fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context) { - let api_key = self.api_key_input.read(cx).text(cx); - let api_key = (!api_key.trim().is_empty()).then_some(api_key); - - if let Some(ep_store) = EditPredictionStore::try_global(cx) { - ep_store.update(cx, |ep_store, cx| (self.on_confirm)(api_key, ep_store, cx)) - } - - cx.emit(DismissEvent); - } -} - -impl EventEmitter for ExternalProviderApiKeyModal {} - -impl ModalView for ExternalProviderApiKeyModal {} - -impl Focusable for ExternalProviderApiKeyModal { - fn focus_handle(&self, _cx: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl Render for ExternalProviderApiKeyModal { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - v_flex() - .key_context("ExternalApiKeyModal") - .on_action(cx.listener(Self::cancel)) - .on_action(cx.listener(Self::confirm)) - .elevation_2(cx) - .w(px(400.)) - .p_4() - .gap_3() - .child(Headline::new("API Token").size(HeadlineSize::Small)) - .child(self.api_key_input.clone()) - .child( - h_flex() - .justify_end() - .gap_2() - .child(Button::new("cancel", "Cancel").on_click(cx.listener( - |_, _, _window, cx| { - cx.emit(DismissEvent); - }, - ))) - .child( - Button::new("save", "Save") - .style(ButtonStyle::Filled) - .on_click(cx.listener(|this, _, window, cx| { - this.confirm(&menu::Confirm, window, cx); - })), - ), - ) - } -} diff --git a/crates/edit_prediction_ui/src/rate_prediction_modal.rs b/crates/edit_prediction_ui/src/rate_prediction_modal.rs index 8e754b33dc18c5be60bc052c33aa08cdcb980acb..1af65ad58083e3cccfa51ea7b674da01cad810a0 100644 --- a/crates/edit_prediction_ui/src/rate_prediction_modal.rs +++ b/crates/edit_prediction_ui/src/rate_prediction_modal.rs @@ -1,5 +1,4 @@ use buffer_diff::{BufferDiff, BufferDiffSnapshot}; -use cloud_zeta2_prompt::write_codeblock; use edit_prediction::{EditPrediction, EditPredictionRating, EditPredictionStore}; use editor::{Editor, ExcerptRange, MultiBuffer}; use feature_flags::FeatureFlag; @@ -306,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; } @@ -362,14 +361,14 @@ impl RatePredictionsModal { write!(&mut formatted_inputs, "## Events\n\n").unwrap(); for event in &prediction.inputs.events { - write!(&mut formatted_inputs, "```diff\n{event}```\n\n").unwrap(); + formatted_inputs.push_str("```diff\n"); + zeta_prompt::write_event(&mut formatted_inputs, event.as_ref()); + formatted_inputs.push_str("```\n\n"); } - write!(&mut formatted_inputs, "## Included files\n\n").unwrap(); - - for included_file in &prediction.inputs.included_files { - let cursor_insertions = &[(prediction.inputs.cursor_point, "<|CURSOR|>")]; + write!(&mut formatted_inputs, "## Related files\n\n").unwrap(); + for included_file in prediction.inputs.related_files.as_ref() { write!( &mut formatted_inputs, "### {}\n\n", @@ -377,20 +376,28 @@ impl RatePredictionsModal { ) .unwrap(); - write_codeblock( - &included_file.path, - &included_file.excerpts, - if included_file.path == prediction.inputs.cursor_path { - cursor_insertions.as_slice() - } else { - &[] - }, - included_file.max_row, - false, - &mut formatted_inputs, - ); + for excerpt in included_file.excerpts.iter() { + write!( + &mut formatted_inputs, + "```{}\n{}\n```\n", + included_file.path.display(), + excerpt.text + ) + .unwrap(); + } } + write!(&mut formatted_inputs, "## Cursor Excerpt\n\n").unwrap(); + + writeln!( + &mut formatted_inputs, + "```{}\n{}{}\n```\n", + prediction.inputs.cursor_path.display(), + &prediction.inputs.cursor_excerpt[..prediction.inputs.cursor_offset_in_excerpt], + &prediction.inputs.cursor_excerpt[prediction.inputs.cursor_offset_in_excerpt..], + ) + .unwrap(); + self.active_prediction = Some(ActivePrediction { prediction, feedback_editor: cx.new(|cx| { @@ -503,13 +510,13 @@ impl RatePredictionsModal { base_text_style: window.text_style(), syntax: cx.theme().syntax().clone(), code_block: StyleRefinement { - text: Some(TextStyleRefinement { + text: TextStyleRefinement { font_family: Some( theme_settings.buffer_font.family.clone(), ), font_size: Some(buffer_font_size.into()), ..Default::default() - }), + }, padding: EdgesRefinement { top: Some(DefiniteLength::Absolute( AbsoluteLength::Pixels(px(8.)), 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/actions.rs b/crates/editor/src/actions.rs index fb058eb8d7c5ad72a2b2656c3ce943871a623163..ba36f88f6380ade2a0d70f0f7ac3eb221446b781 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -370,7 +370,8 @@ actions!( AcceptEditPrediction, /// Accepts a partial edit prediction. #[action(deprecated_aliases = ["editor::AcceptPartialCopilotSuggestion"])] - AcceptPartialEditPrediction, + AcceptNextWordEditPrediction, + AcceptNextLineEditPrediction, /// Applies all diff hunks in the editor. ApplyAllDiffHunks, /// Applies the diff hunk at the current position. diff --git a/crates/editor/src/bracket_colorization.rs b/crates/editor/src/bracket_colorization.rs index e4933b3ad5d8a2cae80e882abaa2eb34dfd3a429..ee7e785ed30a14bce53bb777b67bdf69a9cecd07 100644 --- a/crates/editor/src/bracket_colorization.rs +++ b/crates/editor/src/bracket_colorization.rs @@ -45,7 +45,7 @@ impl Editor { let bracket_matches_by_accent = self.visible_excerpts(false, cx).into_iter().fold( HashMap::default(), - |mut acc, (excerpt_id, (buffer, buffer_version, buffer_range))| { + |mut acc, (excerpt_id, (buffer, _, buffer_range))| { let buffer_snapshot = buffer.read(cx).snapshot(); if language_settings::language_settings( buffer_snapshot.language().map(|language| language.name()), @@ -62,7 +62,7 @@ impl Editor { let brackets_by_accent = buffer_snapshot .fetch_bracket_ranges( buffer_range.start..buffer_range.end, - Some((&buffer_version, fetched_chunks)), + Some(fetched_chunks), ) .into_iter() .flat_map(|(chunk_range, pairs)| { @@ -348,6 +348,61 @@ where ); } + #[gpui::test] + async fn test_bracket_colorization_after_language_swap(cx: &mut gpui::TestAppContext) { + init_test(cx, |language_settings| { + language_settings.defaults.colorize_brackets = Some(true); + }); + + let language_registry = Arc::new(language::LanguageRegistry::test(cx.executor())); + language_registry.add(markdown_lang()); + language_registry.add(rust_lang()); + + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| { + buffer.set_language_registry(language_registry.clone()); + buffer.set_language(Some(markdown_lang()), cx); + }); + + cx.set_state(indoc! {r#" + fn main() { + let v: Vec = vec![]; + } + "#}); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + + assert_eq!( + r#"fn main«1()1» «1{ + let v: Vec = vec!«2[]2»; +}1» + +1 hsla(207.80, 16.20%, 69.19%, 1.00) +2 hsla(29.00, 54.00%, 65.88%, 1.00) +"#, + &bracket_colors_markup(&mut cx), + "Markdown does not colorize <> brackets" + ); + + cx.update_buffer(|buffer, cx| { + buffer.set_language(Some(rust_lang()), cx); + }); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + + assert_eq!( + r#"fn main«1()1» «1{ + let v: Vec«22» = vec!«2[]2»; +}1» + +1 hsla(207.80, 16.20%, 69.19%, 1.00) +2 hsla(29.00, 54.00%, 65.88%, 1.00) +"#, + &bracket_colors_markup(&mut cx), + "After switching to Rust, <> brackets are now colorized" + ); + } + #[gpui::test] async fn test_bracket_colorization_when_editing(cx: &mut gpui::TestAppContext) { init_test(cx, |language_settings| { diff --git a/crates/editor/src/code_context_menus.rs b/crates/editor/src/code_context_menus.rs index dcd96674207f02101b4066924b011d2b9ebd7a08..2336a38fa7767fa6184608066f69d3b0520234ff 100644 --- a/crates/editor/src/code_context_menus.rs +++ b/crates/editor/src/code_context_menus.rs @@ -51,6 +51,8 @@ pub const MENU_ASIDE_MIN_WIDTH: Pixels = px(260.); pub const MENU_ASIDE_MAX_WIDTH: Pixels = px(500.); pub const COMPLETION_MENU_MIN_WIDTH: Pixels = px(280.); pub const COMPLETION_MENU_MAX_WIDTH: Pixels = px(540.); +pub const CODE_ACTION_MENU_MIN_WIDTH: Pixels = px(220.); +pub const CODE_ACTION_MENU_MAX_WIDTH: Pixels = px(540.); // Constants for the markdown cache. The purpose of this cache is to reduce flickering due to // documentation not yet being parsed. @@ -179,7 +181,7 @@ impl CodeContextMenu { ) -> Option { match self { CodeContextMenu::Completions(menu) => menu.render_aside(max_size, window, cx), - CodeContextMenu::CodeActions(_) => None, + CodeContextMenu::CodeActions(menu) => menu.render_aside(max_size, window, cx), } } @@ -206,6 +208,13 @@ impl CodeContextMenu { CodeContextMenu::CodeActions(_) => (), } } + + pub fn primary_scroll_handle(&self) -> UniformListScrollHandle { + match self { + CodeContextMenu::Completions(menu) => menu.scroll_handle.clone(), + CodeContextMenu::CodeActions(menu) => menu.scroll_handle.clone(), + } + } } pub enum ContextMenuOrigin { @@ -303,6 +312,7 @@ impl CompletionsMenu { is_incomplete: bool, buffer: Entity, completions: Box<[Completion]>, + scroll_handle: Option, display_options: CompletionDisplayOptions, snippet_sort_order: SnippetSortOrder, language_registry: Option>, @@ -332,7 +342,7 @@ impl CompletionsMenu { selected_item: 0, filter_task: Task::ready(()), cancel_filter: Arc::new(AtomicBool::new(false)), - scroll_handle: UniformListScrollHandle::new(), + scroll_handle: scroll_handle.unwrap_or_else(UniformListScrollHandle::new), scroll_handle_aside: ScrollHandle::new(), resolve_completions: true, last_rendered_range: RefCell::new(None).into(), @@ -354,6 +364,7 @@ impl CompletionsMenu { choices: &Vec, selection: Range, buffer: Entity, + scroll_handle: Option, snippet_sort_order: SnippetSortOrder, ) -> Self { let completions = choices @@ -404,7 +415,7 @@ impl CompletionsMenu { selected_item: 0, filter_task: Task::ready(()), cancel_filter: Arc::new(AtomicBool::new(false)), - scroll_handle: UniformListScrollHandle::new(), + scroll_handle: scroll_handle.unwrap_or_else(UniformListScrollHandle::new), scroll_handle_aside: ScrollHandle::new(), resolve_completions: false, show_completion_documentation: false, @@ -1410,26 +1421,6 @@ pub enum CodeActionsItem { } impl CodeActionsItem { - fn as_task(&self) -> Option<&ResolvedTask> { - let Self::Task(_, task) = self else { - return None; - }; - Some(task) - } - - fn as_code_action(&self) -> Option<&CodeAction> { - let Self::CodeAction { action, .. } = self else { - return None; - }; - Some(action) - } - fn as_debug_scenario(&self) -> Option<&DebugScenario> { - let Self::DebugScenario(scenario) = self else { - return None; - }; - Some(scenario) - } - pub fn label(&self) -> String { match self { Self::CodeAction { action, .. } => action.lsp_action.title().to_owned(), @@ -1437,6 +1428,14 @@ impl CodeActionsItem { Self::DebugScenario(scenario) => scenario.label.to_string(), } } + + pub fn menu_label(&self) -> String { + match self { + Self::CodeAction { action, .. } => action.lsp_action.title().replace("\n", ""), + Self::Task(_, task) => task.resolved_label.replace("\n", ""), + Self::DebugScenario(scenario) => format!("debug: {}", scenario.label), + } + } } pub struct CodeActionsMenu { @@ -1546,60 +1545,33 @@ impl CodeActionsMenu { let item_ix = range.start + ix; let selected = item_ix == selected_item; let colors = cx.theme().colors(); - div().min_w(px(220.)).max_w(px(540.)).child( - ListItem::new(item_ix) - .inset(true) - .toggle_state(selected) - .when_some(action.as_code_action(), |this, action| { - this.child( - h_flex() - .overflow_hidden() - .when(is_quick_action_bar, |this| this.text_ui(cx)) - .child( - // TASK: It would be good to make lsp_action.title a SharedString to avoid allocating here. - action.lsp_action.title().replace("\n", ""), - ) - .when(selected, |this| { - this.text_color(colors.text_accent) - }), - ) - }) - .when_some(action.as_task(), |this, task| { - this.child( - h_flex() - .overflow_hidden() - .when(is_quick_action_bar, |this| this.text_ui(cx)) - .child(task.resolved_label.replace("\n", "")) - .when(selected, |this| { - this.text_color(colors.text_accent) - }), - ) - }) - .when_some(action.as_debug_scenario(), |this, scenario| { - this.child( - h_flex() - .overflow_hidden() - .when(is_quick_action_bar, |this| this.text_ui(cx)) - .child("debug: ") - .child(scenario.label.clone()) - .when(selected, |this| { - this.text_color(colors.text_accent) - }), - ) - }) - .on_click(cx.listener(move |editor, _, window, cx| { - cx.stop_propagation(); - if let Some(task) = editor.confirm_code_action( - &ConfirmCodeAction { - item_ix: Some(item_ix), - }, - window, - cx, - ) { - task.detach_and_log_err(cx) - } - })), - ) + + ListItem::new(item_ix) + .inset(true) + .toggle_state(selected) + .overflow_x() + .child( + div() + .min_w(CODE_ACTION_MENU_MIN_WIDTH) + .max_w(CODE_ACTION_MENU_MAX_WIDTH) + .overflow_hidden() + .text_ellipsis() + .when(is_quick_action_bar, |this| this.text_ui(cx)) + .when(selected, |this| this.text_color(colors.text_accent)) + .child(action.menu_label()), + ) + .on_click(cx.listener(move |editor, _, window, cx| { + cx.stop_propagation(); + if let Some(task) = editor.confirm_code_action( + &ConfirmCodeAction { + item_ix: Some(item_ix), + }, + window, + cx, + ) { + task.detach_and_log_err(cx) + } + })) }) .collect() }), @@ -1626,4 +1598,42 @@ impl CodeActionsMenu { Popover::new().child(list).into_any_element() } + + fn render_aside( + &mut self, + max_size: Size, + window: &mut Window, + _cx: &mut Context, + ) -> Option { + let Some(action) = self.actions.get(self.selected_item) else { + return None; + }; + + let label = action.menu_label(); + let text_system = window.text_system(); + let mut line_wrapper = text_system.line_wrapper( + window.text_style().font(), + window.text_style().font_size.to_pixels(window.rem_size()), + ); + let is_truncated = + line_wrapper.should_truncate_line(&label, CODE_ACTION_MENU_MAX_WIDTH, "…"); + + if is_truncated.is_none() { + return None; + } + + Some( + Popover::new() + .child( + div() + .child(label) + .id("code_actions_menu_extended") + .px(MENU_ASIDE_X_PADDING / 2.) + .max_w(max_size.width) + .max_h(max_size.height) + .occlude(), + ) + .into_any_element(), + ) + } } diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 081e8ec2e5c3fed341d9949689f6d8bbbb7ccf1c..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; @@ -56,6 +105,7 @@ use sum_tree::{Bias, TreeMap}; use text::{BufferId, LineIndent}; use ui::{SharedString, px}; use unicode_segmentation::UnicodeSegmentation; +use ztracing::instrument; use std::{ any::TypeId, @@ -168,6 +218,7 @@ impl DisplayMap { } } + #[instrument(skip_all)] pub fn snapshot(&mut self, cx: &mut Context) -> DisplaySnapshot { let tab_size = Self::tab_size(&self.buffer, cx); @@ -195,6 +246,7 @@ impl DisplayMap { } } + #[instrument(skip_all)] pub fn set_state(&mut self, other: &DisplaySnapshot, cx: &mut Context) { self.fold( other @@ -211,6 +263,7 @@ impl DisplayMap { } /// Creates folds for the given creases. + #[instrument(skip_all)] pub fn fold(&mut self, creases: Vec>, cx: &mut Context) { let buffer_snapshot = self.buffer.read(cx).snapshot(cx); let edits = self.buffer_subscription.consume().into_inner(); @@ -279,6 +332,7 @@ impl DisplayMap { } /// Removes any folds with the given ranges. + #[instrument(skip_all)] pub fn remove_folds_with_type( &mut self, ranges: impl IntoIterator>, @@ -304,6 +358,7 @@ impl DisplayMap { } /// Removes any folds whose ranges intersect any of the given ranges. + #[instrument(skip_all)] pub fn unfold_intersecting( &mut self, ranges: impl IntoIterator>, @@ -335,6 +390,7 @@ impl DisplayMap { block_map.remove_intersecting_replace_blocks(offset_ranges, inclusive); } + #[instrument(skip_all)] pub fn disable_header_for_buffer(&mut self, buffer_id: BufferId, cx: &mut Context) { let snapshot = self.buffer.read(cx).snapshot(cx); let edits = self.buffer_subscription.consume().into_inner(); @@ -349,6 +405,7 @@ impl DisplayMap { block_map.disable_header_for_buffer(buffer_id) } + #[instrument(skip_all)] pub fn fold_buffers( &mut self, buffer_ids: impl IntoIterator, @@ -367,6 +424,7 @@ impl DisplayMap { block_map.fold_buffers(buffer_ids, self.buffer.read(cx), cx) } + #[instrument(skip_all)] pub fn unfold_buffers( &mut self, buffer_ids: impl IntoIterator, @@ -385,14 +443,17 @@ impl DisplayMap { block_map.unfold_buffers(buffer_ids, self.buffer.read(cx), cx) } + #[instrument(skip_all)] pub(crate) fn is_buffer_folded(&self, buffer_id: language::BufferId) -> bool { self.block_map.folded_buffers.contains(&buffer_id) } + #[instrument(skip_all)] pub(crate) fn folded_buffers(&self) -> &HashSet { &self.block_map.folded_buffers } + #[instrument(skip_all)] pub fn insert_creases( &mut self, creases: impl IntoIterator>, @@ -402,6 +463,7 @@ impl DisplayMap { self.crease_map.insert(creases, &snapshot) } + #[instrument(skip_all)] pub fn remove_creases( &mut self, crease_ids: impl IntoIterator, @@ -411,6 +473,7 @@ impl DisplayMap { self.crease_map.remove(crease_ids, &snapshot) } + #[instrument(skip_all)] pub fn insert_blocks( &mut self, blocks: impl IntoIterator>, @@ -429,6 +492,7 @@ impl DisplayMap { block_map.insert(blocks) } + #[instrument(skip_all)] pub fn resize_blocks(&mut self, heights: HashMap, cx: &mut Context) { let snapshot = self.buffer.read(cx).snapshot(cx); let edits = self.buffer_subscription.consume().into_inner(); @@ -443,10 +507,12 @@ impl DisplayMap { block_map.resize(heights); } + #[instrument(skip_all)] pub fn replace_blocks(&mut self, renderers: HashMap) { self.block_map.replace_blocks(renderers); } + #[instrument(skip_all)] pub fn remove_blocks(&mut self, ids: HashSet, cx: &mut Context) { let snapshot = self.buffer.read(cx).snapshot(cx); let edits = self.buffer_subscription.consume().into_inner(); @@ -461,6 +527,7 @@ impl DisplayMap { block_map.remove(ids); } + #[instrument(skip_all)] pub fn row_for_block( &mut self, block_id: CustomBlockId, @@ -480,6 +547,7 @@ impl DisplayMap { Some(DisplayRow(block_row.0)) } + #[instrument(skip_all)] pub fn highlight_text( &mut self, key: HighlightKey, @@ -507,6 +575,7 @@ impl DisplayMap { self.text_highlights.insert(key, to_insert); } + #[instrument(skip_all)] pub(crate) fn highlight_inlays( &mut self, type_id: TypeId, @@ -526,6 +595,7 @@ impl DisplayMap { } } + #[instrument(skip_all)] pub fn text_highlights(&self, type_id: TypeId) -> Option<(HighlightStyle, &[Range])> { let highlights = self.text_highlights.get(&HighlightKey::Type(type_id))?; Some((highlights.0, &highlights.1)) @@ -538,6 +608,7 @@ impl DisplayMap { self.text_highlights.values() } + #[instrument(skip_all)] pub fn clear_highlights(&mut self, type_id: TypeId) -> bool { let mut cleared = self .text_highlights @@ -566,6 +637,7 @@ impl DisplayMap { .update(cx, |map, cx| map.set_wrap_width(width, cx)) } + #[instrument(skip_all)] pub fn update_fold_widths( &mut self, widths: impl IntoIterator, @@ -597,6 +669,7 @@ impl DisplayMap { self.inlay_map.current_inlays() } + #[instrument(skip_all)] pub(crate) fn splice_inlays( &mut self, to_remove: &[InlayId], @@ -626,6 +699,7 @@ impl DisplayMap { self.block_map.read(snapshot, edits); } + #[instrument(skip_all)] fn tab_size(buffer: &Entity, cx: &App) -> NonZeroU32 { let buffer = buffer.read(cx).as_singleton().map(|buffer| buffer.read(cx)); let language = buffer @@ -675,6 +749,7 @@ pub struct HighlightedChunk<'a> { } impl<'a> HighlightedChunk<'a> { + #[instrument(skip_all)] fn highlight_invisibles( self, editor_style: &'a EditorStyle, @@ -832,6 +907,7 @@ impl DisplaySnapshot { self.buffer_snapshot().widest_line_number() } + #[instrument(skip_all)] pub fn prev_line_boundary(&self, mut point: MultiBufferPoint) -> (Point, DisplayPoint) { loop { let mut inlay_point = self.inlay_snapshot().to_inlay_point(point); @@ -850,6 +926,7 @@ impl DisplaySnapshot { } } + #[instrument(skip_all)] pub fn next_line_boundary( &self, mut point: MultiBufferPoint, @@ -888,6 +965,7 @@ impl DisplaySnapshot { new_start..new_end } + #[instrument(skip_all)] pub fn point_to_display_point(&self, point: MultiBufferPoint, bias: Bias) -> DisplayPoint { let inlay_point = self.inlay_snapshot().to_inlay_point(point); let fold_point = self.fold_snapshot().to_fold_point(inlay_point, bias); @@ -917,6 +995,7 @@ impl DisplaySnapshot { .anchor_at(point.to_offset(self, bias), bias) } + #[instrument(skip_all)] fn display_point_to_inlay_point(&self, point: DisplayPoint, bias: Bias) -> InlayPoint { let block_point = point.0; let wrap_point = self.block_snapshot.to_wrap_point(block_point, bias); @@ -928,6 +1007,7 @@ impl DisplaySnapshot { fold_point.to_inlay_point(self.fold_snapshot()) } + #[instrument(skip_all)] pub fn display_point_to_fold_point(&self, point: DisplayPoint, bias: Bias) -> FoldPoint { let block_point = point.0; let wrap_point = self.block_snapshot.to_wrap_point(block_point, bias); @@ -937,6 +1017,7 @@ impl DisplaySnapshot { .0 } + #[instrument(skip_all)] pub fn fold_point_to_display_point(&self, fold_point: FoldPoint) -> DisplayPoint { let tab_point = self.tab_snapshot().fold_point_to_tab_point(fold_point); let wrap_point = self.wrap_snapshot().tab_point_to_wrap_point(tab_point); @@ -949,6 +1030,7 @@ impl DisplaySnapshot { } /// Returns text chunks starting at the given display row until the end of the file + #[instrument(skip_all)] pub fn text_chunks(&self, display_row: DisplayRow) -> impl Iterator { self.block_snapshot .chunks( @@ -961,6 +1043,7 @@ impl DisplaySnapshot { } /// Returns text chunks starting at the end of the given display row in reverse until the start of the file + #[instrument(skip_all)] pub fn reverse_text_chunks(&self, display_row: DisplayRow) -> impl Iterator { (0..=display_row.0).rev().flat_map(move |row| { self.block_snapshot @@ -977,6 +1060,7 @@ impl DisplaySnapshot { }) } + #[instrument(skip_all)] pub fn chunks( &self, display_rows: Range, @@ -995,6 +1079,7 @@ impl DisplaySnapshot { ) } + #[instrument(skip_all)] pub fn highlighted_chunks<'a>( &'a self, display_rows: Range, @@ -1071,6 +1156,7 @@ impl DisplaySnapshot { }) } + #[instrument(skip_all)] pub fn layout_row( &self, display_row: DisplayRow, @@ -1132,6 +1218,7 @@ impl DisplaySnapshot { layout_line.closest_index_for_x(x) as u32 } + #[instrument(skip_all)] pub fn grapheme_at(&self, mut point: DisplayPoint) -> Option { point = DisplayPoint(self.block_snapshot.clip_point(point.0, Bias::Left)); let chars = self @@ -1321,6 +1408,7 @@ impl DisplaySnapshot { .unwrap_or(false) } + #[instrument(skip_all)] pub fn crease_for_buffer_row(&self, buffer_row: MultiBufferRow) -> Option> { let start = MultiBufferPoint::new(buffer_row.0, self.buffer_snapshot().line_len(buffer_row)); @@ -1407,6 +1495,7 @@ impl DisplaySnapshot { } #[cfg(any(test, feature = "test-support"))] + #[instrument(skip_all)] pub fn text_highlight_ranges( &self, ) -> Option>)>> { @@ -1417,6 +1506,7 @@ impl DisplaySnapshot { } #[cfg(any(test, feature = "test-support"))] + #[instrument(skip_all)] pub fn all_text_highlight_ranges( &self, ) -> Vec<(gpui::Hsla, Range)> { @@ -1466,6 +1556,7 @@ impl DisplaySnapshot { /// /// This moves by buffer rows instead of display rows, a distinction that is /// important when soft wrapping is enabled. + #[instrument(skip_all)] pub fn start_of_relative_buffer_row(&self, point: DisplayPoint, times: isize) -> DisplayPoint { let start = self.display_point_to_fold_point(point, Bias::Left); let target = start.row() as isize + times; diff --git a/crates/editor/src/display_map/block_map.rs b/crates/editor/src/display_map/block_map.rs index 79d06dbf8b6e27cffffd47d6637c83eadcb00424..15bf012cd907da2455c1a2205bcccd363162fd46 100644 --- a/crates/editor/src/display_map/block_map.rs +++ b/crates/editor/src/display_map/block_map.rs @@ -529,7 +529,7 @@ impl BlockMap { BlockMapWriter(self) } - #[ztracing::instrument(skip_all, fields(edits))] + #[ztracing::instrument(skip_all, fields(edits = ?edits))] fn sync(&self, wrap_snapshot: &WrapSnapshot, mut edits: WrapPatch) { let _timer = zlog::time!("BlockMap::sync").warn_if_gt(std::time::Duration::from_millis(50)); @@ -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, @@ -570,6 +570,9 @@ impl BlockMap { let mut wrap_point_cursor = wrap_snapshot.wrap_point_cursor(); while let Some(edit) = edits.next() { + let span = ztracing::debug_span!("while edits", edit = ?edit); + let _enter = span.enter(); + let mut old_start = edit.old.start; let mut new_start = edit.new.start; @@ -628,6 +631,8 @@ impl BlockMap { let mut old_end = edit.old.end; let mut new_end = edit.new.end; loop { + let span = ztracing::debug_span!("decide where edit ends loop"); + let _enter = span.enter(); // Seek to the transform starting at or after the end of the edit cursor.seek(&old_end, Bias::Left); cursor.next(); @@ -710,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; } @@ -736,6 +742,10 @@ impl BlockMap { // and then insert the block itself. let mut just_processed_folded_buffer = false; for (block_placement, block) in blocks_in_edit.drain(..) { + let span = + ztracing::debug_span!("for block in edits", block_height = block.height()); + let _enter = span.enter(); + let mut summary = TransformSummary { input_rows: WrapRow(0), output_rows: BlockRow(block.height()), @@ -957,6 +967,7 @@ impl BlockMap { } } +#[ztracing::instrument(skip(tree, wrap_snapshot))] fn push_isomorphic(tree: &mut SumTree, rows: RowDelta, wrap_snapshot: &WrapSnapshot) { if rows == RowDelta(0) { return; diff --git a/crates/editor/src/display_map/custom_highlights.rs b/crates/editor/src/display_map/custom_highlights.rs index c9202280bf957fac4d729bab558f686c0f62e774..1ece2493e3228536999036a32959a6228f0f7cd1 100644 --- a/crates/editor/src/display_map/custom_highlights.rs +++ b/crates/editor/src/display_map/custom_highlights.rs @@ -79,12 +79,15 @@ fn create_highlight_endpoints( let start_ix = ranges .binary_search_by(|probe| probe.end.cmp(&start, buffer).then(cmp::Ordering::Less)) .unwrap_or_else(|i| i); + let end_ix = ranges[start_ix..] + .binary_search_by(|probe| { + probe.start.cmp(&end, buffer).then(cmp::Ordering::Greater) + }) + .unwrap_or_else(|i| i); - for range in &ranges[start_ix..] { - if range.start.cmp(&end, buffer).is_ge() { - break; - } + highlight_endpoints.reserve(2 * end_ix); + for range in &ranges[start_ix..][..end_ix] { let start = range.start.to_offset(buffer); let end = range.end.to_offset(buffer); if start == end { 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 51d5324c838dc7cb7f4df04b0e58577108aab6c8..879ca11be1a84ffd44daa6e53677b06887172026 100644 --- a/crates/editor/src/display_map/wrap_map.rs +++ b/crates/editor/src/display_map/wrap_map.rs @@ -840,31 +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, 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::>(()); + cursor.seek(&point, Bias::Right); if cursor.item().is_none() { cursor.prev(); } + // 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(); } - unreachable!() + WrapRow(0) } #[ztracing::instrument(skip_all)] @@ -887,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), @@ -1294,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/edit_prediction_tests.rs b/crates/editor/src/edit_prediction_tests.rs index bfce1532ce78699e1fb524fd594df1ba83c864a5..b5931cde42a4e2c0e21b2d1f68558879de9750b4 100644 --- a/crates/editor/src/edit_prediction_tests.rs +++ b/crates/editor/src/edit_prediction_tests.rs @@ -485,15 +485,6 @@ impl EditPredictionDelegate for FakeEditPredictionDelegate { ) { } - fn cycle( - &mut self, - _buffer: gpui::Entity, - _cursor_position: language::Anchor, - _direction: edit_prediction_types::Direction, - _cx: &mut gpui::Context, - ) { - } - fn accept(&mut self, _cx: &mut gpui::Context) {} fn discard(&mut self, _cx: &mut gpui::Context) {} @@ -561,15 +552,6 @@ impl EditPredictionDelegate for FakeNonZedEditPredictionDelegate { ) { } - fn cycle( - &mut self, - _buffer: gpui::Entity, - _cursor_position: language::Anchor, - _direction: edit_prediction_types::Direction, - _cx: &mut gpui::Context, - ) { - } - fn accept(&mut self, _cx: &mut gpui::Context) {} fn discard(&mut self, _cx: &mut gpui::Context) {} diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index d841bf858b8a77f502b8bfb2499118f9e714572e..8560705802264dad55b87dbf21e1f9aa7625edf8 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -73,11 +73,7 @@ pub use multi_buffer::{ pub use split::SplittableEditor; pub use text::Bias; -use ::git::{ - Restore, - blame::{BlameEntry, ParsedCommitMessage}, - status::FileStatus, -}; +use ::git::{Restore, blame::BlameEntry, commit::ParsedCommitMessage, status::FileStatus}; use aho_corasick::{AhoCorasick, AhoCorasickBuilder, BuildError}; use anyhow::{Context as _, Result, anyhow, bail}; use blink_manager::BlinkManager; @@ -92,7 +88,9 @@ use collections::{BTreeMap, HashMap, HashSet, VecDeque}; use convert_case::{Case, Casing}; use dap::TelemetrySpawnLocation; use display_map::*; -use edit_prediction_types::{EditPredictionDelegate, EditPredictionDelegateHandle}; +use edit_prediction_types::{ + EditPredictionDelegate, EditPredictionDelegateHandle, EditPredictionGranularity, +}; use editor_settings::{GoToDefinitionFallback, Minimap as MinimapSettings}; use element::{AcceptEditPredictionBinding, LineWithInvisibles, PositionMap, layout_line}; use futures::{ @@ -107,10 +105,11 @@ use gpui::{ AvailableSpace, Background, Bounds, ClickEvent, ClipboardEntry, ClipboardItem, Context, DispatchPhase, Edges, Entity, EntityInputHandler, EventEmitter, FocusHandle, FocusOutEvent, Focusable, FontId, FontWeight, Global, HighlightStyle, Hsla, KeyContext, Modifiers, - MouseButton, MouseDownEvent, MouseMoveEvent, PaintQuad, ParentElement, Pixels, Render, - ScrollHandle, SharedString, Size, Stateful, Styled, Subscription, Task, TextStyle, - TextStyleRefinement, UTF16Selection, UnderlineStyle, UniformListScrollHandle, WeakEntity, - WeakFocusHandle, Window, div, point, prelude::*, pulsating_between, px, relative, size, + MouseButton, MouseDownEvent, MouseMoveEvent, PaintQuad, ParentElement, Pixels, PressureStage, + Render, ScrollHandle, SharedString, Size, Stateful, Styled, Subscription, Task, TextRun, + TextStyle, TextStyleRefinement, UTF16Selection, UnderlineStyle, UniformListScrollHandle, + WeakEntity, WeakFocusHandle, Window, div, point, prelude::*, pulsating_between, px, relative, + size, }; use hover_links::{HoverLink, HoveredLinkState, find_file}; use hover_popover::{HoverState, hide_hover}; @@ -121,8 +120,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, @@ -351,8 +351,8 @@ pub fn init(cx: &mut App) { ) .detach(); } - }); - cx.on_action(move |_: &workspace::NewWindow, cx| { + }) + .on_action(move |_: &workspace::NewWindow, cx| { let app_state = workspace::AppState::global(cx); if let Some(app_state) = app_state.upgrade() { workspace::open_new( @@ -575,7 +575,7 @@ impl Default for EditorStyle { } } -pub fn make_inlay_hints_style(cx: &mut App) -> HighlightStyle { +pub fn make_inlay_hints_style(cx: &App) -> HighlightStyle { let show_background = language_settings::language_settings(None, None, cx) .inlay_hints .show_background; @@ -598,7 +598,7 @@ pub fn make_inlay_hints_style(cx: &mut App) -> HighlightStyle { style } -pub fn make_suggestion_styles(cx: &mut App) -> EditPredictionStyles { +pub fn make_suggestion_styles(cx: &App) -> EditPredictionStyles { EditPredictionStyles { insertion: HighlightStyle { color: Some(cx.theme().status().predictive), @@ -1107,6 +1107,9 @@ pub struct Editor { pending_rename: Option, searchable: bool, cursor_shape: CursorShape, + /// Whether the cursor is offset one character to the left when something is + /// selected (needed for vim visual mode) + cursor_offset_on_selection: bool, current_line_highlight: Option, pub collapse_matches: bool, autoindent_mode: Option, @@ -1118,6 +1121,7 @@ pub struct Editor { remote_id: Option, pub hover_state: HoverState, pending_mouse_down: Option>>>, + prev_pressure_stage: Option, gutter_hovered: bool, hovered_link_state: Option, edit_prediction_provider: Option, @@ -1249,6 +1253,7 @@ impl NextScrollCursorCenterTopBottom { pub struct EditorSnapshot { pub mode: EditorMode, show_gutter: bool, + offset_content: bool, show_line_numbers: Option, show_git_diff_gutter: Option, show_code_actions: Option, @@ -1825,7 +1830,11 @@ impl Editor { Editor::new_internal(mode, buffer, project, None, window, cx) } - pub fn sticky_headers(&self, cx: &App) -> Option>> { + pub fn sticky_headers( + &self, + style: &EditorStyle, + cx: &App, + ) -> Option>> { let multi_buffer = self.buffer().read(cx); let multi_buffer_snapshot = multi_buffer.snapshot(cx); let multi_buffer_visible_start = self @@ -1843,7 +1852,7 @@ impl Editor { .outline_items_containing( Point::new(start_row, 0)..Point::new(end_row, 0), true, - self.style().map(|style| style.syntax.as_ref()), + Some(style.syntax.as_ref()), ) .into_iter() .map(|outline_item| OutlineItem { @@ -2051,46 +2060,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, + ); } } @@ -2276,6 +2273,7 @@ impl Editor { cursor_shape: EditorSettings::get_global(cx) .cursor_shape .unwrap_or_default(), + cursor_offset_on_selection: false, current_line_highlight: None, autoindent_mode: Some(AutoindentMode::EachLine), collapse_matches: false, @@ -2291,6 +2289,7 @@ impl Editor { remote_id: None, hover_state: HoverState::default(), pending_mouse_down: None, + prev_pressure_stage: None, hovered_link_state: None, edit_prediction_provider: None, active_edit_prediction: None, @@ -2766,21 +2765,24 @@ impl Editor { pub fn accept_edit_prediction_keybind( &self, - accept_partial: bool, + granularity: EditPredictionGranularity, window: &mut Window, cx: &mut App, ) -> AcceptEditPredictionBinding { let key_context = self.key_context_internal(true, window, cx); let in_conflict = self.edit_prediction_in_conflict(); - let bindings = if accept_partial { - window.bindings_for_action_in_context(&AcceptPartialEditPrediction, key_context) - } else { - window.bindings_for_action_in_context(&AcceptEditPrediction, key_context) - }; + let bindings = + match granularity { + EditPredictionGranularity::Word => window + .bindings_for_action_in_context(&AcceptNextWordEditPrediction, key_context), + EditPredictionGranularity::Line => window + .bindings_for_action_in_context(&AcceptNextLineEditPrediction, key_context), + EditPredictionGranularity::Full => { + window.bindings_for_action_in_context(&AcceptEditPrediction, key_context) + } + }; - // TODO: if the binding contains multiple keystrokes, display all of them, not - // just the first one. AcceptEditPredictionBinding(bindings.into_iter().rev().find(|binding| { !in_conflict || binding @@ -2935,6 +2937,7 @@ impl Editor { EditorSnapshot { mode: self.mode.clone(), show_gutter: self.show_gutter, + offset_content: self.offset_content, show_line_numbers: self.show_line_numbers, show_git_diff_gutter: self.show_git_diff_gutter, show_code_actions: self.show_code_actions, @@ -3089,6 +3092,10 @@ impl Editor { self.cursor_shape } + pub fn set_cursor_offset_on_selection(&mut self, set_cursor_offset_on_selection: bool) { + self.cursor_offset_on_selection = set_cursor_offset_on_selection; + } + pub fn set_current_line_highlight( &mut self, current_line_highlight: Option, @@ -3405,7 +3412,8 @@ impl Editor { data.selections = inmemory_selections; }); - if WorkspaceSettings::get(None, cx).restore_on_startup != RestoreOnStartupBehavior::None + if WorkspaceSettings::get(None, cx).restore_on_startup + != RestoreOnStartupBehavior::EmptyTab && let Some(workspace_id) = self.workspace_serialization_id(cx) { let snapshot = self.buffer().read(cx).snapshot(cx); @@ -3445,7 +3453,8 @@ impl Editor { use text::ToPoint as _; if self.mode.is_minimap() - || WorkspaceSettings::get(None, cx).restore_on_startup == RestoreOnStartupBehavior::None + || WorkspaceSettings::get(None, cx).restore_on_startup + == RestoreOnStartupBehavior::EmptyTab { return; } @@ -3803,7 +3812,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)); @@ -3908,7 +3917,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)); @@ -4382,10 +4391,50 @@ impl Editor { && bracket_pair.start.len() == 1 { let target = bracket_pair.start.chars().next().unwrap(); + let mut byte_offset = 0u32; let current_line_count = snapshot .reversed_chars_at(selection.start) .take_while(|&c| c != '\n') - .filter(|&c| c == target) + .filter(|c| { + byte_offset += c.len_utf8() as u32; + if *c != target { + return false; + } + + let point = Point::new( + selection.start.row, + selection.start.column.saturating_sub(byte_offset), + ); + + let is_enabled = snapshot + .language_scope_at(point) + .and_then(|scope| { + scope + .brackets() + .find(|(pair, _)| { + pair.start == bracket_pair.start + }) + .map(|(_, enabled)| enabled) + }) + .unwrap_or(true); + + let is_delimiter = snapshot + .language_scope_at(Point::new( + point.row, + point.column + 1, + )) + .and_then(|scope| { + scope + .brackets() + .find(|(pair, _)| { + pair.start == bracket_pair.start + }) + .map(|(_, enabled)| !enabled) + }) + .unwrap_or(false); + + is_enabled && !is_delimiter + }) .count(); current_line_count % 2 == 1 } else { @@ -4738,205 +4787,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); @@ -4946,28 +4841,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() @@ -5004,6 +4899,9 @@ impl Editor { this.change_selections(Default::default(), window, cx, |s| s.select(new_selections)); this.refresh_edit_prediction(true, false, window, cx); + if let Some(task) = this.trigger_on_type_formatting("\n".to_owned(), window, cx) { + task.detach_and_log_err(cx); + } }); } @@ -5068,6 +4966,9 @@ impl Editor { } } editor.edit(indent_edits, cx); + if let Some(format) = editor.trigger_on_type_formatting("\n".to_owned(), window, cx) { + format.detach_and_log_err(cx); + } }); } @@ -5130,6 +5031,9 @@ impl Editor { } } editor.edit(indent_edits, cx); + if let Some(format) = editor.trigger_on_type_formatting("\n".to_owned(), window, cx) { + format.detach_and_log_err(cx); + } }); } @@ -5440,7 +5344,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) -> Option>> { - if input.len() != 1 { + if input.chars().count() != 1 { return None; } @@ -5882,6 +5786,11 @@ impl Editor { is_incomplete, buffer.clone(), completions.into(), + editor + .context_menu() + .borrow_mut() + .as_ref() + .map(|menu| menu.primary_scroll_handle()), display_options, snippet_sort_order, languages, @@ -6594,6 +6503,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, @@ -6753,7 +6708,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( @@ -6890,7 +6845,7 @@ impl Editor { }; let anchor = self.selections.newest_anchor().head(); - let position = self.to_pixel_point(anchor, &snapshot, window); + let position = self.to_pixel_point(anchor, &snapshot, window, cx); if let (Some(position), Some(last_bounds)) = (position, self.last_bounds) { self.show_blame_popover( buffer, @@ -7124,6 +7079,7 @@ impl Editor { Some((query, selection_anchor_range)) } + #[ztracing::instrument(skip_all)] fn update_selection_occurrence_highlights( &mut self, query_text: String, @@ -7268,6 +7224,7 @@ impl Editor { }); } + #[ztracing::instrument(skip_all)] fn refresh_selected_text_highlights( &mut self, on_buffer_edit: bool, @@ -7507,26 +7464,6 @@ impl Editor { .unwrap_or(false) } - fn cycle_edit_prediction( - &mut self, - direction: Direction, - window: &mut Window, - cx: &mut Context, - ) -> Option<()> { - let provider = self.edit_prediction_provider()?; - let cursor = self.selections.newest_anchor().head(); - let (buffer, cursor_buffer_position) = - self.buffer.read(cx).text_anchor_for_position(cursor, cx)?; - if self.edit_predictions_hidden_for_vim_mode || !self.should_show_edit_predictions() { - return None; - } - - provider.cycle(buffer, cursor_buffer_position, direction, cx); - self.update_visible_edit_prediction(window, cx); - - Some(()) - } - pub fn show_edit_prediction( &mut self, _: &ShowEditPrediction, @@ -7564,45 +7501,9 @@ impl Editor { .detach(); } - pub fn next_edit_prediction( - &mut self, - _: &NextEditPrediction, - window: &mut Window, - cx: &mut Context, - ) { - if self.has_active_edit_prediction() { - self.cycle_edit_prediction(Direction::Next, window, cx); - } else { - let is_copilot_disabled = self - .refresh_edit_prediction(false, true, window, cx) - .is_none(); - if is_copilot_disabled { - cx.propagate(); - } - } - } - - pub fn previous_edit_prediction( - &mut self, - _: &PreviousEditPrediction, - window: &mut Window, - cx: &mut Context, - ) { - if self.has_active_edit_prediction() { - self.cycle_edit_prediction(Direction::Prev, window, cx); - } else { - let is_copilot_disabled = self - .refresh_edit_prediction(false, true, window, cx) - .is_none(); - if is_copilot_disabled { - cx.propagate(); - } - } - } - - pub fn accept_edit_prediction( + pub fn accept_partial_edit_prediction( &mut self, - _: &AcceptEditPrediction, + granularity: EditPredictionGranularity, window: &mut Window, cx: &mut Context, ) { @@ -7614,47 +7515,59 @@ impl Editor { return; }; + if !matches!(granularity, EditPredictionGranularity::Full) && self.selections.count() != 1 { + return; + } + match &active_edit_prediction.completion { EditPrediction::MoveWithin { target, .. } => { let target = *target; - if let Some(position_map) = &self.last_position_map { - if position_map - .visible_row_range - .contains(&target.to_display_point(&position_map.snapshot).row()) - || !self.edit_prediction_requires_modifier() - { - self.unfold_ranges(&[target..target], true, false, cx); - // Note that this is also done in vim's handler of the Tab action. - self.change_selections( - SelectionEffects::scroll(Autoscroll::newest()), - window, - cx, - |selections| { - selections.select_anchor_ranges([target..target]); - }, - ); - self.clear_row_highlights::(); + if matches!(granularity, EditPredictionGranularity::Full) { + if let Some(position_map) = &self.last_position_map { + let target_row = target.to_display_point(&position_map.snapshot).row(); + let is_visible = position_map.visible_row_range.contains(&target_row); - self.edit_prediction_preview - .set_previous_scroll_position(None); - } else { - self.edit_prediction_preview - .set_previous_scroll_position(Some( - position_map.snapshot.scroll_anchor, - )); - - self.highlight_rows::( - target..target, - cx.theme().colors().editor_highlighted_line_background, - RowHighlightOptions { - autoscroll: true, - ..Default::default() - }, - cx, - ); - self.request_autoscroll(Autoscroll::fit(), cx); + if is_visible || !self.edit_prediction_requires_modifier() { + self.unfold_ranges(&[target..target], true, false, cx); + self.change_selections( + SelectionEffects::scroll(Autoscroll::newest()), + window, + cx, + |selections| { + selections.select_anchor_ranges([target..target]); + }, + ); + self.clear_row_highlights::(); + self.edit_prediction_preview + .set_previous_scroll_position(None); + } else { + // Highlight and request scroll + self.edit_prediction_preview + .set_previous_scroll_position(Some( + position_map.snapshot.scroll_anchor, + )); + self.highlight_rows::( + target..target, + cx.theme().colors().editor_highlighted_line_background, + RowHighlightOptions { + autoscroll: true, + ..Default::default() + }, + cx, + ); + self.request_autoscroll(Autoscroll::fit(), cx); + } } + } else { + self.change_selections( + SelectionEffects::scroll(Autoscroll::newest()), + window, + cx, + |selections| { + selections.select_anchor_ranges([target..target]); + }, + ); } } EditPrediction::MoveOutside { snapshot, target } => { @@ -7670,126 +7583,131 @@ impl Editor { cx, ); - if let Some(provider) = self.edit_prediction_provider() { - provider.accept(cx); - } + match granularity { + EditPredictionGranularity::Full => { + if let Some(provider) = self.edit_prediction_provider() { + provider.accept(cx); + } - // Store the transaction ID and selections before applying the edit - let transaction_id_prev = self.buffer.read(cx).last_transaction_id(cx); + let transaction_id_prev = self.buffer.read(cx).last_transaction_id(cx); + let snapshot = self.buffer.read(cx).snapshot(cx); + let last_edit_end = edits.last().unwrap().0.end.bias_right(&snapshot); - let snapshot = self.buffer.read(cx).snapshot(cx); - let last_edit_end = edits.last().unwrap().0.end.bias_right(&snapshot); + self.buffer.update(cx, |buffer, cx| { + buffer.edit(edits.iter().cloned(), None, cx) + }); - self.buffer.update(cx, |buffer, cx| { - buffer.edit(edits.iter().cloned(), None, cx) - }); + self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_anchor_ranges([last_edit_end..last_edit_end]); + }); - self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_anchor_ranges([last_edit_end..last_edit_end]); - }); + let selections = self.selections.disjoint_anchors_arc(); + if let Some(transaction_id_now) = + self.buffer.read(cx).last_transaction_id(cx) + { + if transaction_id_prev != Some(transaction_id_now) { + self.selection_history + .insert_transaction(transaction_id_now, selections); + } + } - let selections = self.selections.disjoint_anchors_arc(); - if let Some(transaction_id_now) = self.buffer.read(cx).last_transaction_id(cx) { - let has_new_transaction = transaction_id_prev != Some(transaction_id_now); - if has_new_transaction { - self.selection_history - .insert_transaction(transaction_id_now, selections); + self.update_visible_edit_prediction(window, cx); + if self.active_edit_prediction.is_none() { + self.refresh_edit_prediction(true, true, window, cx); + } + cx.notify(); } - } + _ => { + let snapshot = self.buffer.read(cx).snapshot(cx); + let cursor_offset = self + .selections + .newest::(&self.display_snapshot(cx)) + .head(); + + let insertion = edits.iter().find_map(|(range, text)| { + let range = range.to_offset(&snapshot); + if range.is_empty() && range.start == cursor_offset { + Some(text) + } else { + None + } + }); - self.update_visible_edit_prediction(window, cx); - if self.active_edit_prediction.is_none() { - self.refresh_edit_prediction(true, true, window, cx); - } + if let Some(text) = insertion { + let text_to_insert = match granularity { + EditPredictionGranularity::Word => { + let mut partial = text + .chars() + .by_ref() + .take_while(|c| c.is_alphabetic()) + .collect::(); + if partial.is_empty() { + partial = text + .chars() + .by_ref() + .take_while(|c| c.is_whitespace() || !c.is_alphabetic()) + .collect::(); + } + partial + } + EditPredictionGranularity::Line => { + if let Some(line) = text.split_inclusive('\n').next() { + line.to_string() + } else { + text.to_string() + } + } + EditPredictionGranularity::Full => unreachable!(), + }; - cx.notify(); + cx.emit(EditorEvent::InputHandled { + utf16_range_to_replace: None, + text: text_to_insert.clone().into(), + }); + + self.insert_with_autoindent_mode(&text_to_insert, None, window, cx); + self.refresh_edit_prediction(true, true, window, cx); + cx.notify(); + } else { + self.accept_partial_edit_prediction( + EditPredictionGranularity::Full, + window, + cx, + ); + } + } + } } } self.edit_prediction_requires_modifier_in_indent_conflict = false; } - pub fn accept_partial_edit_prediction( + pub fn accept_next_word_edit_prediction( &mut self, - _: &AcceptPartialEditPrediction, + _: &AcceptNextWordEditPrediction, window: &mut Window, cx: &mut Context, ) { - let Some(active_edit_prediction) = self.active_edit_prediction.as_ref() else { - return; - }; - if self.selections.count() != 1 { - return; - } - - match &active_edit_prediction.completion { - EditPrediction::MoveWithin { target, .. } => { - let target = *target; - self.change_selections( - SelectionEffects::scroll(Autoscroll::newest()), - window, - cx, - |selections| { - selections.select_anchor_ranges([target..target]); - }, - ); - } - EditPrediction::MoveOutside { snapshot, target } => { - if let Some(workspace) = self.workspace() { - Self::open_editor_at_anchor(snapshot, *target, &workspace, window, cx) - .detach_and_log_err(cx); - } - } - EditPrediction::Edit { edits, .. } => { - self.report_edit_prediction_event( - active_edit_prediction.completion_id.clone(), - true, - cx, - ); - - // Find an insertion that starts at the cursor position. - let snapshot = self.buffer.read(cx).snapshot(cx); - let cursor_offset = self - .selections - .newest::(&self.display_snapshot(cx)) - .head(); - let insertion = edits.iter().find_map(|(range, text)| { - let range = range.to_offset(&snapshot); - if range.is_empty() && range.start == cursor_offset { - Some(text) - } else { - None - } - }); - - if let Some(text) = insertion { - let mut partial_completion = text - .chars() - .by_ref() - .take_while(|c| c.is_alphabetic()) - .collect::(); - if partial_completion.is_empty() { - partial_completion = text - .chars() - .by_ref() - .take_while(|c| c.is_whitespace() || !c.is_alphabetic()) - .collect::(); - } - - cx.emit(EditorEvent::InputHandled { - utf16_range_to_replace: None, - text: partial_completion.clone().into(), - }); + self.accept_partial_edit_prediction(EditPredictionGranularity::Word, window, cx); + } - self.insert_with_autoindent_mode(&partial_completion, None, window, cx); + pub fn accept_next_line_edit_prediction( + &mut self, + _: &AcceptNextLineEditPrediction, + window: &mut Window, + cx: &mut Context, + ) { + self.accept_partial_edit_prediction(EditPredictionGranularity::Line, window, cx); + } - self.refresh_edit_prediction(true, true, window, cx); - cx.notify(); - } else { - self.accept_edit_prediction(&Default::default(), window, cx); - } - } - } + pub fn accept_edit_prediction( + &mut self, + _: &AcceptEditPrediction, + window: &mut Window, + cx: &mut Context, + ) { + self.accept_partial_edit_prediction(EditPredictionGranularity::Full, window, cx); } fn discard_edit_prediction( @@ -8009,21 +7927,23 @@ impl Editor { cx: &mut Context, ) { let mut modifiers_held = false; - if let Some(accept_keystroke) = self - .accept_edit_prediction_keybind(false, window, cx) - .keystroke() - { - modifiers_held = modifiers_held - || (accept_keystroke.modifiers() == modifiers - && accept_keystroke.modifiers().modified()); - }; - if let Some(accept_partial_keystroke) = self - .accept_edit_prediction_keybind(true, window, cx) - .keystroke() - { - modifiers_held = modifiers_held - || (accept_partial_keystroke.modifiers() == modifiers - && accept_partial_keystroke.modifiers().modified()); + + // Check bindings for all granularities. + // If the user holds the key for Word, Line, or Full, we want to show the preview. + let granularities = [ + EditPredictionGranularity::Full, + EditPredictionGranularity::Line, + EditPredictionGranularity::Word, + ]; + + for granularity in granularities { + if let Some(keystroke) = self + .accept_edit_prediction_keybind(granularity, window, cx) + .keystroke() + { + modifiers_held = modifiers_held + || (keystroke.modifiers() == modifiers && keystroke.modifiers().modified()); + } } if modifiers_held { @@ -8625,7 +8545,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(), @@ -8817,7 +8737,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)), @@ -9203,7 +9123,8 @@ impl Editor { let size = element.layout_as_root(AvailableSpace::min_size(), window, cx); - let line_origin = self.display_to_pixel_point(target_line_end, editor_snapshot, window)?; + let line_origin = + self.display_to_pixel_point(target_line_end, editor_snapshot, window, cx)?; let start_point = content_origin - point(scroll_pixel_position.x.into(), Pixels::ZERO); let mut origin = start_point @@ -9442,7 +9363,8 @@ impl Editor { window: &mut Window, cx: &mut App, ) -> Option { - let accept_binding = self.accept_edit_prediction_keybind(false, window, cx); + let accept_binding = + self.accept_edit_prediction_keybind(EditPredictionGranularity::Full, window, cx); let accept_keystroke = accept_binding.keystroke()?; let is_platform_style_mac = PlatformStyle::platform() == PlatformStyle::Mac; @@ -9945,8 +9867,7 @@ impl Editor { } pub fn render_context_menu( - &self, - style: &EditorStyle, + &mut self, max_height_in_lines: u32, window: &mut Window, cx: &mut Context, @@ -9956,7 +9877,9 @@ impl Editor { if !menu.visible() { return None; }; - Some(menu.render(style, max_height_in_lines, window, cx)) + self.style + .as_ref() + .map(|style| menu.render(style, max_height_in_lines, window, cx)) } fn render_context_menu_aside( @@ -10016,13 +9939,16 @@ impl Editor { let id = post_inc(&mut self.next_completion_id); let snippet_sort_order = EditorSettings::get_global(cx).snippet_sort_order; - *self.context_menu.borrow_mut() = Some(CodeContextMenu::Completions( + let mut context_menu = self.context_menu.borrow_mut(); + let old_menu = context_menu.take(); + *context_menu = Some(CodeContextMenu::Completions( CompletionsMenu::new_snippet_choices( id, true, choices, selection, buffer, + old_menu.map(|menu| menu.primary_scroll_handle()), snippet_sort_order, ), )); @@ -11226,7 +11152,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, _| { @@ -15429,10 +15355,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); @@ -17374,7 +17299,14 @@ impl Editor { // If there is one url or file, open it directly match first_url_or_file { Some(Either::Left(url)) => { - cx.update(|_, cx| cx.open_url(&url))?; + cx.update(|window, cx| { + if parse_zed_link(&url, cx).is_some() { + window + .dispatch_action(Box::new(zed_actions::OpenZedUrl { url }), cx); + } else { + cx.open_url(&url); + } + })?; Ok(Navigated::Yes) } Some(Either::Right(path)) => { @@ -18047,7 +17979,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, @@ -18161,7 +18093,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( @@ -20356,8 +20288,11 @@ impl Editor { self.style = Some(style); } - pub fn style(&self) -> Option<&EditorStyle> { - self.style.as_ref() + pub fn style(&mut self, cx: &App) -> &EditorStyle { + if self.style.is_none() { + self.style = Some(self.create_style(cx)); + } + self.style.as_ref().unwrap() } // Called by the element. This method is not designed to be called outside of the editor @@ -20954,9 +20889,22 @@ impl Editor { buffer_ranges.last() }?; - let selection = text::ToPoint::to_point(&range.start, buffer).row - ..text::ToPoint::to_point(&range.end, buffer).row; - Some((multi_buffer.buffer(buffer.remote_id()).unwrap(), selection)) + let start_row_in_buffer = text::ToPoint::to_point(&range.start, buffer).row; + let end_row_in_buffer = text::ToPoint::to_point(&range.end, buffer).row; + + let Some(buffer_diff) = multi_buffer.diff_for(buffer.remote_id()) else { + let selection = start_row_in_buffer..end_row_in_buffer; + + return Some((multi_buffer.buffer(buffer.remote_id()).unwrap(), selection)); + }; + + let buffer_diff_snapshot = buffer_diff.read(cx).snapshot(cx); + + Some(( + multi_buffer.buffer(buffer.remote_id()).unwrap(), + buffer_diff_snapshot.row_to_base_text_row(start_row_in_buffer, buffer) + ..buffer_diff_snapshot.row_to_base_text_row(end_row_in_buffer, buffer), + )) }); let Some((buffer, selection)) = buffer_and_selection else { @@ -22715,7 +22663,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) @@ -22922,10 +22870,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - let workspace = self.workspace(); - let project = self.project(); - let save_tasks = self.buffer().update(cx, |multi_buffer, cx| { - let mut tasks = Vec::new(); + self.buffer().update(cx, |multi_buffer, cx| { for (buffer_id, changes) in revert_changes { if let Some(buffer) = multi_buffer.buffer(buffer_id) { buffer.update(cx, |buffer, cx| { @@ -22937,66 +22882,33 @@ impl Editor { cx, ); }); - - if let Some(project) = - project.filter(|_| multi_buffer.all_diff_hunks_expanded()) - { - project.update(cx, |project, cx| { - tasks.push((buffer.clone(), project.save_buffer(buffer, cx))); - }) - } } } - tasks }); - cx.spawn_in(window, async move |_, cx| { - for (buffer, task) in save_tasks { - let result = task.await; - if result.is_err() { - let Some(path) = buffer - .read_with(cx, |buffer, cx| buffer.project_path(cx)) - .ok() - else { - continue; - }; - if let Some((workspace, path)) = workspace.as_ref().zip(path) { - let Some(task) = cx - .update_window_entity(workspace, |workspace, window, cx| { - workspace - .open_path_preview(path, None, false, false, false, window, cx) - }) - .ok() - else { - continue; - }; - task.await.log_err(); - } - } - } - }) - .detach(); self.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { selections.refresh() }); } pub fn to_pixel_point( - &self, + &mut self, source: multi_buffer::Anchor, editor_snapshot: &EditorSnapshot, window: &mut Window, + cx: &App, ) -> Option> { let source_point = source.to_display_point(editor_snapshot); - self.display_to_pixel_point(source_point, editor_snapshot, window) + self.display_to_pixel_point(source_point, editor_snapshot, window, cx) } pub fn display_to_pixel_point( - &self, + &mut self, source: DisplayPoint, editor_snapshot: &EditorSnapshot, window: &mut Window, + cx: &App, ) -> Option> { - let line_height = self.style()?.text.line_height_in_pixels(window.rem_size()); + let line_height = self.style(cx).text.line_height_in_pixels(window.rem_size()); let text_layout_details = self.text_layout_details(window); let scroll_top = text_layout_details .scroll_anchor @@ -23060,10 +22972,6 @@ impl Editor { } } - pub fn last_gutter_dimensions(&self) -> &GutterDimensions { - &self.gutter_dimensions - } - pub fn wait_for_diff_to_load(&self) -> Option>> { self.load_diff_task.clone() } @@ -23077,7 +22985,8 @@ impl Editor { ) { if self.buffer_kind(cx) == ItemBufferKind::Singleton && !self.mode.is_minimap() - && WorkspaceSettings::get(None, cx).restore_on_startup != RestoreOnStartupBehavior::None + && WorkspaceSettings::get(None, cx).restore_on_startup + != RestoreOnStartupBehavior::EmptyTab { let buffer_snapshot = OnceCell::new(); @@ -23163,6 +23072,57 @@ impl Editor { // skip any LSP updates for it. self.active_diagnostics == ActiveDiagnostic::All || !self.mode().is_full() } + + fn create_style(&self, cx: &App) -> EditorStyle { + let settings = ThemeSettings::get_global(cx); + + let mut text_style = match self.mode { + EditorMode::SingleLine | EditorMode::AutoHeight { .. } => TextStyle { + color: cx.theme().colors().editor_foreground, + font_family: settings.ui_font.family.clone(), + font_features: settings.ui_font.features.clone(), + font_fallbacks: settings.ui_font.fallbacks.clone(), + font_size: rems(0.875).into(), + font_weight: settings.ui_font.weight, + line_height: relative(settings.buffer_line_height.value()), + ..Default::default() + }, + EditorMode::Full { .. } | EditorMode::Minimap { .. } => TextStyle { + color: cx.theme().colors().editor_foreground, + font_family: settings.buffer_font.family.clone(), + font_features: settings.buffer_font.features.clone(), + font_fallbacks: settings.buffer_font.fallbacks.clone(), + font_size: settings.buffer_font_size(cx).into(), + font_weight: settings.buffer_font.weight, + line_height: relative(settings.buffer_line_height.value()), + ..Default::default() + }, + }; + if let Some(text_style_refinement) = &self.text_style_refinement { + text_style.refine(text_style_refinement) + } + + let background = match self.mode { + EditorMode::SingleLine => cx.theme().system().transparent, + EditorMode::AutoHeight { .. } => cx.theme().system().transparent, + EditorMode::Full { .. } => cx.theme().colors().editor_background, + EditorMode::Minimap { .. } => cx.theme().colors().editor_background.opacity(0.7), + }; + + EditorStyle { + background, + border: cx.theme().colors().border, + local_player: cx.theme().players().local(), + text: text_style, + scrollbar_width: EditorElement::SCROLLBAR_WIDTH, + syntax: cx.theme().syntax().clone(), + status: cx.theme().status().clone(), + inlay_hints_style: make_inlay_hints_style(cx), + edit_prediction_styles: make_suggestion_styles(cx), + unnecessary_code_fade: settings.unnecessary_code_fade, + show_underlines: self.diagnostics_enabled(), + } + } } fn edit_for_markdown_paste<'a>( @@ -23334,76 +23294,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( @@ -24690,94 +24830,98 @@ impl EditorSnapshot { self.scroll_anchor.scroll_position(&self.display_snapshot) } - fn gutter_dimensions( + pub fn gutter_dimensions( &self, font_id: FontId, font_size: Pixels, - max_line_number_width: Pixels, + style: &EditorStyle, + window: &mut Window, cx: &App, - ) -> Option { - if !self.show_gutter { - return None; - } - - let ch_width = cx.text_system().ch_width(font_id, font_size).log_err()?; - let ch_advance = cx.text_system().ch_advance(font_id, font_size).log_err()?; + ) -> GutterDimensions { + if self.show_gutter + && let Some(ch_width) = cx.text_system().ch_width(font_id, font_size).log_err() + && let Some(ch_advance) = cx.text_system().ch_advance(font_id, font_size).log_err() + { + let show_git_gutter = self.show_git_diff_gutter.unwrap_or_else(|| { + matches!( + ProjectSettings::get_global(cx).git.git_gutter, + GitGutterSetting::TrackedFiles + ) + }); + let gutter_settings = EditorSettings::get_global(cx).gutter; + let show_line_numbers = self + .show_line_numbers + .unwrap_or(gutter_settings.line_numbers); + let line_gutter_width = if show_line_numbers { + // Avoid flicker-like gutter resizes when the line number gains another digit by + // only resizing the gutter on files with > 10**min_line_number_digits lines. + let min_width_for_number_on_gutter = + ch_advance * gutter_settings.min_line_number_digits as f32; + self.max_line_number_width(style, window) + .max(min_width_for_number_on_gutter) + } else { + 0.0.into() + }; - let show_git_gutter = self.show_git_diff_gutter.unwrap_or_else(|| { - matches!( - ProjectSettings::get_global(cx).git.git_gutter, - GitGutterSetting::TrackedFiles - ) - }); - let gutter_settings = EditorSettings::get_global(cx).gutter; - let show_line_numbers = self - .show_line_numbers - .unwrap_or(gutter_settings.line_numbers); - let line_gutter_width = if show_line_numbers { - // Avoid flicker-like gutter resizes when the line number gains another digit by - // only resizing the gutter on files with > 10**min_line_number_digits lines. - let min_width_for_number_on_gutter = - ch_advance * gutter_settings.min_line_number_digits as f32; - max_line_number_width.max(min_width_for_number_on_gutter) - } else { - 0.0.into() - }; + let show_runnables = self.show_runnables.unwrap_or(gutter_settings.runnables); + let show_breakpoints = self.show_breakpoints.unwrap_or(gutter_settings.breakpoints); - let show_runnables = self.show_runnables.unwrap_or(gutter_settings.runnables); - let show_breakpoints = self.show_breakpoints.unwrap_or(gutter_settings.breakpoints); + let git_blame_entries_width = + self.git_blame_gutter_max_author_length + .map(|max_author_length| { + let renderer = cx.global::().0.clone(); + const MAX_RELATIVE_TIMESTAMP: &str = "60 minutes ago"; - let git_blame_entries_width = - self.git_blame_gutter_max_author_length - .map(|max_author_length| { - let renderer = cx.global::().0.clone(); - const MAX_RELATIVE_TIMESTAMP: &str = "60 minutes ago"; + /// The number of characters to dedicate to gaps and margins. + const SPACING_WIDTH: usize = 4; - /// The number of characters to dedicate to gaps and margins. - const SPACING_WIDTH: usize = 4; + let max_char_count = max_author_length.min(renderer.max_author_length()) + + ::git::SHORT_SHA_LENGTH + + MAX_RELATIVE_TIMESTAMP.len() + + SPACING_WIDTH; - let max_char_count = max_author_length.min(renderer.max_author_length()) - + ::git::SHORT_SHA_LENGTH - + MAX_RELATIVE_TIMESTAMP.len() - + SPACING_WIDTH; + ch_advance * max_char_count + }); - ch_advance * max_char_count - }); + let is_singleton = self.buffer_snapshot().is_singleton(); + + let mut left_padding = git_blame_entries_width.unwrap_or(Pixels::ZERO); + left_padding += if !is_singleton { + ch_width * 4.0 + } else if show_runnables || show_breakpoints { + ch_width * 3.0 + } else if show_git_gutter && show_line_numbers { + ch_width * 2.0 + } else if show_git_gutter || show_line_numbers { + ch_width + } else { + px(0.) + }; - let is_singleton = self.buffer_snapshot().is_singleton(); - - let mut left_padding = git_blame_entries_width.unwrap_or(Pixels::ZERO); - left_padding += if !is_singleton { - ch_width * 4.0 - } else if show_runnables || show_breakpoints { - ch_width * 3.0 - } else if show_git_gutter && show_line_numbers { - ch_width * 2.0 - } else if show_git_gutter || show_line_numbers { - ch_width - } else { - px(0.) - }; + let shows_folds = is_singleton && gutter_settings.folds; - let shows_folds = is_singleton && gutter_settings.folds; + let right_padding = if shows_folds && show_line_numbers { + ch_width * 4.0 + } else if shows_folds || (!is_singleton && show_line_numbers) { + ch_width * 3.0 + } else if show_line_numbers { + ch_width + } else { + px(0.) + }; - let right_padding = if shows_folds && show_line_numbers { - ch_width * 4.0 - } else if shows_folds || (!is_singleton && show_line_numbers) { - ch_width * 3.0 - } else if show_line_numbers { - ch_width + GutterDimensions { + left_padding, + right_padding, + width: line_gutter_width + left_padding + right_padding, + margin: GutterDimensions::default_gutter_margin(font_id, font_size, cx), + git_blame_entries_width, + } + } else if self.offset_content { + GutterDimensions::default_with_margin(font_id, font_size, cx) } else { - px(0.) - }; - - Some(GutterDimensions { - left_padding, - right_padding, - width: line_gutter_width + left_padding + right_padding, - margin: GutterDimensions::default_gutter_margin(font_id, font_size, cx), - git_blame_entries_width, - }) + GutterDimensions::default() + } } pub fn render_crease_toggle( @@ -24860,6 +25004,28 @@ impl EditorSnapshot { None } } + + pub fn max_line_number_width(&self, style: &EditorStyle, window: &mut Window) -> Pixels { + let digit_count = self.widest_line_number().ilog10() + 1; + column_pixels(style, digit_count as usize, window) + } +} + +pub fn column_pixels(style: &EditorStyle, column: usize, window: &Window) -> Pixels { + let font_size = style.text.font_size.to_pixels(window.rem_size()); + let layout = window.text_system().shape_line( + SharedString::from(" ".repeat(column)), + font_size, + &[TextRun { + len: column, + font: style.text.font(), + color: Hsla::default(), + ..Default::default() + }], + None, + ); + + layout.width } impl Deref for EditorSnapshot { @@ -24940,57 +25106,7 @@ impl Focusable for Editor { impl Render for Editor { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let settings = ThemeSettings::get_global(cx); - - let mut text_style = match self.mode { - EditorMode::SingleLine | EditorMode::AutoHeight { .. } => TextStyle { - color: cx.theme().colors().editor_foreground, - font_family: settings.ui_font.family.clone(), - font_features: settings.ui_font.features.clone(), - font_fallbacks: settings.ui_font.fallbacks.clone(), - font_size: rems(0.875).into(), - font_weight: settings.ui_font.weight, - line_height: relative(settings.buffer_line_height.value()), - ..Default::default() - }, - EditorMode::Full { .. } | EditorMode::Minimap { .. } => TextStyle { - color: cx.theme().colors().editor_foreground, - font_family: settings.buffer_font.family.clone(), - font_features: settings.buffer_font.features.clone(), - font_fallbacks: settings.buffer_font.fallbacks.clone(), - font_size: settings.buffer_font_size(cx).into(), - font_weight: settings.buffer_font.weight, - line_height: relative(settings.buffer_line_height.value()), - ..Default::default() - }, - }; - if let Some(text_style_refinement) = &self.text_style_refinement { - text_style.refine(text_style_refinement) - } - - let background = match self.mode { - EditorMode::SingleLine => cx.theme().system().transparent, - EditorMode::AutoHeight { .. } => cx.theme().system().transparent, - EditorMode::Full { .. } => cx.theme().colors().editor_background, - EditorMode::Minimap { .. } => cx.theme().colors().editor_background.opacity(0.7), - }; - - EditorElement::new( - &cx.entity(), - EditorStyle { - background, - border: cx.theme().colors().border, - local_player: cx.theme().players().local(), - text: text_style, - scrollbar_width: EditorElement::SCROLLBAR_WIDTH, - syntax: cx.theme().syntax().clone(), - status: cx.theme().status().clone(), - inlay_hints_style: make_inlay_hints_style(cx), - edit_prediction_styles: make_suggestion_styles(cx), - unnecessary_code_fade: ThemeSettings::get_global(cx).unnecessary_code_fade, - show_underlines: self.diagnostics_enabled(), - }, - ) + EditorElement::new(&cx.entity(), self.create_style(cx)) } } @@ -25793,7 +25909,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_settings.rs b/crates/editor/src/editor_settings.rs index e1984311d4eb0ba9d989f77a707b22698b00c750..464157202f4821c8f05af479d2eff9f441a961ef 100644 --- a/crates/editor/src/editor_settings.rs +++ b/crates/editor/src/editor_settings.rs @@ -215,7 +215,8 @@ impl Settings for EditorSettings { }, scrollbar: Scrollbar { show: scrollbar.show.map(Into::into).unwrap(), - git_diff: scrollbar.git_diff.unwrap(), + git_diff: scrollbar.git_diff.unwrap() + && content.git.unwrap().enabled.unwrap().is_git_diff_enabled(), selected_text: scrollbar.selected_text.unwrap(), selected_symbol: scrollbar.selected_symbol.unwrap(), search_results: scrollbar.search_results.unwrap(), diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 3c33519370907d3a2f53d63d9e24403c36a5e45a..c0112c5eda406c9cb3b3b9d004d20853b710f6e1 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::{ @@ -67,7 +69,6 @@ use util::{ use workspace::{ CloseActiveItem, CloseAllItems, CloseOtherItems, MoveItemToPaneInDirection, NavigationEntry, OpenOptions, ViewId, - invalid_item_view::InvalidItemView, item::{FollowEvent, FollowableItem, Item, ItemHandle, SaveOptions}, register_project_item, }; @@ -2218,10 +2219,9 @@ async fn test_move_start_of_paragraph_end_of_paragraph(cx: &mut TestAppContext) init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; - let line_height = cx.editor(|editor, window, _| { + let line_height = cx.update_editor(|editor, window, cx| { editor - .style() - .unwrap() + .style(cx) .text .line_height_in_pixels(window.rem_size()) }); @@ -2334,10 +2334,9 @@ async fn test_move_start_of_paragraph_end_of_paragraph(cx: &mut TestAppContext) async fn test_scroll_page_up_page_down(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; - let line_height = cx.editor(|editor, window, _| { + let line_height = cx.update_editor(|editor, window, cx| { editor - .style() - .unwrap() + .style(cx) .text .line_height_in_pixels(window.rem_size()) }); @@ -2400,8 +2399,7 @@ async fn test_autoscroll(cx: &mut TestAppContext) { let line_height = cx.update_editor(|editor, window, cx| { editor.set_vertical_scroll_margin(2, cx); editor - .style() - .unwrap() + .style(cx) .text .line_height_in_pixels(window.rem_size()) }); @@ -2480,10 +2478,9 @@ async fn test_move_page_up_page_down(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; - let line_height = cx.editor(|editor, window, _cx| { + let line_height = cx.update_editor(|editor, window, cx| { editor - .style() - .unwrap() + .style(cx) .text .line_height_in_pixels(window.rem_size()) }); @@ -7754,10 +7751,12 @@ fn test_select_line(cx: &mut TestAppContext) { ]) }); editor.select_line(&SelectLine, window, cx); + // Adjacent line selections should NOT merge (only overlapping ones do) assert_eq!( display_ranges(editor, cx), vec![ - DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(2), 0), + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(1), 0), + DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(2), 0), DisplayPoint::new(DisplayRow(4), 0)..DisplayPoint::new(DisplayRow(5), 0), ] ); @@ -7776,9 +7775,13 @@ fn test_select_line(cx: &mut TestAppContext) { _ = editor.update(cx, |editor, window, cx| { editor.select_line(&SelectLine, window, cx); + // Adjacent but not overlapping, so they stay separate assert_eq!( display_ranges(editor, cx), - vec![DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(5), 5)] + vec![ + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(4), 0), + DisplayPoint::new(DisplayRow(4), 0)..DisplayPoint::new(DisplayRow(5), 5), + ] ); }); } @@ -10867,6 +10870,115 @@ async fn test_autoclose_with_overrides(cx: &mut TestAppContext) { ); } +#[gpui::test] +async fn test_autoclose_quotes_with_scope_awareness(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + let language = languages::language("python", tree_sitter_python::LANGUAGE.into()); + + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + + // Double quote inside single-quoted string + cx.set_state(indoc! {r#" + def main(): + items = ['"', ˇ] + "#}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("\"", window, cx); + }); + cx.assert_editor_state(indoc! {r#" + def main(): + items = ['"', "ˇ"] + "#}); + + // Two double quotes inside single-quoted string + cx.set_state(indoc! {r#" + def main(): + items = ['""', ˇ] + "#}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("\"", window, cx); + }); + cx.assert_editor_state(indoc! {r#" + def main(): + items = ['""', "ˇ"] + "#}); + + // Single quote inside double-quoted string + cx.set_state(indoc! {r#" + def main(): + items = ["'", ˇ] + "#}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("'", window, cx); + }); + cx.assert_editor_state(indoc! {r#" + def main(): + items = ["'", 'ˇ'] + "#}); + + // Two single quotes inside double-quoted string + cx.set_state(indoc! {r#" + def main(): + items = ["''", ˇ] + "#}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("'", window, cx); + }); + cx.assert_editor_state(indoc! {r#" + def main(): + items = ["''", 'ˇ'] + "#}); + + // Mixed quotes on same line + cx.set_state(indoc! {r#" + def main(): + items = ['"""', "'''''", ˇ] + "#}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("\"", window, cx); + }); + cx.assert_editor_state(indoc! {r#" + def main(): + items = ['"""', "'''''", "ˇ"] + "#}); + cx.update_editor(|editor, window, cx| { + editor.move_right(&MoveRight, window, cx); + }); + cx.update_editor(|editor, window, cx| { + editor.handle_input(", ", window, cx); + }); + cx.update_editor(|editor, window, cx| { + editor.handle_input("'", window, cx); + }); + cx.assert_editor_state(indoc! {r#" + def main(): + items = ['"""', "'''''", "", 'ˇ'] + "#}); +} + +#[gpui::test] +async fn test_autoclose_quotes_with_multibyte_characters(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + let language = languages::language("python", tree_sitter_python::LANGUAGE.into()); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + + cx.set_state(indoc! {r#" + def main(): + items = ["🎉", ˇ] + "#}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("\"", window, cx); + }); + cx.assert_editor_state(indoc! {r#" + def main(): + items = ["🎉", "ˇ"] + "#}); +} + #[gpui::test] async fn test_surround_with_pair(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -16200,7 +16312,7 @@ async fn test_toggle_comment(cx: &mut TestAppContext) { cx.assert_editor_state(indoc! {" fn a() { «b(); - c(); + ˇ»«c(); ˇ» d(); } "}); @@ -16212,8 +16324,8 @@ async fn test_toggle_comment(cx: &mut TestAppContext) { cx.assert_editor_state(indoc! {" fn a() { // «b(); - // c(); - ˇ»// d(); + ˇ»// «c(); + ˇ» // d(); } "}); @@ -16222,7 +16334,7 @@ async fn test_toggle_comment(cx: &mut TestAppContext) { fn a() { // b(); «// c(); - ˇ» // d(); + ˇ» // d(); } "}); @@ -16232,7 +16344,7 @@ async fn test_toggle_comment(cx: &mut TestAppContext) { fn a() { // b(); «c(); - ˇ» // d(); + ˇ» // d(); } "}); @@ -18088,7 +18200,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)]) }); @@ -20768,6 +20880,36 @@ async fn test_toggling_adjacent_diff_hunks(cx: &mut TestAppContext) { .to_string(), ); + cx.update_editor(|editor, window, cx| { + editor.move_up(&MoveUp, window, cx); + editor.toggle_selected_diff_hunks(&Default::default(), window, cx); + }); + cx.assert_state_with_diff( + indoc! { " + ˇone + - two + three + five + "} + .to_string(), + ); + + cx.update_editor(|editor, window, cx| { + editor.move_down(&MoveDown, window, cx); + editor.move_down(&MoveDown, window, cx); + editor.toggle_selected_diff_hunks(&Default::default(), window, cx); + }); + cx.assert_state_with_diff( + indoc! { " + one + - two + ˇthree + - four + five + "} + .to_string(), + ); + cx.set_state(indoc! { " one ˇTWO @@ -20807,6 +20949,66 @@ async fn test_toggling_adjacent_diff_hunks(cx: &mut TestAppContext) { ); } +#[gpui::test] +async fn test_toggling_adjacent_diff_hunks_2( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + let diff_base = r#" + lineA + lineB + lineC + lineD + "# + .unindent(); + + cx.set_state( + &r#" + ˇlineA1 + lineB + lineD + "# + .unindent(), + ); + cx.set_head_text(&diff_base); + executor.run_until_parked(); + + cx.update_editor(|editor, window, cx| { + editor.toggle_selected_diff_hunks(&ToggleSelectedDiffHunks, window, cx); + }); + executor.run_until_parked(); + cx.assert_state_with_diff( + r#" + - lineA + + ˇlineA1 + lineB + lineD + "# + .unindent(), + ); + + cx.update_editor(|editor, window, cx| { + editor.move_down(&MoveDown, window, cx); + editor.move_right(&MoveRight, window, cx); + editor.toggle_selected_diff_hunks(&ToggleSelectedDiffHunks, window, cx); + }); + executor.run_until_parked(); + cx.assert_state_with_diff( + r#" + - lineA + + lineA1 + lˇineB + - lineC + lineD + "# + .unindent(), + ); +} + #[gpui::test] async fn test_edits_around_expanded_deletion_hunks( executor: BackgroundExecutor, @@ -22122,6 +22324,40 @@ async fn test_toggle_deletion_hunk_at_start_of_file( cx.assert_state_with_diff(hunk_expanded); } +#[gpui::test] +async fn test_expand_first_line_diff_hunk_keeps_deleted_lines_visible( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state("ˇnew\nsecond\nthird\n"); + cx.set_head_text("old\nsecond\nthird\n"); + cx.update_editor(|editor, window, cx| { + editor.scroll(gpui::Point { x: 0., y: 0. }, None, window, cx); + }); + executor.run_until_parked(); + assert_eq!(cx.update_editor(|e, _, cx| e.scroll_position(cx)).y, 0.0); + + // Expanding a diff hunk at the first line inserts deleted lines above the first buffer line. + cx.update_editor(|editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + let excerpt_id = editor.buffer.read(cx).excerpt_ids()[0]; + let hunks = editor + .diff_hunks_in_ranges(&[Anchor::min()..Anchor::max()], &snapshot.buffer_snapshot()) + .collect::>(); + assert_eq!(hunks.len(), 1); + let hunk_range = Anchor::range_in_buffer(excerpt_id, hunks[0].buffer_range.clone()); + editor.toggle_single_diff_hunk(hunk_range, cx) + }); + executor.run_until_parked(); + cx.assert_state_with_diff("- old\n+ ˇnew\n second\n third\n".to_string()); + + // Keep the editor scrolled to the top so the full hunk remains visible. + assert_eq!(cx.update_editor(|e, _, cx| e.scroll_position(cx)).y, 0.0); +} + #[gpui::test] async fn test_display_diff_hunks(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -25433,6 +25669,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: @@ -25452,6 +25689,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: @@ -25485,6 +25723,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: @@ -25501,6 +25740,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: @@ -25534,6 +25774,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: @@ -25551,6 +25792,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: @@ -25570,6 +25812,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: @@ -25593,6 +25836,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: @@ -25617,6 +25861,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: @@ -25642,6 +25887,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: @@ -25667,6 +25913,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: @@ -25690,6 +25937,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: @@ -25711,6 +25959,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): @@ -25727,6 +25976,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ˇ @@ -25740,6 +25990,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:ˇ @@ -25763,6 +26014,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: ˇ @@ -25775,7 +26027,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! {" { ˇ @@ -25809,6 +26061,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, |_| {}); @@ -25835,6 +26129,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 @@ -25852,6 +26147,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 @@ -25886,6 +26182,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 @@ -25928,6 +26225,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 @@ -25962,6 +26260,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\" @@ -25977,6 +26276,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\" @@ -25994,6 +26294,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\" @@ -26011,6 +26312,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\" @@ -26026,6 +26328,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\" @@ -26046,6 +26349,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) @@ -26068,6 +26372,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) @@ -26087,6 +26392,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\" @@ -26113,6 +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.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" # COMMENT: ˇ @@ -26126,7 +26433,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 @@ -26141,7 +26448,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 @@ -26156,7 +26463,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 @@ -26170,7 +26477,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 ˇ @@ -26184,7 +26491,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) @@ -26201,7 +26508,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) @@ -26217,7 +26524,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() { ˇ @@ -26231,7 +26538,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\"; ˇ @@ -27449,11 +27756,10 @@ async fn test_non_utf_8_opens(cx: &mut TestAppContext) { }) .await .unwrap(); - - assert_eq!( - handle.to_any_view().entity_type(), - TypeId::of::() - ); + // The test file content `vec![0xff, 0xfe, ...]` starts with a UTF-16 LE BOM. + // Previously, this fell back to `InvalidItemView` because it wasn't valid UTF-8. + // With auto-detection enabled, this is now recognized as UTF-16 and opens in the Editor. + assert_eq!(handle.to_any_view().entity_type(), TypeId::of::()); } #[gpui::test] @@ -27705,6 +28011,7 @@ async fn test_markdown_indents(cx: &mut gpui::TestAppContext) { cx.update_editor(|editor, window, cx| { editor.handle_input("x", window, cx); }); + cx.run_until_parked(); cx.assert_editor_state(indoc! {" - [ ] Item 1 - [ ] Item 1.a @@ -27720,8 +28027,7 @@ async fn test_markdown_indents(cx: &mut gpui::TestAppContext) { - [ ] Item 1.a - [x] Item 2 - [x] Item 2.a - - [x] Item 2.bˇ - " + - [x] Item 2.bˇ" }); cx.update_editor(|editor, window, cx| { editor.newline(&Newline, window, cx); @@ -27732,34 +28038,41 @@ async fn test_markdown_indents(cx: &mut gpui::TestAppContext) { - [x] Item 2 - [x] Item 2.a - [x] Item 2.b - ˇ - " + ˇ" }); // Case 3: Test adding a new nested list item preserves indent + cx.set_state(&indoc! {" + - [ ] Item 1 + - [ ] Item 1.a + - [x] Item 2 + - [x] Item 2.a + - [x] Item 2.b + ˇ" + }); cx.update_editor(|editor, window, cx| { editor.handle_input("-", window, cx); }); + cx.run_until_parked(); cx.assert_editor_state(indoc! {" - [ ] Item 1 - [ ] Item 1.a - [x] Item 2 - [x] Item 2.a - [x] Item 2.b - -ˇ - " + -ˇ" }); cx.update_editor(|editor, window, cx| { editor.handle_input(" [x] Item 2.c", window, cx); }); + cx.run_until_parked(); cx.assert_editor_state(indoc! {" - [ ] Item 1 - [ ] Item 1.a - [x] Item 2 - [x] Item 2.a - [x] Item 2.b - - [x] Item 2.cˇ - " + - [x] Item 2.cˇ" }); // Case 4: Test adding new line after nested ordered list preserves indent of previous line @@ -27768,8 +28081,7 @@ async fn test_markdown_indents(cx: &mut gpui::TestAppContext) { 1. Item 1.a 2. Item 2 1. Item 2.a - 2. Item 2.bˇ - " + 2. Item 2.bˇ" }); cx.update_editor(|editor, window, cx| { editor.newline(&Newline, window, cx); @@ -27780,60 +28092,81 @@ async fn test_markdown_indents(cx: &mut gpui::TestAppContext) { 2. Item 2 1. Item 2.a 2. Item 2.b - ˇ - " + ˇ" }); // Case 5: Adding new ordered list item preserves indent + cx.set_state(indoc! {" + 1. Item 1 + 1. Item 1.a + 2. Item 2 + 1. Item 2.a + 2. Item 2.b + ˇ" + }); cx.update_editor(|editor, window, cx| { editor.handle_input("3", window, cx); }); + cx.run_until_parked(); cx.assert_editor_state(indoc! {" 1. Item 1 1. Item 1.a 2. Item 2 1. Item 2.a 2. Item 2.b - 3ˇ - " + 3ˇ" }); cx.update_editor(|editor, window, cx| { editor.handle_input(".", window, cx); }); + cx.run_until_parked(); cx.assert_editor_state(indoc! {" 1. Item 1 1. Item 1.a 2. Item 2 1. Item 2.a 2. Item 2.b - 3.ˇ - " + 3.ˇ" }); cx.update_editor(|editor, window, cx| { editor.handle_input(" Item 2.c", window, cx); }); + cx.run_until_parked(); cx.assert_editor_state(indoc! {" 1. Item 1 1. Item 1.a 2. Item 2 1. Item 2.a 2. Item 2.b - 3. Item 2.cˇ - " + 3. Item 2.cˇ" + }); + + // Case 6: Test adding new line after nested ordered list preserves indent of previous line + cx.set_state(indoc! {" + - Item 1 + - Item 1.a + - Item 1.a + ˇ"}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("-", window, cx); }); + cx.run_until_parked(); + cx.assert_editor_state(indoc! {" + - Item 1 + - Item 1.a + - Item 1.a + -ˇ"}); // Case 7: Test blockquote newline preserves something cx.set_state(indoc! {" - > Item 1ˇ - " + > Item 1ˇ" }); cx.update_editor(|editor, window, cx| { editor.newline(&Newline, window, cx); }); cx.assert_editor_state(indoc! {" > Item 1 - ˇ - " + ˇ" }); } @@ -28311,7 +28644,8 @@ async fn test_sticky_scroll(cx: &mut TestAppContext) { let mut sticky_headers = |offset: ScrollOffset| { cx.update_editor(|e, window, cx| { e.scroll(gpui::Point { x: 0., y: offset }, None, window, cx); - EditorElement::sticky_headers(&e, &e.snapshot(window, cx), cx) + let style = e.style(cx).clone(); + EditorElement::sticky_headers(&e, &e.snapshot(window, cx), &style, cx) .into_iter() .map( |StickyHeader { @@ -28365,10 +28699,9 @@ async fn test_scroll_by_clicking_sticky_header(cx: &mut TestAppContext) { }); let mut cx = EditorTestContext::new(cx).await; - let line_height = cx.editor(|editor, window, _cx| { + let line_height = cx.update_editor(|editor, window, cx| { editor - .style() - .unwrap() + .style(cx) .text .line_height_in_pixels(window.rem_size()) }); @@ -29163,3 +29496,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 fab51cbef29de436e447c317849ad15aa318c45d..f7b6aa949e74dca9bee73419fa2b87899f9986fd 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -11,6 +11,7 @@ use crate::{ SelectedTextHighlight, Selection, SelectionDragState, SelectionEffects, SizingBehavior, SoftWrap, StickyHeaderExcerpt, ToPoint, ToggleFold, ToggleFoldAll, code_context_menus::{CodeActionsMenu, MENU_ASIDE_MAX_WIDTH, MENU_ASIDE_MIN_WIDTH, MENU_GAP}, + column_pixels, display_map::{ Block, BlockContext, BlockStyle, ChunkRendererId, DisplaySnapshot, EditorMargins, HighlightKey, HighlightedChunk, ToDisplayPoint, @@ -36,22 +37,18 @@ use crate::{ use buffer_diff::{DiffHunkStatus, DiffHunkStatusKind}; use collections::{BTreeMap, HashMap}; use file_icons::FileIcons; -use git::{ - Oid, - blame::{BlameEntry, ParsedCommitMessage}, - status::FileStatus, -}; +use git::{Oid, blame::BlameEntry, commit::ParsedCommitMessage, status::FileStatus}; use gpui::{ Action, Along, AnyElement, App, AppContext, AvailableSpace, Axis as ScrollbarAxis, BorderStyle, Bounds, ClickEvent, ClipboardItem, ContentMask, Context, Corner, Corners, CursorStyle, DispatchPhase, Edges, Element, ElementInputHandler, Entity, Focusable as _, FontId, GlobalElementId, Hitbox, HitboxBehavior, Hsla, InteractiveElement, IntoElement, IsZero, KeybindingKeystroke, Length, Modifiers, ModifiersChangedEvent, MouseButton, MouseClickEvent, - MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, ParentElement, Pixels, ScrollDelta, - ScrollHandle, ScrollWheelEvent, ShapedLine, SharedString, Size, StatefulInteractiveElement, - Style, Styled, TextRun, TextStyleRefinement, WeakEntity, Window, anchored, deferred, div, fill, - linear_color_stop, linear_gradient, outline, point, px, quad, relative, size, solid_background, - transparent_black, + MouseDownEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent, PaintQuad, ParentElement, + Pixels, PressureStage, ScrollDelta, ScrollHandle, ScrollWheelEvent, ShapedLine, SharedString, + Size, StatefulInteractiveElement, Style, Styled, TextRun, TextStyleRefinement, WeakEntity, + Window, anchored, deferred, div, fill, linear_color_stop, linear_gradient, outline, point, px, + quad, relative, size, solid_background, transparent_black, }; use itertools::Itertools; use language::{IndentGuideSettings, language_settings::ShowWhitespaceSetting}; @@ -61,6 +58,7 @@ use multi_buffer::{ MultiBufferRow, RowInfo, }; +use edit_prediction_types::EditPredictionGranularity; use project::{ Entry, ProjectPath, debugger::breakpoint_store::{Breakpoint, BreakpointSessionState}, @@ -131,6 +129,7 @@ impl SelectionLayout { fn new( selection: Selection, line_mode: bool, + cursor_offset: bool, cursor_shape: CursorShape, map: &DisplaySnapshot, is_newest: bool, @@ -151,12 +150,9 @@ impl SelectionLayout { } // any vim visual mode (including line mode) - if (cursor_shape == CursorShape::Block || cursor_shape == CursorShape::Hollow) - && !range.is_empty() - && !selection.reversed - { + if cursor_offset && !range.is_empty() && !selection.reversed { if head.column() > 0 { - head = map.clip_point(DisplayPoint::new(head.row(), head.column() - 1), Bias::Left) + head = map.clip_point(DisplayPoint::new(head.row(), head.column() - 1), Bias::Left); } else if head.row().0 > 0 && head != map.max_point() { head = map.clip_point( DisplayPoint::new( @@ -594,8 +590,6 @@ impl EditorElement { register_action(editor, window, Editor::show_signature_help); register_action(editor, window, Editor::signature_help_prev); register_action(editor, window, Editor::signature_help_next); - register_action(editor, window, Editor::next_edit_prediction); - register_action(editor, window, Editor::previous_edit_prediction); register_action(editor, window, Editor::show_edit_prediction); register_action(editor, window, Editor::context_menu_first); register_action(editor, window, Editor::context_menu_prev); @@ -604,7 +598,8 @@ impl EditorElement { register_action(editor, window, Editor::display_cursor_names); register_action(editor, window, Editor::unique_lines_case_insensitive); register_action(editor, window, Editor::unique_lines_case_sensitive); - register_action(editor, window, Editor::accept_partial_edit_prediction); + register_action(editor, window, Editor::accept_next_word_edit_prediction); + register_action(editor, window, Editor::accept_next_line_edit_prediction); register_action(editor, window, Editor::accept_edit_prediction); register_action(editor, window, Editor::restore_file); register_action(editor, window, Editor::git_restore); @@ -1016,10 +1011,16 @@ impl EditorElement { let pending_nonempty_selections = editor.has_pending_nonempty_selection(); let hovered_link_modifier = Editor::is_cmd_or_ctrl_pressed(&event.modifiers(), cx); + let mouse_down_hovered_link_modifier = if let ClickEvent::Mouse(mouse_event) = event { + Editor::is_cmd_or_ctrl_pressed(&mouse_event.down.modifiers, cx) + } else { + true + }; if let Some(mouse_position) = event.mouse_position() && !pending_nonempty_selections && hovered_link_modifier + && mouse_down_hovered_link_modifier && text_hitbox.is_hovered(window) { let point = position_map.point_for_position(mouse_position); @@ -1030,6 +1031,28 @@ impl EditorElement { } } + fn pressure_click( + editor: &mut Editor, + event: &MousePressureEvent, + position_map: &PositionMap, + window: &mut Window, + cx: &mut Context, + ) { + let text_hitbox = &position_map.text_hitbox; + let force_click_possible = + matches!(editor.prev_pressure_stage, Some(PressureStage::Normal)) + && event.stage == PressureStage::Force; + + editor.prev_pressure_stage = Some(event.stage); + + if force_click_possible && text_hitbox.is_hovered(window) { + let point = position_map.point_for_position(event.position); + editor.handle_click_hovered_link(point, event.modifiers, window, cx); + editor.selection_drag_state = SelectionDragState::None; + cx.stop_propagation(); + } + } + fn mouse_dragged( editor: &mut Editor, event: &MouseMoveEvent, @@ -1434,6 +1457,7 @@ impl EditorElement { let layout = SelectionLayout::new( selection, editor.selections.line_mode(), + editor.cursor_offset_on_selection, editor.cursor_shape, &snapshot.display_snapshot, is_newest, @@ -1480,6 +1504,7 @@ impl EditorElement { let drag_cursor_layout = SelectionLayout::new( drop_cursor.clone(), false, + editor.cursor_offset_on_selection, CursorShape::Bar, &snapshot.display_snapshot, false, @@ -1543,6 +1568,7 @@ impl EditorElement { .push(SelectionLayout::new( selection.selection, selection.line_mode, + editor.cursor_offset_on_selection, selection.cursor_shape, &snapshot.display_snapshot, false, @@ -1553,6 +1579,8 @@ impl EditorElement { selections.extend(remote_selections.into_values()); } else if !editor.is_focused(window) && editor.show_cursor_when_unfocused { + let cursor_offset_on_selection = editor.cursor_offset_on_selection; + let layouts = snapshot .buffer_snapshot() .selections_in_range(&(start_anchor..end_anchor), true) @@ -1560,6 +1588,7 @@ impl EditorElement { SelectionLayout::new( selection, line_mode, + cursor_offset_on_selection, cursor_shape, &snapshot.display_snapshot, false, @@ -2269,7 +2298,8 @@ impl EditorElement { }; let padding = ProjectSettings::get_global(cx).diagnostics.inline.padding as f32 * em_width; - let min_x = self.column_pixels( + let min_x = column_pixels( + &self.style, ProjectSettings::get_global(cx) .diagnostics .inline @@ -2572,7 +2602,8 @@ impl EditorElement { let padded_line_end = line_end + padding; - let min_column_in_pixels = self.column_pixels( + let min_column_in_pixels = column_pixels( + &self.style, ProjectSettings::get_global(cx).git.inline_blame.min_column as usize, window, ); @@ -2796,7 +2827,7 @@ impl EditorElement { .enumerate() .filter_map(|(i, indent_guide)| { let single_indent_width = - self.column_pixels(indent_guide.tab_size as usize, window); + column_pixels(&self.style, indent_guide.tab_size as usize, window); let total_width = single_indent_width * indent_guide.depth as f32; let start_x = Pixels::from( ScrollOffset::from(content_origin.x + total_width) @@ -2853,7 +2884,7 @@ impl EditorElement { .wrap_guides(cx) .into_iter() .flat_map(|(guide, active)| { - let wrap_position = self.column_pixels(guide, window); + let wrap_position = column_pixels(&self.style, guide, window); let wrap_guide_x = wrap_position + horizontal_offset; let display_wrap_guide = wrap_guide_x >= content_origin && wrap_guide_x <= hitbox.bounds.right() - vertical_scrollbar_width; @@ -3281,6 +3312,7 @@ impl EditorElement { SelectionLayout::new( newest, editor.selections.line_mode(), + editor.cursor_offset_on_selection, editor.cursor_shape, &snapshot.display_snapshot, true, @@ -4619,6 +4651,7 @@ impl EditorElement { gutter_dimensions: &GutterDimensions, gutter_hitbox: &Hitbox, text_hitbox: &Hitbox, + style: &EditorStyle, window: &mut Window, cx: &mut App, ) -> Option { @@ -4626,7 +4659,7 @@ impl EditorElement { .show_line_numbers .unwrap_or_else(|| EditorSettings::get_global(cx).gutter.line_numbers); - let rows = Self::sticky_headers(self.editor.read(cx), snapshot, cx); + let rows = Self::sticky_headers(self.editor.read(cx), snapshot, style, cx); let mut lines = Vec::::new(); @@ -4685,6 +4718,7 @@ impl EditorElement { pub(crate) fn sticky_headers( editor: &Editor, snapshot: &EditorSnapshot, + style: &EditorStyle, cx: &App, ) -> Vec { let scroll_top = snapshot.scroll_position().y; @@ -4692,7 +4726,7 @@ impl EditorElement { let mut end_rows = Vec::::new(); let mut rows = Vec::::new(); - let items = editor.sticky_headers(cx).unwrap_or_default(); + let items = editor.sticky_headers(style, cx).unwrap_or_default(); for item in items { let start_point = item.range.start.to_point(snapshot.buffer_snapshot()); @@ -4862,8 +4896,11 @@ impl EditorElement { let edit_prediction = if edit_prediction_popover_visible { self.editor.update(cx, move |editor, cx| { - let accept_binding = - editor.accept_edit_prediction_keybind(false, window, cx); + let accept_binding = editor.accept_edit_prediction_keybind( + EditPredictionGranularity::Full, + window, + cx, + ); let mut element = editor.render_edit_prediction_cursor_popover( min_width, max_width, @@ -5255,7 +5292,7 @@ impl EditorElement { ) -> Option { let max_height_in_lines = ((height - POPOVER_Y_PADDING) / line_height).floor() as u32; self.editor.update(cx, |editor, cx| { - editor.render_context_menu(&self.style, max_height_in_lines, window, cx) + editor.render_context_menu(max_height_in_lines, window, cx) }) } @@ -5282,16 +5319,18 @@ impl EditorElement { window: &mut Window, cx: &mut App, ) -> Option { - let position = self.editor.update(cx, |editor, _cx| { + let position = self.editor.update(cx, |editor, cx| { let visible_start_point = editor.display_to_pixel_point( DisplayPoint::new(visible_range.start, 0), editor_snapshot, window, + cx, )?; let visible_end_point = editor.display_to_pixel_point( DisplayPoint::new(visible_range.end, 0), editor_snapshot, window, + cx, )?; let mouse_context_menu = editor.mouse_context_menu.as_ref()?; @@ -5299,7 +5338,8 @@ impl EditorElement { MenuPosition::PinnedToScreen(point) => (None, point), MenuPosition::PinnedToEditor { source, offset } => { let source_display_point = source.to_display_point(editor_snapshot); - let source_point = editor.to_pixel_point(source, editor_snapshot, window)?; + let source_point = + editor.to_pixel_point(source, editor_snapshot, window, cx)?; let position = content_origin + source_point + offset; (Some(source_display_point), position) } @@ -7750,6 +7790,19 @@ impl EditorElement { } }); + window.on_mouse_event({ + let position_map = layout.position_map.clone(); + let editor = self.editor.clone(); + + move |event: &MousePressureEvent, phase, window, cx| { + if phase == DispatchPhase::Bubble { + editor.update(cx, |editor, cx| { + Self::pressure_click(editor, &event, &position_map, window, cx); + }) + } + } + }); + window.on_mouse_event({ let position_map = layout.position_map.clone(); let editor = self.editor.clone(); @@ -7773,29 +7826,6 @@ impl EditorElement { }); } - fn column_pixels(&self, column: usize, window: &Window) -> Pixels { - let style = &self.style; - let font_size = style.text.font_size.to_pixels(window.rem_size()); - let layout = window.text_system().shape_line( - SharedString::from(" ".repeat(column)), - font_size, - &[TextRun { - len: column, - font: style.text.font(), - color: Hsla::default(), - ..Default::default() - }], - None, - ); - - layout.width - } - - fn max_line_number_width(&self, snapshot: &EditorSnapshot, window: &mut Window) -> Pixels { - let digit_count = snapshot.widest_line_number().ilog10() + 1; - self.column_pixels(digit_count as usize, window) - } - fn shape_line_number( &self, text: SharedString, @@ -8943,8 +8973,6 @@ impl Element for EditorElement { max_lines, } => { let editor_handle = cx.entity(); - let max_line_number_width = - self.max_line_number_width(&editor.snapshot(window, cx), window); window.request_measured_layout( Style::default(), move |known_dimensions, available_space, window, cx| { @@ -8954,7 +8982,6 @@ impl Element for EditorElement { editor, min_lines, max_lines, - max_line_number_width, known_dimensions, available_space.width, window, @@ -9041,15 +9068,10 @@ impl Element for EditorElement { .gutter_dimensions( font_id, font_size, - self.max_line_number_width(&snapshot, window), + style, + window, cx, - ) - .or_else(|| { - self.editor.read(cx).offset_content.then(|| { - GutterDimensions::default_with_margin(font_id, font_size, cx) - }) - }) - .unwrap_or_default(); + ); let text_width = bounds.size.width - gutter_dimensions.width; let settings = EditorSettings::get_global(cx); @@ -9136,6 +9158,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, @@ -9192,10 +9223,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); @@ -9740,6 +9777,7 @@ impl Element for EditorElement { &gutter_dimensions, &gutter_hitbox, &text_hitbox, + &style, window, cx, ) @@ -11456,7 +11494,6 @@ fn compute_auto_height_layout( editor: &mut Editor, min_lines: usize, max_lines: Option, - max_line_number_width: Pixels, known_dimensions: Size>, available_width: AvailableSpace, window: &mut Window, @@ -11480,14 +11517,7 @@ fn compute_auto_height_layout( let em_width = window.text_system().em_width(font_id, font_size).unwrap(); let mut snapshot = editor.snapshot(window, cx); - let gutter_dimensions = snapshot - .gutter_dimensions(font_id, font_size, max_line_number_width, cx) - .or_else(|| { - editor - .offset_content - .then(|| GutterDimensions::default_with_margin(font_id, font_size, cx)) - }) - .unwrap_or_default(); + let gutter_dimensions = snapshot.gutter_dimensions(font_id, font_size, style, window, cx); editor.gutter_dimensions = gutter_dimensions; let text_width = width - gutter_dimensions.width; @@ -11550,7 +11580,7 @@ mod tests { }); let cx = &mut VisualTestContext::from_window(*window, cx); let editor = window.root(cx).unwrap(); - let style = cx.update(|_, cx| editor.read(cx).style().unwrap().clone()); + let style = cx.update(|_, cx| editor.update(cx, |editor, cx| editor.style(cx).clone())); for x in 1..=100 { let (_, state) = cx.draw( @@ -11578,7 +11608,7 @@ mod tests { }); let cx = &mut VisualTestContext::from_window(*window, cx); let editor = window.root(cx).unwrap(); - let style = cx.update(|_, cx| editor.read(cx).style().unwrap().clone()); + let style = cx.update(|_, cx| editor.update(cx, |editor, cx| editor.style(cx).clone())); for x in 1..=100 { let (_, state) = cx.draw( @@ -11603,7 +11633,7 @@ mod tests { }); let editor = window.root(cx).unwrap(); - let style = cx.update(|cx| editor.read(cx).style().unwrap().clone()); + let style = editor.update(cx, |editor, cx| editor.style(cx).clone()); let line_height = window .update(cx, |_, window, _| { style.text.line_height_in_pixels(window.rem_size()) @@ -11751,7 +11781,7 @@ mod tests { }); let editor = window.root(cx).unwrap(); - let style = cx.update(|cx| editor.read(cx).style().unwrap().clone()); + let style = editor.update(cx, |editor, cx| editor.style(cx).clone()); let line_height = window .update(cx, |_, window, _| { style.text.line_height_in_pixels(window.rem_size()) @@ -11878,11 +11908,11 @@ mod tests { }); let cx = &mut VisualTestContext::from_window(*window, cx); let editor = window.root(cx).unwrap(); - let style = cx.update(|_, cx| editor.read(cx).style().unwrap().clone()); + let style = cx.update(|_, cx| editor.update(cx, |editor, cx| editor.style(cx).clone())); window .update(cx, |editor, window, cx| { - editor.cursor_shape = CursorShape::Block; + editor.cursor_offset_on_selection = true; editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([ Point::new(0, 0)..Point::new(1, 0), @@ -11949,7 +11979,7 @@ mod tests { }); let cx = &mut VisualTestContext::from_window(*window, cx); let editor = window.root(cx).unwrap(); - let style = cx.update(|_, cx| editor.read(cx).style().unwrap().clone()); + let style = cx.update(|_, cx| editor.update(cx, |editor, cx| editor.style(cx).clone())); window .update(cx, |editor, window, cx| { editor.set_placeholder_text("hello", window, cx); @@ -12189,7 +12219,7 @@ mod tests { let cx = &mut VisualTestContext::from_window(*window, cx); let editor = window.root(cx).unwrap(); - let style = cx.update(|_, cx| editor.read(cx).style().unwrap().clone()); + let style = editor.update(cx, |editor, cx| editor.style(cx).clone()); window .update(cx, |editor, _, cx| { editor.set_soft_wrap_mode(language_settings::SoftWrap::EditorWidth, cx); diff --git a/crates/editor/src/git/blame.rs b/crates/editor/src/git/blame.rs index 67df69aadab43a45c2941703e10bb81af2b8dd78..d1338c3cbd3540914b23a53410fd5c823e1285c8 100644 --- a/crates/editor/src/git/blame.rs +++ b/crates/editor/src/git/blame.rs @@ -1,11 +1,11 @@ use crate::Editor; -use anyhow::Result; +use anyhow::{Context as _, Result}; use collections::HashMap; -use futures::StreamExt; + use git::{ - GitHostingProviderRegistry, GitRemote, Oid, - blame::{Blame, BlameEntry, ParsedCommitMessage}, - parse_git_remote_url, + GitHostingProviderRegistry, Oid, + blame::{Blame, BlameEntry}, + commit::ParsedCommitMessage, }; use gpui::{ AnyElement, App, AppContext as _, Context, Entity, Hsla, ScrollHandle, Subscription, Task, @@ -494,84 +494,103 @@ impl GitBlame { self.changed_while_blurred = true; return; } - let blame = self.project.update(cx, |project, cx| { - let Some(multi_buffer) = self.multi_buffer.upgrade() else { - return Vec::new(); - }; - multi_buffer - .read(cx) - .all_buffer_ids() - .into_iter() - .filter_map(|id| { - let buffer = multi_buffer.read(cx).buffer(id)?; - let snapshot = buffer.read(cx).snapshot(); - let buffer_edits = buffer.update(cx, |buffer, _| buffer.subscribe()); - - let blame_buffer = project.blame_buffer(&buffer, None, cx); - let remote_url = project - .git_store() - .read(cx) - .repository_and_path_for_buffer_id(buffer.read(cx).remote_id(), cx) - .and_then(|(repo, _)| { - repo.read(cx) - .remote_upstream_url - .clone() - .or(repo.read(cx).remote_origin_url.clone()) - }); - Some( - async move { (id, snapshot, buffer_edits, blame_buffer.await, remote_url) }, - ) - }) - .collect::>() - }); - let provider_registry = GitHostingProviderRegistry::default_global(cx); + let buffers_to_blame = self + .multi_buffer + .update(cx, |multi_buffer, _| { + multi_buffer + .all_buffer_ids() + .into_iter() + .filter_map(|id| Some(multi_buffer.buffer(id)?.downgrade())) + .collect::>() + }) + .unwrap_or_default(); + let project = self.project.downgrade(); self.task = cx.spawn(async move |this, cx| { - let (result, errors) = cx - .background_spawn({ - async move { - let blame = futures::stream::iter(blame) - .buffered(4) - .collect::>() - .await; - let mut res = vec![]; - let mut errors = vec![]; - for (id, snapshot, buffer_edits, blame, remote_url) in blame { - match blame { - Ok(Some(Blame { entries, messages })) => { - let entries = build_blame_entry_sum_tree( - entries, - snapshot.max_point().row, - ); - let commit_details = parse_commit_messages( - messages, - remote_url, - provider_registry.clone(), - ) - .await; - - res.push(( + let mut all_results = Vec::new(); + let mut all_errors = Vec::new(); + + for buffers in buffers_to_blame.chunks(4) { + let blame = cx.update(|cx| { + buffers + .iter() + .map(|buffer| { + let buffer = buffer.upgrade().context("buffer was dropped")?; + let project = project.upgrade().context("project was dropped")?; + let id = buffer.read(cx).remote_id(); + let snapshot = buffer.read(cx).snapshot(); + let buffer_edits = buffer.update(cx, |buffer, _| buffer.subscribe()); + let remote_url = project + .read(cx) + .git_store() + .read(cx) + .repository_and_path_for_buffer_id(buffer.read(cx).remote_id(), cx) + .and_then(|(repo, _)| repo.read(cx).default_remote_url()); + let blame_buffer = project + .update(cx, |project, cx| project.blame_buffer(&buffer, None, cx)); + Ok(async move { + (id, snapshot, buffer_edits, blame_buffer.await, remote_url) + }) + }) + .collect::>>() + })??; + let provider_registry = + cx.update(|cx| GitHostingProviderRegistry::default_global(cx))?; + let (results, errors) = cx + .background_spawn({ + async move { + let blame = futures::future::join_all(blame).await; + let mut res = vec![]; + let mut errors = vec![]; + for (id, snapshot, buffer_edits, blame, remote_url) in blame { + match blame { + Ok(Some(Blame { entries, messages })) => { + let entries = build_blame_entry_sum_tree( + entries, + snapshot.max_point().row, + ); + let commit_details = messages + .into_iter() + .map(|(oid, message)| { + let parsed_commit_message = + ParsedCommitMessage::parse( + oid.to_string(), + message, + remote_url.as_deref(), + Some(provider_registry.clone()), + ); + (oid, parsed_commit_message) + }) + .collect(); + res.push(( + id, + snapshot, + buffer_edits, + Some(entries), + commit_details, + )); + } + Ok(None) => res.push(( id, snapshot, buffer_edits, - Some(entries), - commit_details, - )); - } - Ok(None) => { - res.push((id, snapshot, buffer_edits, None, Default::default())) + None, + Default::default(), + )), + Err(e) => errors.push(e), } - Err(e) => errors.push(e), } + (res, errors) } - (res, errors) - } - }) - .await; + }) + .await; + all_results.extend(results); + all_errors.extend(errors) + } this.update(cx, |this, cx| { this.buffers.clear(); - for (id, snapshot, buffer_edits, entries, commit_details) in result { + for (id, snapshot, buffer_edits, entries, commit_details) in all_results { let Some(entries) = entries else { continue; }; @@ -586,11 +605,11 @@ impl GitBlame { ); } cx.notify(); - if !errors.is_empty() { + if !all_errors.is_empty() { this.project.update(cx, |_, cx| { if this.user_triggered { - log::error!("failed to get git blame data: {errors:?}"); - let notification = errors + log::error!("failed to get git blame data: {all_errors:?}"); + let notification = all_errors .into_iter() .format_with(",", |e, f| f(&format_args!("{:#}", e))) .to_string(); @@ -601,7 +620,7 @@ impl GitBlame { } else { // If we weren't triggered by a user, we just log errors in the background, instead of sending // notifications. - log::debug!("failed to get git blame data: {errors:?}"); + log::debug!("failed to get git blame data: {all_errors:?}"); } }) } @@ -662,55 +681,6 @@ fn build_blame_entry_sum_tree(entries: Vec, max_row: u32) -> SumTree entries } -async fn parse_commit_messages( - messages: impl IntoIterator, - remote_url: Option, - provider_registry: Arc, -) -> HashMap { - let mut commit_details = HashMap::default(); - - let parsed_remote_url = remote_url - .as_deref() - .and_then(|remote_url| parse_git_remote_url(provider_registry, remote_url)); - - for (oid, message) in messages { - let permalink = if let Some((provider, git_remote)) = parsed_remote_url.as_ref() { - Some(provider.build_commit_permalink( - git_remote, - git::BuildCommitPermalinkParams { - sha: oid.to_string().as_str(), - }, - )) - } else { - None - }; - - let remote = parsed_remote_url - .as_ref() - .map(|(provider, remote)| GitRemote { - host: provider.clone(), - owner: remote.owner.clone().into(), - repo: remote.repo.clone().into(), - }); - - let pull_request = parsed_remote_url - .as_ref() - .and_then(|(provider, remote)| provider.extract_pull_request(remote, &message)); - - commit_details.insert( - oid, - ParsedCommitMessage { - message: message.into(), - permalink, - remote, - pull_request, - }, - ); - } - - commit_details -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/editor/src/highlight_matching_bracket.rs b/crates/editor/src/highlight_matching_bracket.rs index eaef28bed21bf480a32c3abd3440a6c41e42d5f1..3ead3e2a11348b0f262926bbfe4fb880f0dff663 100644 --- a/crates/editor/src/highlight_matching_bracket.rs +++ b/crates/editor/src/highlight_matching_bracket.rs @@ -7,6 +7,7 @@ use theme::ActiveTheme; enum MatchingBracketHighlight {} impl Editor { + #[ztracing::instrument(skip_all)] pub fn refresh_matching_bracket_highlights( &mut self, window: &Window, diff --git a/crates/editor/src/hover_links.rs b/crates/editor/src/hover_links.rs index 9d0261f00f8f7258023b092d4f55d40ac8abcf40..1c00acbfa9f1a69cbe01c45758db5a0cd4fee757 100644 --- a/crates/editor/src/hover_links.rs +++ b/crates/editor/src/hover_links.rs @@ -9,8 +9,10 @@ use language::{Bias, ToOffset}; use linkify::{LinkFinder, LinkKind}; use lsp::LanguageServerId; use project::{InlayId, LocationLink, Project, ResolvedPath}; +use regex::Regex; use settings::Settings; -use std::ops::Range; +use std::{ops::Range, sync::LazyLock}; +use text::OffsetRangeExt; use theme::ActiveTheme as _; use util::{ResultExt, TryFutureExt as _, maybe}; @@ -216,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 @@ -595,7 +597,8 @@ pub(crate) async fn find_file( let project = project?; let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()).ok()?; let scope = snapshot.language_scope_at(position); - let (range, candidate_file_path) = surrounding_filename(snapshot, position)?; + let (range, candidate_file_path) = surrounding_filename(&snapshot, position)?; + let candidate_len = candidate_file_path.len(); async fn check_path( candidate_file_path: &str, @@ -612,29 +615,66 @@ pub(crate) async fn find_file( .filter(|s| s.is_file()) } - if let Some(existing_path) = check_path(&candidate_file_path, &project, buffer, cx).await { - return Some((range, existing_path)); + let pattern_candidates = link_pattern_file_candidates(&candidate_file_path); + + for (pattern_candidate, pattern_range) in &pattern_candidates { + if let Some(existing_path) = check_path(&pattern_candidate, &project, buffer, cx).await { + let offset_range = range.to_offset(&snapshot); + let actual_start = offset_range.start + pattern_range.start; + let actual_end = offset_range.end - (candidate_len - pattern_range.end); + return Some(( + snapshot.anchor_before(actual_start)..snapshot.anchor_after(actual_end), + existing_path, + )); + } } - if let Some(scope) = scope { - for suffix in scope.path_suffixes() { - if candidate_file_path.ends_with(format!(".{suffix}").as_str()) { - continue; - } + for (pattern_candidate, pattern_range) in pattern_candidates { + for suffix in scope.path_suffixes() { + if pattern_candidate.ends_with(format!(".{suffix}").as_str()) { + continue; + } - let suffixed_candidate = format!("{candidate_file_path}.{suffix}"); - if let Some(existing_path) = check_path(&suffixed_candidate, &project, buffer, cx).await - { - return Some((range, existing_path)); + let suffixed_candidate = format!("{pattern_candidate}.{suffix}"); + if let Some(existing_path) = + check_path(&suffixed_candidate, &project, buffer, cx).await + { + let offset_range = range.to_offset(&snapshot); + let actual_start = offset_range.start + pattern_range.start; + let actual_end = offset_range.end - (candidate_len - pattern_range.end); + return Some(( + snapshot.anchor_before(actual_start)..snapshot.anchor_after(actual_end), + existing_path, + )); + } } } } - None } +// Tries to capture potentially inlined links, like those found in markdown, +// e.g. [LinkTitle](link_file.txt) +// Since files can have parens, we should always return the full string +// (literally, [LinkTitle](link_file.txt)) as a candidate. +fn link_pattern_file_candidates(candidate: &str) -> Vec<(String, Range)> { + static MD_LINK_REGEX: LazyLock = + LazyLock::new(|| Regex::new(r"\(([^)]*)\)").expect("Failed to create REGEX")); + + let candidate_len = candidate.len(); + + let mut candidates = vec![(candidate.to_string(), 0..candidate_len)]; + + if let Some(captures) = MD_LINK_REGEX.captures(candidate) { + if let Some(link) = captures.get(1) { + candidates.push((link.as_str().to_string(), link.range())); + } + } + candidates +} + fn surrounding_filename( - snapshot: language::BufferSnapshot, + snapshot: &language::BufferSnapshot, position: text::Anchor, ) -> Option<(Range, String)> { const LIMIT: usize = 2048; @@ -735,7 +775,7 @@ mod tests { test::editor_lsp_test_context::EditorLspTestContext, }; use futures::StreamExt; - use gpui::Modifiers; + use gpui::{Modifiers, MousePressureEvent, PressureStage}; use indoc::indoc; use lsp::request::{GotoDefinition, GotoTypeDefinition}; use multi_buffer::MultiBufferOffset; @@ -1316,6 +1356,58 @@ mod tests { assert_eq!(cx.opened_url(), Some("https://zed.dev/releases".into())); } + #[test] + fn test_link_pattern_file_candidates() { + let candidates: Vec = link_pattern_file_candidates("[LinkTitle](link_file.txt)") + .into_iter() + .map(|(c, _)| c) + .collect(); + assert_eq!( + candidates, + vec!["[LinkTitle](link_file.txt)", "link_file.txt",] + ); + // Link title with spaces in it + let candidates: Vec = link_pattern_file_candidates("LinkTitle](link_file.txt)") + .into_iter() + .map(|(c, _)| c) + .collect(); + assert_eq!( + candidates, + vec!["LinkTitle](link_file.txt)", "link_file.txt",] + ); + + // Link with spaces + let candidates: Vec = link_pattern_file_candidates("LinkTitle](link\\ _file.txt)") + .into_iter() + .map(|(c, _)| c) + .collect(); + + assert_eq!( + candidates, + vec!["LinkTitle](link\\ _file.txt)", "link\\ _file.txt",] + ); + // + // Square brackets not strictly necessary + let candidates: Vec = link_pattern_file_candidates("(link_file.txt)") + .into_iter() + .map(|(c, _)| c) + .collect(); + + assert_eq!(candidates, vec!["(link_file.txt)", "link_file.txt",]); + + // No nesting + let candidates: Vec = + link_pattern_file_candidates("LinkTitle](link_(link_file)file.txt)") + .into_iter() + .map(|(c, _)| c) + .collect(); + + assert_eq!( + candidates, + vec!["LinkTitle](link_(link_file)file.txt)", "link_(link_file",] + ) + } + #[gpui::test] async fn test_surrounding_filename(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); @@ -1374,7 +1466,7 @@ mod tests { (positions, snapshot) }); - let result = surrounding_filename(snapshot, position); + let result = surrounding_filename(&snapshot, position); if let Some(expected) = expected { assert!(result.is_some(), "Failed to find file path: {}", input); @@ -1706,4 +1798,77 @@ mod tests { cx.simulate_click(screen_coord, Modifiers::secondary_key()); cx.update_workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 1)); } + + #[gpui::test] + async fn test_pressure_links(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + hover_provider: Some(lsp::HoverProviderCapability::Simple(true)), + definition_provider: Some(lsp::OneOf::Left(true)), + ..Default::default() + }, + cx, + ) + .await; + + cx.set_state(indoc! {" + fn ˇtest() { do_work(); } + fn do_work() { test(); } + "}); + + // Position the mouse over a symbol that has a definition + let hover_point = cx.pixel_position(indoc! {" + fn test() { do_wˇork(); } + fn do_work() { test(); } + "}); + let symbol_range = cx.lsp_range(indoc! {" + fn test() { «do_work»(); } + fn do_work() { test(); } + "}); + let target_range = cx.lsp_range(indoc! {" + fn test() { do_work(); } + fn «do_work»() { test(); } + "}); + + let mut requests = + cx.set_request_handler::(move |url, _, _| async move { + Ok(Some(lsp::GotoDefinitionResponse::Link(vec![ + lsp::LocationLink { + origin_selection_range: Some(symbol_range), + target_uri: url.clone(), + target_range, + target_selection_range: target_range, + }, + ]))) + }); + + cx.simulate_mouse_move(hover_point, None, Modifiers::none()); + + // First simulate Normal pressure to set up the previous stage + cx.simulate_event(MousePressureEvent { + pressure: 0.5, + stage: PressureStage::Normal, + position: hover_point, + modifiers: Modifiers::none(), + }); + cx.background_executor.run_until_parked(); + + // Now simulate Force pressure to trigger the force click and go-to definition + cx.simulate_event(MousePressureEvent { + pressure: 1.0, + stage: PressureStage::Force, + position: hover_point, + modifiers: Modifiers::none(), + }); + requests.next().await; + cx.background_executor.run_until_parked(); + + // Assert that we navigated to the definition + cx.assert_editor_state(indoc! {" + fn test() { do_work(); } + fn «do_workˇ»() { test(); } + "}); + } } diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index 9ef54139d39ece6e9414d8fee3c7a75c9a89036d..64415005ec61b1ce942e4fbedaabc70919f5e61d 100644 --- a/crates/editor/src/hover_popover.rs +++ b/crates/editor/src/hover_popover.rs @@ -151,7 +151,7 @@ pub fn hover_at_inlay( false }) { - hide_hover(editor, cx); + return; } let hover_popover_delay = EditorSettings::get_global(cx).hover_popover_delay.0; @@ -623,7 +623,10 @@ pub fn hover_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { }); MarkdownStyle { base_text_style, - code_block: StyleRefinement::default().my(rems(1.)).font_buffer(cx), + code_block: StyleRefinement::default() + .my(rems(1.)) + .font_buffer(cx) + .font_features(buffer_font_features.clone()), inline_code: TextStyleRefinement { background_color: Some(cx.theme().colors().background), font_family: Some(buffer_font_family), @@ -653,6 +656,7 @@ pub fn hover_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { .text_base() .mt(rems(1.)) .mb_0(), + table_columns_min_size: true, ..Default::default() } } @@ -706,6 +710,7 @@ pub fn diagnostics_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { .font_weight(FontWeight::BOLD) .text_base() .mb_0(), + table_columns_min_size: true, ..Default::default() } } diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 3b9c17f80f10116f2302bab203966922cbf0bcb2..cfbb7c975c844f08d76a5568f1e02dfe3d7d74f1 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -842,7 +842,6 @@ impl Item for Editor { .map(|handle| handle.read(cx).base_buffer().unwrap_or(handle.clone())) .collect::>(); - // let mut buffers_to_save = let buffers_to_save = if self.buffer.read(cx).is_singleton() && !options.autosave { buffers } else { 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 e868b105fac8a8fa87601e2d5bc8578c94bd1940..7314991bd5e4842f395383888a87b4e2db7e0a0c 100644 --- a/crates/editor/src/mouse_context_menu.rs +++ b/crates/editor/src/mouse_context_menu.rs @@ -59,7 +59,7 @@ impl MouseContextMenu { x: editor.gutter_dimensions.width, y: Pixels::ZERO, }; - let source_position = editor.to_pixel_point(source, &editor_snapshot, window)?; + let source_position = editor.to_pixel_point(source, &editor_snapshot, window, cx)?; let menu_position = MenuPosition::PinnedToEditor { source, offset: position - (source_position + content_origin), @@ -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 @@ -280,7 +280,11 @@ pub fn deploy_context_menu( "Copy Permalink", Box::new(CopyPermalinkToLine), ) - .action_disabled_when(!has_git_repo, "File History", Box::new(git::FileHistory)); + .action_disabled_when( + !has_git_repo, + "View File History", + Box::new(git::FileHistory), + ); match focus { Some(focus) => builder.context(focus), None => builder, diff --git a/crates/editor/src/scroll.rs b/crates/editor/src/scroll.rs index a92735d18617057ddd10f049e5a22525827e1874..422be9a54e7cfcc40484e4093eeab6c94ce7d8ee 100644 --- a/crates/editor/src/scroll.rs +++ b/crates/editor/src/scroll.rs @@ -251,7 +251,11 @@ impl ScrollManager { Bias::Left, ) .to_point(map); - let top_anchor = map.buffer_snapshot().anchor_after(scroll_top_buffer_point); + // Anchor the scroll position to the *left* of the first visible buffer point. + // + // This prevents the viewport from shifting down when blocks (e.g. expanded diff hunk + // deletions) are inserted *above* the first buffer character in the file. + let top_anchor = map.buffer_snapshot().anchor_before(scroll_top_buffer_point); self.set_anchor( ScrollAnchor { diff --git a/crates/editor/src/selections_collection.rs b/crates/editor/src/selections_collection.rs index 6f744e11334fc32e7985ee77e25866ef0c6cfe4c..54bb7ceec1d035fbefb0c229c4e537e8277b67cd 100644 --- a/crates/editor/src/selections_collection.rs +++ b/crates/editor/src/selections_collection.rs @@ -136,7 +136,13 @@ impl SelectionsCollection { iter::from_fn(move || { if let Some(pending) = pending_opt.as_mut() { while let Some(next_selection) = disjoint.peek() { - if pending.start <= next_selection.end && pending.end >= next_selection.start { + if should_merge( + pending.start, + pending.end, + next_selection.start, + next_selection.end, + false, + ) { let next_selection = disjoint.next().unwrap(); if next_selection.start < pending.start { pending.start = next_selection.start; @@ -236,7 +242,13 @@ impl SelectionsCollection { iter::from_fn(move || { if let Some(pending) = pending_opt.as_mut() { while let Some(next_selection) = disjoint.peek() { - if pending.start <= next_selection.end && pending.end >= next_selection.start { + if should_merge( + pending.start, + pending.end, + next_selection.start, + next_selection.end, + false, + ) { let next_selection = disjoint.next().unwrap(); if next_selection.start < pending.start { pending.start = next_selection.start; @@ -666,10 +678,13 @@ impl<'snap, 'a> MutableSelectionsCollection<'snap, 'a> { }) .collect::>(); selections.sort_unstable_by_key(|s| s.start); - // Merge overlapping selections. + let mut i = 1; while i < selections.len() { - if selections[i].start <= selections[i - 1].end { + let prev = &selections[i - 1]; + let current = &selections[i]; + + if should_merge(prev.start, prev.end, current.start, current.end, true) { let removed = selections.remove(i); if removed.start < selections[i - 1].start { selections[i - 1].start = removed.start; @@ -1139,7 +1154,13 @@ fn coalesce_selections( iter::from_fn(move || { let mut selection = selections.next()?; while let Some(next_selection) = selections.peek() { - if selection.end >= next_selection.start { + if should_merge( + selection.start, + selection.end, + next_selection.start, + next_selection.end, + true, + ) { if selection.reversed == next_selection.reversed { selection.end = cmp::max(selection.end, next_selection.end); selections.next(); @@ -1161,3 +1182,35 @@ fn coalesce_selections( Some(selection) }) } + +/// Determines whether two selections should be merged into one. +/// +/// Two selections should be merged when: +/// 1. They overlap: the selections share at least one position +/// 2. They have the same start position: one contains or equals the other +/// 3. A cursor touches a selection boundary: a zero-width selection (cursor) at the +/// start or end of another selection should be absorbed into it +/// +/// Note: two selections that merely touch (one ends exactly where the other begins) +/// but don't share any positions remain separate, see: https://github.com/zed-industries/zed/issues/24748 +fn should_merge(a_start: T, a_end: T, b_start: T, b_end: T, sorted: bool) -> bool { + let is_overlapping = if sorted { + // When sorted, `a` starts before or at `b`, so overlap means `b` starts before `a` ends + b_start < a_end + } else { + a_start < b_end && b_start < a_end + }; + + // Selections starting at the same position should always merge (one contains the other) + let same_start = a_start == b_start; + + // A cursor (zero-width selection) touching another selection's boundary should merge. + // This handles cases like a cursor at position X merging with a selection that + // starts or ends at X. + let is_cursor_a = a_start == a_end; + let is_cursor_b = b_start == b_end; + let cursor_at_boundary = (is_cursor_a && (a_start == b_start || a_end == b_end)) + || (is_cursor_b && (b_start == a_start || b_end == a_end)); + + is_overlapping || same_start || cursor_at_boundary +} diff --git a/crates/editor/src/split.rs b/crates/editor/src/split.rs index 8a413a376f2296acdacddc97707a6112e8cd5185..b5090f06dc1e68d609413db31112775e56559689 100644 --- a/crates/editor/src/split.rs +++ b/crates/editor/src/split.rs @@ -194,7 +194,7 @@ impl SplittableEditor { }); let primary_pane = self.panes.first_pane(); self.panes - .split(&primary_pane, &secondary_pane, SplitDirection::Left) + .split(&primary_pane, &secondary_pane, SplitDirection::Left, cx) .unwrap(); cx.notify(); } @@ -203,7 +203,7 @@ impl SplittableEditor { let Some(secondary) = self.secondary.take() else { return; }; - self.panes.remove(&secondary.pane).unwrap(); + self.panes.remove(&secondary.pane, cx).unwrap(); self.primary_editor.update(cx, |primary, cx| { primary.buffer().update(cx, |buffer, _| { buffer.set_filter_mode(None); 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 cd45a6ec47ad7631404189194a6a0291a6240647..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 }); @@ -283,8 +283,7 @@ impl EditorTestContext { .head(); let pixel_position = editor.pixel_position_of_newest_cursor.unwrap(); let line_height = editor - .style() - .unwrap() + .style(cx) .text .line_height_in_pixels(window.rem_size()); let snapshot = editor.snapshot(window, cx); @@ -306,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 787d3372c8248a59e74fc67f347d5bf3b064890f..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, ); @@ -625,6 +626,15 @@ impl agent::TerminalHandle for EvalTerminalHandle { self.terminal .read_with(cx, |term, cx| term.current_output(cx)) } + + fn kill(&self, cx: &AsyncApp) -> Result<()> { + cx.update(|cx| { + self.terminal.update(cx, |terminal, cx| { + terminal.kill(cx); + }); + })?; + Ok(()) + } } impl agent::ThreadEnvironment for EvalThreadEnvironment { @@ -892,7 +902,7 @@ pub fn wait_for_lang_server( .update(cx, |buffer, cx| { lsp_store.update(cx, |lsp_store, cx| { lsp_store - .language_servers_for_local_buffer(buffer, cx) + .running_language_servers_for_local_buffer(buffer, cx) .next() .is_some() }) diff --git a/crates/eval_utils/LICENSE-GPL b/crates/eval_utils/LICENSE-GPL index e0f9dbd5d63fef1630c297edc4ceba4790be6f02..89e542f750cd3860a0598eff0dc34b56d7336dc4 120000 --- a/crates/eval_utils/LICENSE-GPL +++ b/crates/eval_utils/LICENSE-GPL @@ -1 +1 @@ -LICENSE-GPL \ No newline at end of file +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/eval_utils/src/eval_utils.rs b/crates/eval_utils/src/eval_utils.rs index 880b1a97e414bbc3219bdf8f7163dbf9b6c9c82b..be3294ed1490d6a602c3a5282d25dbba7d065443 100644 --- a/crates/eval_utils/src/eval_utils.rs +++ b/crates/eval_utils/src/eval_utils.rs @@ -40,6 +40,24 @@ pub struct EvalOutput { pub metadata: M, } +impl EvalOutput { + pub fn passed(message: impl Into) -> Self { + EvalOutput { + outcome: OutcomeKind::Passed, + data: message.into(), + metadata: M::default(), + } + } + + pub fn failed(message: impl Into) -> Self { + EvalOutput { + outcome: OutcomeKind::Failed, + data: message.into(), + metadata: M::default(), + } + } +} + pub struct NoProcessor; impl EvalOutputProcessor for NoProcessor { type Metadata = (); diff --git a/crates/extension_api/src/extension_api.rs b/crates/extension_api/src/extension_api.rs index 9418623224289f795fed061acbfc6035a4cc5cdf..acd1cba47b0150b85ddec8baafa8b5f341460a39 100644 --- a/crates/extension_api/src/extension_api.rs +++ b/crates/extension_api/src/extension_api.rs @@ -331,7 +331,6 @@ static mut EXTENSION: Option> = None; pub static ZED_API_VERSION: [u8; 6] = *include_bytes!(concat!(env!("OUT_DIR"), "/version_bytes")); mod wit { - wit_bindgen::generate!({ skip: ["init-extension"], path: "./wit/since_v0.8.0", @@ -524,6 +523,12 @@ impl wit::Guest for Component { #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)] pub struct LanguageServerId(String); +impl LanguageServerId { + pub fn new(value: String) -> Self { + Self(value) + } +} + impl AsRef for LanguageServerId { fn as_ref(&self) -> &str { &self.0 @@ -540,6 +545,12 @@ impl fmt::Display for LanguageServerId { #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)] pub struct ContextServerId(String); +impl ContextServerId { + pub fn new(value: String) -> Self { + Self(value) + } +} + impl AsRef for ContextServerId { fn as_ref(&self) -> &str { &self.0 diff --git a/crates/extension_host/src/wasm_host.rs b/crates/extension_host/src/wasm_host.rs index cecaf2039bc6dc049ece1177700a14eead3d86bc..a6e5768f16243ce6c6a4d250002e29d5db06a071 100644 --- a/crates/extension_host/src/wasm_host.rs +++ b/crates/extension_host/src/wasm_host.rs @@ -45,7 +45,7 @@ use wasmtime::{ CacheStore, Engine, Store, component::{Component, ResourceTable}, }; -use wasmtime_wasi::{self as wasi, WasiView}; +use wasmtime_wasi::p2::{self as wasi, IoView as _}; use wit::Extension; pub struct WasmHost { @@ -685,8 +685,8 @@ impl WasmHost { .await .context("failed to create extension work dir")?; - let file_perms = wasi::FilePerms::all(); - let dir_perms = wasi::DirPerms::all(); + let file_perms = wasmtime_wasi::FilePerms::all(); + let dir_perms = wasmtime_wasi::DirPerms::all(); let path = SanitizedPath::new(&extension_work_dir).to_string(); #[cfg(target_os = "windows")] let path = path.replace('\\', "/"); @@ -856,11 +856,13 @@ impl WasmState { } } -impl wasi::WasiView for WasmState { +impl wasi::IoView for WasmState { fn table(&mut self) -> &mut ResourceTable { &mut self.table } +} +impl wasi::WasiView for WasmState { fn ctx(&mut self) -> &mut wasi::WasiCtx { &mut self.ctx } diff --git a/crates/extension_host/src/wasm_host/wit.rs b/crates/extension_host/src/wasm_host/wit.rs index 5058c63365021a00dc9abf9fc05e9085757e161e..e080915b4fe1f18325843961db36e2fbc16bd418 100644 --- a/crates/extension_host/src/wasm_host/wit.rs +++ b/crates/extension_host/src/wasm_host/wit.rs @@ -45,7 +45,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 a2776f9f3b5b055d00787fb59c9bbca582352b1f..8b3f8e86b71e959eade1e5d3710ce66b5b2d3008 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_6_0::{ dap::{ - AttachRequest, BuildTaskDefinition, BuildTaskDefinitionTemplatePayload, LaunchRequest, - StartDebuggingRequestArguments, TcpArguments, TcpArgumentsTemplate, + BuildTaskDefinition, BuildTaskDefinitionTemplatePayload, StartDebuggingRequestArguments, + TcpArguments, TcpArgumentsTemplate, }, slash_command::SlashCommandOutputSection, }; @@ -736,6 +736,7 @@ impl nodejs::Host for WasmState { .node_runtime .npm_package_latest_version(&package_name) .await + .map(|v| v.to_string()) .to_wasmtime_result() } @@ -747,6 +748,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/feature_flags/src/flags.rs b/crates/feature_flags/src/flags.rs index 61d9a34e38de546c79a2dbb5f889e2fddad38480..1768e43d1d0a88433d61c6390f912377c2ba55e3 100644 --- a/crates/feature_flags/src/flags.rs +++ b/crates/feature_flags/src/flags.rs @@ -12,12 +12,14 @@ impl FeatureFlag for PanicFeatureFlag { const NAME: &'static str = "panic"; } -pub struct InlineAssistantV2FeatureFlag; +pub struct InlineAssistantUseToolFeatureFlag; -impl FeatureFlag for InlineAssistantV2FeatureFlag { - const NAME: &'static str = "inline-assistant-v2"; +impl FeatureFlag for InlineAssistantUseToolFeatureFlag { + const NAME: &'static str = "inline-assistant-use-tool"; +} + +pub struct AgentV2FeatureFlag; - fn enabled_for_staff() -> bool { - false - } +impl FeatureFlag for AgentV2FeatureFlag { + const NAME: &'static str = "agent-v2"; } 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/file_finder/src/open_path_prompt.rs b/crates/file_finder/src/open_path_prompt.rs index 2ae0c47776acb5c58b7d0919aa7522fb64d923d0..f75d0ee99dc32bc1a1ab812328bba3d36fcb2953 100644 --- a/crates/file_finder/src/open_path_prompt.rs +++ b/crates/file_finder/src/open_path_prompt.rs @@ -44,8 +44,9 @@ impl OpenPathDelegate { tx: oneshot::Sender>>, lister: DirectoryLister, creating_path: bool, - path_style: PathStyle, + cx: &App, ) -> Self { + let path_style = lister.path_style(cx); Self { tx: Some(tx), lister, @@ -216,8 +217,7 @@ impl OpenPathPrompt { cx: &mut Context, ) { workspace.toggle_modal(window, cx, |window, cx| { - let delegate = - OpenPathDelegate::new(tx, lister.clone(), creating_path, PathStyle::local()); + let delegate = OpenPathDelegate::new(tx, lister.clone(), creating_path, cx); let picker = Picker::uniform_list(delegate, window, cx).width(rems(34.)); let query = lister.default_query(cx); picker.set_query(query, window, cx); diff --git a/crates/file_finder/src/open_path_prompt_tests.rs b/crates/file_finder/src/open_path_prompt_tests.rs index dea188034bfa7ae46f5b17c50424b40331fadb75..9af18c8a6bd82b389d4d18a997c3b5fe4a088730 100644 --- a/crates/file_finder/src/open_path_prompt_tests.rs +++ b/crates/file_finder/src/open_path_prompt_tests.rs @@ -5,7 +5,7 @@ use picker::{Picker, PickerDelegate}; use project::Project; use serde_json::json; use ui::rems; -use util::{path, paths::PathStyle}; +use util::path; use workspace::{AppState, Workspace}; use crate::OpenPathDelegate; @@ -37,7 +37,7 @@ async fn test_open_path_prompt(cx: &mut TestAppContext) { let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - let (picker, cx) = build_open_path_prompt(project, false, PathStyle::local(), cx); + let (picker, cx) = build_open_path_prompt(project, false, cx); insert_query(path!("sadjaoislkdjasldj"), &picker, cx).await; assert_eq!(collect_match_candidates(&picker, cx), Vec::::new()); @@ -119,7 +119,7 @@ async fn test_open_path_prompt_completion(cx: &mut TestAppContext) { let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - let (picker, cx) = build_open_path_prompt(project, false, PathStyle::local(), cx); + let (picker, cx) = build_open_path_prompt(project, false, cx); // Confirm completion for the query "/root", since it's a directory, it should add a trailing slash. let query = path!("/root"); @@ -227,7 +227,7 @@ async fn test_open_path_prompt_on_windows(cx: &mut TestAppContext) { let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - let (picker, cx) = build_open_path_prompt(project, false, PathStyle::local(), cx); + let (picker, cx) = build_open_path_prompt(project, false, cx); // Support both forward and backward slashes. let query = "C:/root/"; @@ -295,56 +295,6 @@ async fn test_open_path_prompt_on_windows(cx: &mut TestAppContext) { ); } -#[gpui::test] -#[cfg_attr(not(target_os = "windows"), ignore)] -async fn test_open_path_prompt_on_windows_with_remote(cx: &mut TestAppContext) { - let app_state = init_test(cx); - app_state - .fs - .as_fake() - .insert_tree( - "/root", - json!({ - "a": "A", - "dir1": {}, - "dir2": {} - }), - ) - .await; - - let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await; - - let (picker, cx) = build_open_path_prompt(project, false, PathStyle::Posix, cx); - - let query = "/root/"; - insert_query(query, &picker, cx).await; - assert_eq!( - collect_match_candidates(&picker, cx), - vec!["./", "a", "dir1", "dir2"] - ); - assert_eq!( - confirm_completion(query, 1, &picker, cx).unwrap(), - "/root/a" - ); - - // Confirm completion for the query "/root/d", selecting the second candidate "dir2", since it's a directory, it should add a trailing slash. - let query = "/root/d"; - insert_query(query, &picker, cx).await; - assert_eq!(collect_match_candidates(&picker, cx), vec!["dir1", "dir2"]); - assert_eq!( - confirm_completion(query, 1, &picker, cx).unwrap(), - "/root/dir2/" - ); - - let query = "/root/d"; - insert_query(query, &picker, cx).await; - assert_eq!(collect_match_candidates(&picker, cx), vec!["dir1", "dir2"]); - assert_eq!( - confirm_completion(query, 0, &picker, cx).unwrap(), - "/root/dir1/" - ); -} - #[gpui::test] async fn test_new_path_prompt(cx: &mut TestAppContext) { let app_state = init_test(cx); @@ -372,7 +322,7 @@ async fn test_new_path_prompt(cx: &mut TestAppContext) { let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - let (picker, cx) = build_open_path_prompt(project, true, PathStyle::local(), cx); + let (picker, cx) = build_open_path_prompt(project, true, cx); insert_query(path!("/root"), &picker, cx).await; assert_eq!(collect_match_candidates(&picker, cx), vec!["root"]); @@ -406,16 +356,15 @@ fn init_test(cx: &mut TestAppContext) -> Arc { fn build_open_path_prompt( project: Entity, creating_path: bool, - path_style: PathStyle, cx: &mut TestAppContext, ) -> (Entity>, &mut VisualTestContext) { let (tx, _) = futures::channel::oneshot::channel(); let lister = project::DirectoryLister::Project(project.clone()); - let delegate = OpenPathDelegate::new(tx, lister.clone(), creating_path, path_style); let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); ( workspace.update_in(cx, |_, window, cx| { + let delegate = OpenPathDelegate::new(tx, lister.clone(), creating_path, cx); cx.new(|cx| { let picker = Picker::uniform_list(delegate, window, cx) .width(rems(34.)) diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index 6ca8b5a58f9f8f75023aa73e7a80e8547eb156f3..be9b84ff6acd5e13080148f15103b8a21111de7a 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -23,6 +23,7 @@ use std::{ path::PathBuf, sync::{Arc, LazyLock}, }; +use text::LineEnding; use util::{paths::PathStyle, rel_path::RelPath}; pub static LOAD_INDEX_TEXT_TASK: LazyLock = LazyLock::new(TaskLabel::new); @@ -200,6 +201,7 @@ impl GitRepository for FakeGitRepository { async { Ok(CommitDetails { sha: commit.into(), + message: "initial commit".into(), ..Default::default() }) } @@ -451,7 +453,12 @@ impl GitRepository for FakeGitRepository { }) } - fn blame(&self, path: RepoPath, _content: Rope) -> BoxFuture<'_, Result> { + fn blame( + &self, + path: RepoPath, + _content: Rope, + _line_ending: LineEnding, + ) -> BoxFuture<'_, Result> { self.with_state_async(false, move |state| { state .blames @@ -568,7 +575,7 @@ impl GitRepository for FakeGitRepository { _askpass: AskPassDelegate, _env: Arc>, ) -> BoxFuture<'_, Result<()>> { - unimplemented!() + async { Ok(()) }.boxed() } fn run_hook( @@ -576,7 +583,7 @@ impl GitRepository for FakeGitRepository { _hook: RunHook, _env: Arc>, ) -> BoxFuture<'_, Result<()>> { - unimplemented!() + async { Ok(()) }.boxed() } fn push( diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index 5be94ab6302b0a950b91e32dc43da374f0c62f29..2cbbf61a21e145464e9dbec01ace3b5510709d0d 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -434,7 +434,18 @@ impl RealFs { for component in path.components() { match component { std::path::Component::Prefix(_) => { - let canonicalized = std::fs::canonicalize(component)?; + let component = component.as_os_str(); + let canonicalized = if component + .to_str() + .map(|e| e.ends_with("\\")) + .unwrap_or(false) + { + std::fs::canonicalize(component) + } else { + let mut component = component.to_os_string(); + component.push("\\"); + std::fs::canonicalize(component) + }?; let mut strip = PathBuf::new(); for component in canonicalized.components() { @@ -641,6 +652,8 @@ impl Fs for RealFs { use objc::{class, msg_send, sel, sel_impl}; unsafe { + /// Allow NSString::alloc use here because it sets autorelease + #[allow(clippy::disallowed_methods)] unsafe fn ns_string(string: &str) -> id { unsafe { NSString::alloc(nil).init_str(string).autorelease() } } @@ -803,7 +816,7 @@ impl Fs for RealFs { } let file = smol::fs::File::create(path).await?; let mut writer = smol::io::BufWriter::with_capacity(buffer_size, file); - for chunk in chunks(text, line_ending) { + for chunk in text::chunks_with_line_ending(text, line_ending) { writer.write_all(chunk.as_bytes()).await?; } writer.flush().await?; @@ -2555,7 +2568,7 @@ impl Fs for FakeFs { async fn save(&self, path: &Path, text: &Rope, line_ending: LineEnding) -> Result<()> { self.simulate_random_delay().await; let path = normalize_path(path); - let content = chunks(text, line_ending).collect::(); + let content = text::chunks_with_line_ending(text, line_ending).collect::(); if let Some(path) = path.parent() { self.create_dir(path).await?; } @@ -2773,25 +2786,6 @@ impl Fs for FakeFs { } } -fn chunks(rope: &Rope, line_ending: LineEnding) -> impl Iterator { - rope.chunks().flat_map(move |chunk| { - let mut newline = false; - let end_with_newline = chunk.ends_with('\n').then_some(line_ending.as_str()); - chunk - .lines() - .flat_map(move |line| { - let ending = if newline { - Some(line_ending.as_str()) - } else { - None - }; - newline = true; - ending.into_iter().chain([line]) - }) - .chain(end_with_newline) - }) -} - pub fn normalize_path(path: &Path) -> PathBuf { let mut components = path.components().peekable(); let mut ret = if let Some(c @ Component::Prefix(..)) = components.peek().cloned() { @@ -3411,6 +3405,26 @@ mod tests { assert_eq!(content, "Hello"); } + #[gpui::test] + #[cfg(target_os = "windows")] + async fn test_realfs_canonicalize(executor: BackgroundExecutor) { + use util::paths::SanitizedPath; + + let fs = RealFs { + bundled_git_binary_path: None, + executor, + next_job_id: Arc::new(AtomicUsize::new(0)), + job_event_subscribers: Arc::new(Mutex::new(Vec::new())), + }; + let temp_dir = TempDir::new().unwrap(); + let file = temp_dir.path().join("test (1).txt"); + let file = SanitizedPath::new(&file); + std::fs::write(&file, "test").unwrap(); + + let canonicalized = fs.canonicalize(file.as_path()).await; + assert!(canonicalized.is_ok()); + } + #[gpui::test] async fn test_rename(executor: BackgroundExecutor) { let fs = FakeFs::new(executor.clone()); diff --git a/crates/git/src/blame.rs b/crates/git/src/blame.rs index 6325eacc8201d812d14dfdf4853f4004e22c263e..d6011de98b8c69837d16bf2a2211fc7632726230 100644 --- a/crates/git/src/blame.rs +++ b/crates/git/src/blame.rs @@ -1,14 +1,13 @@ +use crate::Oid; use crate::commit::get_messages; use crate::repository::RepoPath; -use crate::{GitRemote, Oid}; use anyhow::{Context as _, Result}; use collections::{HashMap, HashSet}; use futures::AsyncWriteExt; -use gpui::SharedString; use serde::{Deserialize, Serialize}; use std::process::Stdio; use std::{ops::Range, path::Path}; -use text::Rope; +use text::{LineEnding, Rope}; use time::OffsetDateTime; use time::UtcOffset; use time::macros::format_description; @@ -21,22 +20,16 @@ pub struct Blame { pub messages: HashMap, } -#[derive(Clone, Debug, Default)] -pub struct ParsedCommitMessage { - pub message: SharedString, - pub permalink: Option, - pub pull_request: Option, - pub remote: Option, -} - impl Blame { pub async fn for_path( git_binary: &Path, working_directory: &Path, path: &RepoPath, content: &Rope, + line_ending: LineEnding, ) -> Result { - let output = run_git_blame(git_binary, working_directory, path, content).await?; + let output = + run_git_blame(git_binary, working_directory, path, content, line_ending).await?; let mut entries = parse_git_blame(&output)?; entries.sort_unstable_by(|a, b| a.range.start.cmp(&b.range.start)); @@ -63,12 +56,12 @@ async fn run_git_blame( working_directory: &Path, path: &RepoPath, contents: &Rope, + line_ending: LineEnding, ) -> Result { let mut child = util::command::new_smol_command(git_binary) .current_dir(working_directory) .arg("blame") .arg("--incremental") - .arg("-w") .arg("--contents") .arg("-") .arg(path.as_unix_str()) @@ -83,7 +76,7 @@ async fn run_git_blame( .as_mut() .context("failed to get pipe to stdin of git blame command")?; - for chunk in contents.chunks() { + for chunk in text::chunks_with_line_ending(contents, line_ending) { stdin.write_all(chunk.as_bytes()).await?; } stdin.flush().await?; diff --git a/crates/git/src/commit.rs b/crates/git/src/commit.rs index ece1d76b8ae9c9f40f27178da1ef13fe1a78e659..1b450a3dffb9e9956e5b43aa2797ae02f90e731c 100644 --- a/crates/git/src/commit.rs +++ b/crates/git/src/commit.rs @@ -1,7 +1,52 @@ -use crate::{Oid, status::StatusCode}; +use crate::{ + BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, Oid, parse_git_remote_url, + status::StatusCode, +}; use anyhow::{Context as _, Result}; use collections::HashMap; -use std::path::Path; +use gpui::SharedString; +use std::{path::Path, sync::Arc}; + +#[derive(Clone, Debug, Default)] +pub struct ParsedCommitMessage { + pub message: SharedString, + pub permalink: Option, + pub pull_request: Option, + pub remote: Option, +} + +impl ParsedCommitMessage { + pub fn parse( + sha: String, + message: String, + remote_url: Option<&str>, + provider_registry: Option>, + ) -> Self { + if let Some((hosting_provider, remote)) = provider_registry + .and_then(|reg| remote_url.and_then(|url| parse_git_remote_url(reg, url))) + { + let pull_request = hosting_provider.extract_pull_request(&remote, &message); + Self { + message: message.into(), + permalink: Some( + hosting_provider + .build_commit_permalink(&remote, BuildCommitPermalinkParams { sha: &sha }), + ), + pull_request, + remote: Some(GitRemote { + host: hosting_provider, + owner: remote.owner.into(), + repo: remote.repo.into(), + }), + } + } else { + Self { + message: message.into(), + ..Default::default() + } + } + } +} pub async fn get_messages(working_directory: &Path, shas: &[Oid]) -> Result> { if shas.is_empty() { diff --git a/crates/git/src/git.rs b/crates/git/src/git.rs index 197ce4d6fb3bb3a41dd0be67e542d91d87561736..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, @@ -232,14 +233,12 @@ impl From for usize { #[derive(Copy, Clone, Debug)] pub enum RunHook { PreCommit, - PrePush, } impl RunHook { pub fn as_str(&self) -> &str { match self { Self::PreCommit => "pre-commit", - Self::PrePush => "pre-push", } } @@ -250,7 +249,6 @@ impl RunHook { pub fn from_proto(value: i32) -> Option { match value { 0 => Some(Self::PreCommit), - 1 => Some(Self::PrePush), _ => None, } } diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 70cbf6e3c58b7d8f6b690a554370d34262f541e3..c3dd0995ff83d4bfdd494e4b5c192ff5999c21f8 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -14,6 +14,7 @@ use rope::Rope; use schemars::JsonSchema; use serde::Deserialize; use smol::io::{AsyncBufReadExt, AsyncReadExt, BufReader}; +use text::LineEnding; use std::collections::HashSet; use std::ffi::{OsStr, OsString}; @@ -487,7 +488,12 @@ pub trait GitRepository: Send + Sync { fn show(&self, commit: String) -> BoxFuture<'_, Result>; fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result>; - fn blame(&self, path: RepoPath, content: Rope) -> BoxFuture<'_, Result>; + fn blame( + &self, + path: RepoPath, + content: Rope, + line_ending: LineEnding, + ) -> BoxFuture<'_, Result>; fn file_history(&self, path: RepoPath) -> BoxFuture<'_, Result>; fn file_history_paginated( &self, @@ -652,6 +658,7 @@ pub struct RealGitRepository { pub repository: Arc>, pub system_git_binary_path: Option, pub any_git_binary_path: PathBuf, + any_git_binary_help_output: Arc>>, executor: BackgroundExecutor, } @@ -670,6 +677,7 @@ impl RealGitRepository { system_git_binary_path, any_git_binary_path, executor, + any_git_binary_help_output: Arc::new(Mutex::new(None)), }) } @@ -680,6 +688,27 @@ impl RealGitRepository { .context("failed to read git work directory") .map(Path::to_path_buf) } + + async fn any_git_binary_help_output(&self) -> SharedString { + if let Some(output) = self.any_git_binary_help_output.lock().clone() { + return output; + } + let git_binary_path = self.any_git_binary_path.clone(); + let executor = self.executor.clone(); + let working_directory = self.working_directory(); + let output: SharedString = self + .executor + .spawn(async move { + GitBinary::new(git_binary_path, working_directory?, executor) + .run(["help", "-a"]) + .await + }) + .await + .unwrap_or_default() + .into(); + *self.any_git_binary_help_output.lock() = Some(output.clone()); + output + } } #[derive(Clone, Debug)] @@ -1489,7 +1518,12 @@ impl GitRepository for RealGitRepository { .boxed() } - fn blame(&self, path: RepoPath, content: Rope) -> BoxFuture<'_, Result> { + fn blame( + &self, + path: RepoPath, + content: Rope, + line_ending: LineEnding, + ) -> BoxFuture<'_, Result> { let working_directory = self.working_directory(); let git_binary_path = self.any_git_binary_path.clone(); let executor = self.executor.clone(); @@ -1501,6 +1535,7 @@ impl GitRepository for RealGitRepository { &working_directory?, &path, &content, + line_ending, ) .await }) @@ -2290,18 +2325,47 @@ impl GitRepository for RealGitRepository { env: Arc>, ) -> BoxFuture<'_, Result<()>> { let working_directory = self.working_directory(); + let repository = self.repository.clone(); let git_binary_path = self.any_git_binary_path.clone(); let executor = self.executor.clone(); - self.executor - .spawn(async move { - let working_directory = working_directory?; - let git = GitBinary::new(git_binary_path, working_directory, executor) - .envs(HashMap::clone(&env)); - git.run(&["hook", "run", "--ignore-missing", hook.as_str()]) - .await?; - Ok(()) - }) - .boxed() + let help_output = self.any_git_binary_help_output(); + + // Note: Do not spawn these commands on the background thread, as this causes some git hooks to hang. + async move { + let working_directory = working_directory?; + if !help_output + .await + .lines() + .any(|line| line.trim().starts_with("hook ")) + { + let hook_abs_path = repository.lock().path().join("hooks").join(hook.as_str()); + if hook_abs_path.is_file() { + let output = new_smol_command(&hook_abs_path) + .envs(env.iter()) + .current_dir(&working_directory) + .output() + .await?; + + if !output.status.success() { + return Err(GitBinaryCommandError { + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + status: output.status, + } + .into()); + } + } + + return Ok(()); + } + + let git = GitBinary::new(git_binary_path, working_directory, executor) + .envs(HashMap::clone(&env)); + git.run(&["hook", "run", "--ignore-missing", hook.as_str()]) + .await?; + Ok(()) + } + .boxed() } } diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml index beaf192b0ef538fb524ff4986710255040b89f27..c88244a036767be0ef862e74faa2113d54125443 100644 --- a/crates/git_ui/Cargo.toml +++ b/crates/git_ui/Cargo.toml @@ -43,6 +43,7 @@ notifications.workspace = true panel.workspace = true picker.workspace = true project.workspace = true +prompt_store.workspace = true recent_projects.workspace = true remote.workspace = true schemars.workspace = true @@ -74,6 +75,7 @@ gpui = { workspace = true, features = ["test-support"] } indoc.workspace = true pretty_assertions.workspace = true project = { workspace = true, features = ["test-support"] } +rand.workspace = true settings = { workspace = true, features = ["test-support"] } unindent.workspace = true workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/git_ui/src/blame_ui.rs b/crates/git_ui/src/blame_ui.rs index fe9af196021e01edd406c9ced86e0465b7e70984..d4d8750a18ee6efbd90a38722043450c6ec61358 100644 --- a/crates/git_ui/src/blame_ui.rs +++ b/crates/git_ui/src/blame_ui.rs @@ -3,10 +3,7 @@ use crate::{ commit_view::CommitView, }; use editor::{BlameRenderer, Editor, hover_markdown_style}; -use git::{ - blame::{BlameEntry, ParsedCommitMessage}, - repository::CommitSummary, -}; +use git::{blame::BlameEntry, commit::ParsedCommitMessage, repository::CommitSummary}; use gpui::{ ClipboardItem, Entity, Hsla, MouseButton, ScrollHandle, Subscription, TextStyle, TextStyleRefinement, UnderlineStyle, WeakEntity, prelude::*, @@ -47,11 +44,13 @@ impl BlameRenderer for GitBlameRenderer { let name = util::truncate_and_trailoff(author_name, GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED); let avatar = if ProjectSettings::get_global(cx).git.blame.show_avatar { - CommitAvatar::new( - &blame_entry.sha.to_string().into(), - details.as_ref().and_then(|it| it.remote.as_ref()), + Some( + CommitAvatar::new( + &blame_entry.sha.to_string().into(), + details.as_ref().and_then(|it| it.remote.as_ref()), + ) + .render(window, cx), ) - .render(window, cx) } else { None }; @@ -65,7 +64,7 @@ impl BlameRenderer for GitBlameRenderer { .w_full() .gap_2() .justify_between() - .font_family(style.font().family) + .font(style.font()) .line_height(style.line_height) .text_color(cx.theme().status().hint) .child( @@ -264,7 +263,7 @@ impl BlameRenderer for GitBlameRenderer { .flex_wrap() .border_b_1() .border_color(cx.theme().colors().border_variant) - .children(avatar) + .child(avatar) .child(author) .when(!author_email.is_empty(), |this| { this.child( diff --git a/crates/git_ui/src/branch_picker.rs b/crates/git_ui/src/branch_picker.rs index 90b5c4bb284112c8a13ad406da2b7424e982298a..4db37e91b8720e51ff0416cc471842483ab1d0ca 100644 --- a/crates/git_ui/src/branch_picker.rs +++ b/crates/git_ui/src/branch_picker.rs @@ -6,7 +6,7 @@ use collections::HashSet; use git::repository::Branch; use gpui::http_client::Url; use gpui::{ - Action, App, AsyncApp, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, + Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, actions, rems, }; @@ -17,8 +17,8 @@ use settings::Settings; use std::sync::Arc; use time::OffsetDateTime; use ui::{ - CommonAnimationExt, Divider, HighlightedLabel, KeyBinding, ListHeader, ListItem, - ListItemSpacing, Tooltip, prelude::*, + Divider, HighlightedLabel, KeyBinding, ListHeader, ListItem, ListItemSpacing, Tooltip, + prelude::*, }; use util::ResultExt; use workspace::notifications::DetachAndPromptErr; @@ -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, @@ -232,21 +226,12 @@ impl BranchList { window: &mut Window, cx: &mut Context, ) { - self.picker.update(cx, |this, cx| { - this.delegate.display_remotes = !this.delegate.display_remotes; - cx.spawn_in(window, async move |this, cx| { - this.update_in(cx, |picker, window, cx| { - let last_query = picker.delegate.last_query.clone(); - picker.delegate.update_matches(last_query, window, cx) - })? - .await; - - Result::Ok::<_, anyhow::Error>(()) - }) - .detach_and_log_err(cx); + self.picker.update(cx, |picker, cx| { + picker.delegate.branch_filter = picker.delegate.branch_filter.invert(); + picker.update_matches(picker.query(cx), window, cx); + picker.refresh_placeholder(window, cx); + cx.notify(); }); - - cx.notify(); } } impl ModalView for BranchList {} @@ -289,6 +274,10 @@ enum Entry { NewBranch { name: String, }, + NewRemoteName { + name: String, + url: SharedString, + }, } impl Entry { @@ -304,6 +293,7 @@ impl Entry { Entry::Branch { branch, .. } => branch.name(), Entry::NewUrl { url, .. } => url.as_str(), Entry::NewBranch { name, .. } => name.as_str(), + Entry::NewRemoteName { name, .. } => name.as_str(), } } @@ -318,8 +308,25 @@ impl Entry { } } +#[derive(Clone, Copy, PartialEq)] +enum BranchFilter { + /// Show both local and remote branches. + All, + /// Only show remote branches. + Remote, +} + +impl BranchFilter { + fn invert(&self) -> Self { + match self { + BranchFilter::All => BranchFilter::Remote, + BranchFilter::Remote => BranchFilter::All, + } + } +} + pub struct BranchListDelegate { - workspace: Option>, + workspace: WeakEntity, matches: Vec, all_branches: Option>, default_branch: Option, @@ -328,9 +335,8 @@ pub struct BranchListDelegate { selected_index: usize, last_query: String, modifiers: Modifiers, - display_remotes: bool, + branch_filter: BranchFilter, state: PickerState, - loading: bool, focus_handle: FocusHandle, } @@ -348,7 +354,7 @@ enum PickerState { impl BranchListDelegate { fn new( - workspace: Option>, + workspace: WeakEntity, repo: Option>, style: BranchListStyle, cx: &mut Context, @@ -363,9 +369,8 @@ impl BranchListDelegate { selected_index: 0, last_query: Default::default(), modifiers: Default::default(), - display_remotes: false, + branch_filter: BranchFilter::All, state: PickerState::List, - loading: false, focus_handle: cx.focus_handle(), } } @@ -406,37 +411,13 @@ impl BranchListDelegate { let Some(repo) = self.repo.clone() else { return; }; - cx.spawn(async move |this, cx| { - this.update(cx, |picker, cx| { - picker.delegate.loading = true; - cx.notify(); - }) - .log_err(); - let stop_loader = |this: &WeakEntity>, cx: &mut AsyncApp| { - this.update(cx, |picker, cx| { - picker.delegate.loading = false; - cx.notify(); - }) - .log_err(); - }; - repo.update(cx, |repo, _| repo.create_remote(remote_name, remote_url)) - .inspect_err(|_err| { - stop_loader(&this, cx); - })? - .await - .inspect_err(|_err| { - stop_loader(&this, cx); - })? - .inspect_err(|_err| { - stop_loader(&this, cx); - })?; - stop_loader(&this, cx); - Ok(()) - }) - .detach_and_prompt_err("Failed to create remote", window, cx, |e, _, _cx| { - Some(e.to_string()) - }); + let receiver = repo.update(cx, |repo, _| repo.create_remote(remote_name, remote_url)); + + cx.background_spawn(async move { receiver.await? }) + .detach_and_prompt_err("Failed to create remote", window, cx, |e, _, _cx| { + Some(e.to_string()) + }); cx.emit(DismissEvent); } @@ -477,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( @@ -528,29 +509,33 @@ impl PickerDelegate for BranchListDelegate { type ListItem = ListItem; fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { - "Select branch…".into() + match self.state { + PickerState::List | PickerState::NewRemote | PickerState::NewBranch => { + match self.branch_filter { + BranchFilter::All => "Select branch or remote…", + BranchFilter::Remote => "Select remote…", + } + } + PickerState::CreateRemote(_) => "Enter a name for this remote…", + } + .into() + } + + fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option { + match self.state { + PickerState::CreateRemote(_) => { + Some(SharedString::new_static("Remote name can't be empty")) + } + _ => None, + } } fn render_editor( &self, editor: &Entity, - window: &mut Window, - cx: &mut Context>, + _window: &mut Window, + _cx: &mut Context>, ) -> Div { - cx.update_entity(editor, move |editor, cx| { - let placeholder = match self.state { - PickerState::List | PickerState::NewRemote | PickerState::NewBranch => { - if self.display_remotes { - "Select remote…" - } else { - "Select branch…" - } - } - PickerState::CreateRemote(_) => "Choose a name…", - }; - editor.set_placeholder_text(placeholder, window, cx); - }); - let focus_handle = self.focus_handle.clone(); v_flex() @@ -568,16 +553,14 @@ impl PickerDelegate for BranchListDelegate { .when( self.editor_position() == PickerEditorPosition::End, |this| { - let tooltip_label = if self.display_remotes { - "Turn Off Remote Filter" - } else { - "Filter Remote Branches" + let tooltip_label = match self.branch_filter { + BranchFilter::All => "Filter Remote Branches", + BranchFilter::Remote => "Show All Branches", }; this.gap_1().justify_between().child({ IconButton::new("filter-remotes", IconName::Filter) - .disabled(self.loading) - .toggle_state(self.display_remotes) + .toggle_state(self.branch_filter == BranchFilter::Remote) .tooltip(move |_, cx| { Tooltip::for_action_in( tooltip_label, @@ -636,42 +619,38 @@ impl PickerDelegate for BranchListDelegate { return Task::ready(()); }; - const RECENT_BRANCHES_COUNT: usize = 10; - let display_remotes = self.display_remotes; + 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 { - branch.is_remote() - } else { - !branch.is_remote() - } - }) - .take(RECENT_BRANCHES_COUNT) + .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 { - 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, @@ -686,15 +665,28 @@ 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, _| { - if matches!(picker.delegate.state, PickerState::CreateRemote(_)) { + if let PickerState::CreateRemote(url) = &picker.delegate.state { + let query = query.replace(' ', "-"); + if !query.is_empty() { + picker.delegate.matches = vec![Entry::NewRemoteName { + name: query.clone(), + url: url.clone(), + }]; + picker.delegate.selected_index = 0; + } else { + picker.delegate.matches = Vec::new(); + picker.delegate.selected_index = 0; + } picker.delegate.last_query = query; - picker.delegate.matches = Vec::new(); - picker.delegate.selected_index = 0; - return; } @@ -738,13 +730,6 @@ impl PickerDelegate for BranchListDelegate { } fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context>) { - if let PickerState::CreateRemote(remote_url) = &self.state { - self.create_remote(self.last_query.clone(), remote_url.to_string(), window, cx); - self.state = PickerState::List; - cx.notify(); - return; - } - let Some(entry) = self.matches.get(self.selected_index()) else { return; }; @@ -787,13 +772,19 @@ impl PickerDelegate for BranchListDelegate { self.state = PickerState::CreateRemote(url.clone().into()); self.matches = Vec::new(); self.selected_index = 0; - cx.spawn_in(window, async move |this, cx| { - this.update_in(cx, |picker, window, cx| { - picker.set_query("", window, cx); - }) - }) - .detach_and_log_err(cx); - cx.notify(); + + cx.defer_in(window, |picker, window, cx| { + picker.refresh_placeholder(window, cx); + picker.set_query("", window, cx); + cx.notify(); + }); + + // returning early to prevent dismissing the modal, so a user can enter + // a remote name first. + return; + } + Entry::NewRemoteName { name, url } => { + self.create_remote(name.clone(), url.to_string(), window, cx); } Entry::NewBranch { name } => { let from_branch = if secondary { @@ -844,12 +835,11 @@ impl PickerDelegate for BranchListDelegate { .unwrap_or_else(|| (None, None, None)); let entry_icon = match entry { - Entry::NewUrl { .. } | Entry::NewBranch { .. } => { + Entry::NewUrl { .. } | Entry::NewBranch { .. } | Entry::NewRemoteName { .. } => { Icon::new(IconName::Plus).color(Color::Muted) } - - Entry::Branch { .. } => { - if self.display_remotes { + Entry::Branch { branch, .. } => { + if branch.is_remote() { Icon::new(IconName::Screen).color(Color::Muted) } else { Icon::new(IconName::GitBranchAlt).color(Color::Muted) @@ -866,6 +856,10 @@ impl PickerDelegate for BranchListDelegate { .single_line() .truncate() .into_any_element(), + Entry::NewRemoteName { name, .. } => Label::new(format!("Create Remote: \"{name}\"")) + .single_line() + .truncate() + .into_any_element(), Entry::Branch { branch, positions } => { HighlightedLabel::new(branch.name().to_string(), positions.clone()) .single_line() @@ -875,21 +869,26 @@ impl PickerDelegate for BranchListDelegate { }; let focus_handle = self.focus_handle.clone(); - let is_new_items = matches!(entry, Entry::NewUrl { .. } | Entry::NewBranch { .. }); - - 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 is_new_items = matches!( + entry, + Entry::NewUrl { .. } | Entry::NewBranch { .. } | Entry::NewRemoteName { .. } + ); + + 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(); @@ -937,6 +936,9 @@ impl PickerDelegate for BranchListDelegate { Entry::NewUrl { url } => { format!("Based off {url}") } + Entry::NewRemoteName { url, .. } => { + format!("Based off {url}") + } Entry::NewBranch { .. } => { if let Some(current_branch) = self.repo.as_ref().and_then(|repo| { @@ -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)) } }) }, @@ -1035,10 +1039,9 @@ impl PickerDelegate for BranchListDelegate { _cx: &mut Context>, ) -> Option { matches!(self.state, PickerState::List).then(|| { - let label = if self.display_remotes { - "Remote" - } else { - "Local" + let label = match self.branch_filter { + BranchFilter::All => "Branches", + BranchFilter::Remote => "Remotes", }; ListHeader::new(label).inset(true).into_any_element() @@ -1049,11 +1052,7 @@ impl PickerDelegate for BranchListDelegate { if self.editor_position() == PickerEditorPosition::End { return None; } - let focus_handle = self.focus_handle.clone(); - let loading_icon = Icon::new(IconName::LoadCircle) - .size(IconSize::Small) - .with_rotate_animation(3); let footer_container = || { h_flex() @@ -1092,7 +1091,6 @@ impl PickerDelegate for BranchListDelegate { .gap_1() .child( Button::new("delete-branch", "Delete") - .disabled(self.loading) .key_binding( KeyBinding::for_action_in( &branch_picker::DeleteBranch, @@ -1140,17 +1138,15 @@ impl PickerDelegate for BranchListDelegate { ) }, ) - } else if self.loading { - this.justify_between() - .child(loading_icon) - .child(delete_and_select_btns) } else { this.justify_between() .child({ let focus_handle = focus_handle.clone(); Button::new("filter-remotes", "Filter Remotes") - .disabled(self.loading) - .toggle_state(self.display_remotes) + .toggle_state(matches!( + self.branch_filter, + BranchFilter::Remote + )) .key_binding( KeyBinding::for_action_in( &branch_picker::FilterRemotes, @@ -1215,14 +1211,15 @@ impl PickerDelegate for BranchListDelegate { footer_container() .justify_end() .child( - Label::new("Choose a name for this remote repository") - .size(LabelSize::Small) - .color(Color::Muted), - ) - .child( - Label::new("Save") - .size(LabelSize::Small) - .color(Color::Muted), + Button::new("branch-from-default", "Confirm") + .key_binding( + KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(cx.listener(|this, _, window, cx| { + this.delegate.confirm(false, window, cx); + })) + .disabled(self.last_query.is_empty()), ) .into_any_element(), ), @@ -1237,8 +1234,9 @@ 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; use settings::SettingsStore; use util::path; @@ -1285,37 +1283,49 @@ mod tests { ] } - fn init_branch_list_test( - cx: &mut TestAppContext, + async fn init_branch_list_test( repository: Option>, branches: Vec, - ) -> (VisualTestContext, Entity) { - 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(); - }); + cx: &mut TestAppContext, + ) -> (Entity, VisualTestContext) { + 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 _subscription = cx.subscribe(&picker, |_, _, _, cx| { + cx.emit(DismissEvent); + }); + + BranchList { + picker, + picker_focus_handle, + width: rems(34.), + _subscription, + } + }) + }) + .unwrap(); - let branch_list = window.root(cx).unwrap(); - let cx = VisualTestContext::from_window(*window, cx); + let cx = VisualTestContext::from_window(*workspace, cx); - (cx, branch_list) + (branch_list, cx) } async fn init_fake_repository(cx: &mut TestAppContext) -> Entity { @@ -1349,7 +1359,7 @@ mod tests { init_test(cx); let branches = create_test_branches(); - let (mut ctx, branch_list) = init_branch_list_test(cx, None, branches); + let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await; let cx = &mut ctx; branch_list @@ -1425,7 +1435,7 @@ mod tests { .await; cx.run_until_parked(); - let (mut ctx, branch_list) = init_branch_list_test(cx, repository.into(), branches); + 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; @@ -1490,12 +1500,12 @@ mod tests { .await; cx.run_until_parked(); - let (mut ctx, branch_list) = init_branch_list_test(cx, repository.into(), branches); + 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| { branch_list.picker.update(cx, |picker, _cx| { - picker.delegate.display_remotes = true; + picker.delegate.branch_filter = BranchFilter::Remote; }); }); update_branch_list_matches_with_empty_query(&branch_list, cx).await; @@ -1538,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![ @@ -1548,39 +1558,54 @@ mod tests { create_test_branch("develop", false, None, Some(700)), ]; - let (mut ctx, branch_list) = init_branch_list_test(cx, None, branches); + 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.display_remotes = true; - 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| { @@ -1602,7 +1627,7 @@ mod tests { // Verify the last entry is NOT the "create new branch" option let last_match = picker.delegate.matches.last().unwrap(); assert!(!last_match.is_new_url()); - picker.delegate.display_remotes = true; + picker.delegate.branch_filter = BranchFilter::Remote; picker .delegate .update_matches(String::from("fork"), window, cx) @@ -1631,22 +1656,28 @@ mod tests { #[gpui::test] async fn test_new_branch_creation_with_query(test_cx: &mut TestAppContext) { + const MAIN_BRANCH: &str = "main"; + const FEATURE_BRANCH: &str = "feature"; + const NEW_BRANCH: &str = "new-feature-branch"; + init_test(test_cx); let repository = init_fake_repository(test_cx).await; let branches = vec![ - create_test_branch("main", true, None, Some(1000)), - create_test_branch("feature", false, None, Some(900)), + create_test_branch(MAIN_BRANCH, true, None, Some(1000)), + create_test_branch(FEATURE_BRANCH, false, None, Some(900)), ]; - let (mut ctx, branch_list) = init_branch_list_test(test_cx, repository.into(), branches); + let (branch_list, mut ctx) = + init_branch_list_test(repository.into(), branches, test_cx).await; let cx = &mut ctx; branch_list .update_in(cx, |branch_list, window, cx| { branch_list.picker.update(cx, |picker, cx| { - let query = "new-feature-branch".to_string(); - picker.delegate.update_matches(query, window, cx) + picker + .delegate + .update_matches(NEW_BRANCH.to_string(), window, cx) }) }) .await; @@ -1657,7 +1688,7 @@ mod tests { branch_list.picker.update(cx, |picker, cx| { let last_match = picker.delegate.matches.last().unwrap(); assert!(last_match.is_new_branch()); - assert_eq!(last_match.name(), "new-feature-branch"); + assert_eq!(last_match.name(), NEW_BRANCH); // State is NewBranch because no existing branches fuzzy-match the query assert!(matches!(picker.delegate.state, PickerState::NewBranch)); picker.delegate.confirm(false, window, cx); @@ -1682,11 +1713,11 @@ mod tests { let new_branch = branches .into_iter() - .find(|branch| branch.name() == "new-feature-branch") + .find(|branch| branch.name() == NEW_BRANCH) .expect("new-feature-branch should exist"); assert_eq!( new_branch.ref_name.as_ref(), - "refs/heads/new-feature-branch", + &format!("refs/heads/{NEW_BRANCH}"), "branch ref_name should not have duplicate refs/heads/ prefix" ); } @@ -1697,7 +1728,7 @@ mod tests { let repository = init_fake_repository(cx).await; let branches = vec![create_test_branch("main", true, None, Some(1000))]; - let (mut ctx, branch_list) = init_branch_list_test(cx, repository.into(), branches); + let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await; let cx = &mut ctx; branch_list @@ -1736,8 +1767,13 @@ mod tests { branch_list.update_in(cx, |branch_list, window, cx| { branch_list.picker.update(cx, |picker, cx| { + assert_eq!(picker.delegate.matches.len(), 1); + assert!(matches!( + picker.delegate.matches.first(), + Some(Entry::NewRemoteName { name, url }) + if name == "my_new_remote" && url.as_ref() == "https://github.com/user/repo.git" + )); picker.delegate.confirm(false, window, cx); - assert_eq!(picker.delegate.matches.len(), 0); }) }); cx.run_until_parked(); @@ -1770,7 +1806,7 @@ mod tests { init_test(cx); let branches = vec![create_test_branch("main_branch", true, None, Some(1000))]; - let (mut ctx, branch_list) = init_branch_list_test(cx, None, branches); + let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await; let cx = &mut ctx; branch_list @@ -1825,4 +1861,87 @@ mod tests { }) }); } + + #[gpui::test] + async fn test_confirm_remote_url_does_not_dismiss(cx: &mut TestAppContext) { + const REMOTE_URL: &str = "https://github.com/user/repo.git"; + + 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).await; + let cx = &mut ctx; + + let subscription = cx.update(|_, cx| { + cx.subscribe(&branch_list, |_, _: &DismissEvent, _| { + panic!("DismissEvent should not be emitted when confirming a remote URL"); + }) + }); + + branch_list + .update_in(cx, |branch_list, window, cx| { + 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 + .update_matches(REMOTE_URL.to_string(), window, cx) + }) + }) + .await; + + 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()); + assert!(matches!(picker.delegate.state, PickerState::NewRemote)); + + picker.delegate.confirm(false, window, cx); + + assert!( + matches!(picker.delegate.state, PickerState::CreateRemote(ref url) if url.as_ref() == REMOTE_URL), + "State should transition to CreateRemote with the URL" + ); + }); + + assert!( + branch_list.picker_focus_handle.is_focused(window), + "Branch list picker should still be focused after confirming remote URL" + ); + }); + + cx.run_until_parked(); + + drop(subscription); + } + + #[gpui::test(iterations = 10)] + async fn test_empty_query_displays_all_branches(mut rng: StdRng, cx: &mut TestAppContext) { + init_test(cx); + let branch_count = rng.random_range(13..540); + + let branches: Vec = (0..branch_count) + .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).await; + let cx = &mut ctx; + + update_branch_list_matches_with_empty_query(&branch_list, cx).await; + + branch_list.update(cx, |branch_list, cx| { + branch_list.picker.update(cx, |picker, _cx| { + assert_eq!(picker.delegate.matches.len(), branch_count as usize); + }) + }); + } } diff --git a/crates/git_ui/src/commit_modal.rs b/crates/git_ui/src/commit_modal.rs index 45b1563dca0ceed5ed2ac488026fe94084050780..e154933adc794221159c7f1b28b3d1e33cf1854d 100644 --- a/crates/git_ui/src/commit_modal.rs +++ b/crates/git_ui/src/commit_modal.rs @@ -139,7 +139,7 @@ impl CommitModal { && !git_panel.amend_pending() { git_panel.set_amend_pending(true, cx); - git_panel.load_last_commit_message_if_empty(cx); + git_panel.load_last_commit_message(cx); } } ForceMode::Commit => { @@ -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, @@ -492,60 +501,27 @@ impl CommitModal { } } - fn commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context) { - if self.git_panel.read(cx).amend_pending() { - return; + fn on_commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context) { + if self.git_panel.update(cx, |git_panel, cx| { + git_panel.commit(&self.commit_editor.focus_handle(cx), window, cx) + }) { + telemetry::event!("Git Committed", source = "Git Modal"); + cx.emit(DismissEvent); } - telemetry::event!("Git Committed", source = "Git Modal"); - self.git_panel.update(cx, |git_panel, cx| { - git_panel.commit_changes( - CommitOptions { - amend: false, - signoff: git_panel.signoff_enabled(), - }, - window, - cx, - ) - }); - cx.emit(DismissEvent); } - fn amend(&mut self, _: &git::Amend, window: &mut Window, cx: &mut Context) { - if self - .git_panel - .read(cx) - .active_repository - .as_ref() - .and_then(|repo| repo.read(cx).head_commit.as_ref()) - .is_none() - { - return; - } - if !self.git_panel.read(cx).amend_pending() { - self.git_panel.update(cx, |git_panel, cx| { - git_panel.set_amend_pending(true, cx); - git_panel.load_last_commit_message_if_empty(cx); - }); - } else { + fn on_amend(&mut self, _: &git::Amend, window: &mut Window, cx: &mut Context) { + if self.git_panel.update(cx, |git_panel, cx| { + git_panel.amend(&self.commit_editor.focus_handle(cx), window, cx) + }) { telemetry::event!("Git Amended", source = "Git Modal"); - self.git_panel.update(cx, |git_panel, cx| { - git_panel.set_amend_pending(false, cx); - git_panel.commit_changes( - CommitOptions { - amend: true, - signoff: git_panel.signoff_enabled(), - }, - window, - cx, - ); - }); cx.emit(DismissEvent); } } 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); } @@ -564,8 +540,8 @@ impl Render for CommitModal { .id("commit-modal") .key_context("GitCommit") .on_action(cx.listener(Self::dismiss)) - .on_action(cx.listener(Self::commit)) - .on_action(cx.listener(Self::amend)) + .on_action(cx.listener(Self::on_commit)) + .on_action(cx.listener(Self::on_amend)) .when(!DisableAiSettings::get_global(cx).disable_ai, |this| { this.on_action(cx.listener(|this, _: &GenerateCommitMessage, _, cx| { this.git_panel.update(cx, |panel, cx| { @@ -611,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_tooltip.rs b/crates/git_ui/src/commit_tooltip.rs index 6dfe92427df5b9fd5aa051aeb1635b2e782ad3a4..d18770a704ff31d6dffd705baf44defaaf6d8d4a 100644 --- a/crates/git_ui/src/commit_tooltip.rs +++ b/crates/git_ui/src/commit_tooltip.rs @@ -3,7 +3,7 @@ use editor::hover_markdown_style; use futures::Future; use git::blame::BlameEntry; use git::repository::CommitSummary; -use git::{GitRemote, blame::ParsedCommitMessage}; +use git::{GitRemote, commit::ParsedCommitMessage}; use gpui::{ App, Asset, ClipboardItem, Element, Entity, MouseButton, ParentElement, Render, ScrollHandle, StatefulInteractiveElement, WeakEntity, prelude::*, @@ -29,11 +29,16 @@ pub struct CommitDetails { pub struct CommitAvatar<'a> { sha: &'a SharedString, remote: Option<&'a GitRemote>, + size: Option, } impl<'a> CommitAvatar<'a> { pub fn new(sha: &'a SharedString, remote: Option<&'a GitRemote>) -> Self { - Self { sha, remote } + Self { + sha, + remote, + size: None, + } } pub fn from_commit_details(details: &'a CommitDetails) -> Self { @@ -43,28 +48,37 @@ impl<'a> CommitAvatar<'a> { .message .as_ref() .and_then(|details| details.remote.as_ref()), + size: None, } } -} -impl<'a> CommitAvatar<'a> { - pub fn render(&'a self, window: &mut Window, cx: &mut App) -> Option> { + pub fn size(mut self, size: IconSize) -> Self { + self.size = Some(size); + self + } + + pub fn render(&'a self, window: &mut Window, cx: &mut App) -> AnyElement { + match self.avatar(window, cx) { + // Loading or no avatar found + None => Icon::new(IconName::Person) + .color(Color::Muted) + .when_some(self.size, |this, size| this.size(size)) + .into_any_element(), + // Found + Some(avatar) => avatar + .when_some(self.size, |this, size| this.size(size.rems())) + .into_any_element(), + } + } + + pub fn avatar(&'a self, window: &mut Window, cx: &mut App) -> Option { let remote = self .remote .filter(|remote| remote.host_supports_avatars())?; - let avatar_url = CommitAvatarAsset::new(remote.clone(), self.sha.clone()); - let element = match window.use_asset::(&avatar_url, cx) { - // Loading or no avatar found - None | Some(None) => Icon::new(IconName::Person) - .color(Color::Muted) - .into_element() - .into_any(), - // Found - Some(Some(url)) => Avatar::new(url.to_string()).into_element().into_any(), - }; - Some(element) + let url = window.use_asset::(&avatar_url, cx)??; + Some(Avatar::new(url.to_string())) } } @@ -253,7 +267,7 @@ impl Render for CommitTooltip { .gap_x_2() .overflow_x_hidden() .flex_wrap() - .children(avatar) + .child(avatar) .child(author) .when(!author_email.is_empty(), |this| { this.child( diff --git a/crates/git_ui/src/commit_view.rs b/crates/git_ui/src/commit_view.rs index 238b0cbf52fdb4312178b868be4b22986ea946c3..0f5420fec4169f8e3d945dd8bd0987ebbaba8d19 100644 --- a/crates/git_ui/src/commit_view.rs +++ b/crates/git_ui/src/commit_view.rs @@ -1,19 +1,20 @@ use anyhow::{Context as _, Result}; use buffer_diff::{BufferDiff, BufferDiffSnapshot}; use editor::display_map::{BlockPlacement, BlockProperties, BlockStyle}; -use editor::{ - Editor, EditorEvent, ExcerptId, ExcerptRange, MultiBuffer, multibuffer_context_lines, -}; +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 _, Asset, AsyncApp, AsyncWindowContext, Context, Element, - Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, + AnyElement, App, AppContext as _, AsyncApp, AsyncWindowContext, Context, Element, Entity, + EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, PromptLevel, Render, Styled, Task, WeakEntity, Window, actions, }; use language::{ Anchor, Buffer, Capability, DiskState, File, LanguageRegistry, LineEnding, OffsetRangeExt as _, - ReplicaId, Rope, TextBuffer, + Point, ReplicaId, Rope, TextBuffer, }; use multi_buffer::PathKey; use project::{Project, WorktreeId, git_store::Repository}; @@ -23,7 +24,7 @@ use std::{ sync::Arc, }; use theme::ActiveTheme; -use ui::{Avatar, DiffStat, Tooltip, prelude::*}; +use ui::{DiffStat, Tooltip, prelude::*}; use util::{ResultExt, paths::PathStyle, rel_path::RelPath, truncate_and_trailoff}; use workspace::item::TabTooltipContent; use workspace::{ @@ -35,6 +36,7 @@ use workspace::{ searchable::SearchableItemHandle, }; +use crate::commit_tooltip::CommitAvatar; use crate::git_panel::GitPanel; actions!(git, [ApplyCurrentStash, PopCurrentStash, DropCurrentStash,]); @@ -70,6 +72,7 @@ struct GitBlob { display_name: Arc, } +const COMMIT_MESSAGE_SORT_PREFIX: u64 = 0; const FILE_NAMESPACE_SORT_PREFIX: u64 = 1; impl CommitView { @@ -147,6 +150,32 @@ impl CommitView { ) -> Self { let language_registry = project.read(cx).languages().clone(); let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadOnly)); + + let message_buffer = cx.new(|cx| { + let mut buffer = Buffer::local(commit.message.clone(), cx); + buffer.set_capability(Capability::ReadOnly, cx); + buffer + }); + + multibuffer.update(cx, |multibuffer, cx| { + let snapshot = message_buffer.read(cx).snapshot(); + let full_range = Point::zero()..snapshot.max_point(); + let range = ExcerptRange { + context: full_range.clone(), + primary: full_range, + }; + multibuffer.set_excerpt_ranges_for_path( + PathKey::with_sort_prefix( + COMMIT_MESSAGE_SORT_PREFIX, + RelPath::unix("commit message").unwrap().into(), + ), + message_buffer.clone(), + &snapshot, + vec![range], + cx, + ) + }); + let editor = cx.new(|cx| { let mut editor = Editor::for_multibuffer(multibuffer.clone(), Some(project.clone()), window, cx); @@ -154,9 +183,38 @@ impl CommitView { editor.disable_inline_diagnostics(); editor.set_show_breakpoints(false, cx); editor.set_expand_all_diff_hunks(cx); + editor.disable_header_for_buffer(message_buffer.read(cx).remote_id(), cx); + editor.disable_indent_guides_for_buffer(message_buffer.read(cx).remote_id(), cx); + + editor.insert_blocks( + [BlockProperties { + placement: BlockPlacement::Above(editor::Anchor::min()), + height: Some(1), + style: BlockStyle::Sticky, + render: Arc::new(|_| gpui::Empty.into_any_element()), + priority: 0, + }] + .into_iter() + .chain( + editor + .buffer() + .read(cx) + .buffer_anchor_to_anchor(&message_buffer, Anchor::MAX, cx) + .map(|anchor| BlockProperties { + placement: BlockPlacement::Below(anchor), + height: Some(1), + style: BlockStyle::Sticky, + render: Arc::new(|_| gpui::Empty.into_any_element()), + priority: 0, + }), + ), + None, + cx, + ); editor }); + let commit_sha = Arc::::from(commit.sha.as_ref()); let first_worktree_id = project @@ -166,7 +224,6 @@ impl CommitView { .map(|worktree| worktree.read(cx).id()); let repository_clone = repository.clone(); - let commit_message = commit.message.clone(); cx.spawn(async move |this, cx| { for file in commit_diff.files { @@ -228,59 +285,6 @@ impl CommitView { })?; } - let message_buffer = cx.new(|cx| { - let mut buffer = Buffer::local(commit_message, cx); - buffer.set_capability(Capability::ReadOnly, cx); - buffer - })?; - - this.update(cx, |this, cx| { - this.multibuffer.update(cx, |multibuffer, cx| { - let range = ExcerptRange { - context: Anchor::MIN..Anchor::MAX, - primary: Anchor::MIN..Anchor::MAX, - }; - multibuffer.insert_excerpts_after( - ExcerptId::min(), - message_buffer.clone(), - [range], - cx, - ) - }); - - this.editor.update(cx, |editor, cx| { - editor.disable_header_for_buffer(message_buffer.read(cx).remote_id(), cx); - editor - .disable_indent_guides_for_buffer(message_buffer.read(cx).remote_id(), cx); - - editor.insert_blocks( - [BlockProperties { - placement: BlockPlacement::Above(editor::Anchor::min()), - height: Some(1), - style: BlockStyle::Sticky, - render: Arc::new(|_| gpui::Empty.into_any_element()), - priority: 0, - }] - .into_iter() - .chain( - editor - .buffer() - .read(cx) - .buffer_anchor_to_anchor(&message_buffer, Anchor::MAX, cx) - .map(|anchor| BlockProperties { - placement: BlockPlacement::Below(anchor), - height: Some(1), - style: BlockStyle::Sticky, - render: Arc::new(|_| gpui::Empty.into_any_element()), - priority: 0, - }), - ), - None, - cx, - ) - }); - })?; - anyhow::Ok(()) }) .detach(); @@ -318,17 +322,7 @@ impl CommitView { cx: &mut App, ) -> AnyElement { let size = size.into(); - let remote = self.remote.as_ref().filter(|r| r.host_supports_avatars()); - - if let Some(remote) = remote { - let avatar_asset = CommitAvatarAsset::new(remote.clone(), sha.clone()); - if let Some(Some(url)) = window.use_asset::(&avatar_asset, cx) { - return Avatar::new(url.to_string()) - .size(size) - .into_element() - .into_any(); - } - } + let avatar = CommitAvatar::new(sha, self.remote.as_ref()); v_flex() .w(size) @@ -339,10 +333,15 @@ impl CommitView { .justify_center() .items_center() .child( - Icon::new(IconName::Person) - .color(Color::Muted) - .size(IconSize::Medium) - .into_element(), + avatar + .avatar(window, cx) + .map(|a| a.size(size).into_any_element()) + .unwrap_or_else(|| { + Icon::new(IconName::Person) + .color(Color::Muted) + .size(IconSize::Medium) + .into_any_element() + }), ) .into_any() } @@ -395,14 +394,18 @@ impl CommitView { time_format::TimestampFormat::MediumAbsolute, ); - let github_url = self.remote.as_ref().map(|remote| { - format!( - "{}/{}/{}/commit/{}", - remote.host.base_url(), - remote.owner, - remote.repo, - commit.sha - ) + let remote_info = self.remote.as_ref().map(|remote| { + let provider = remote.host.name(); + 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) }); let (additions, deletions) = self.calculate_changed_lines(cx); @@ -417,12 +420,23 @@ impl CommitView { None }; + let gutter_width = self.editor.update(cx, |editor, cx| { + let snapshot = editor.snapshot(window, cx); + let style = editor.style(cx); + let font_id = window.text_system().resolve_font(&style.text.font()); + let font_size = style.text.font_size.to_pixels(window.rem_size()); + snapshot + .gutter_dimensions(font_id, font_size, style, window, cx) + .full_width() + }); + h_flex() .border_b_1() .border_color(cx.theme().colors().border_variant) + .w_full() .child( h_flex() - .w(self.editor.read(cx).last_gutter_dimensions().full_width()) + .w(gutter_width) .justify_center() .child(self.render_commit_avatar(&commit.sha, rems_from_px(48.), window, cx)), ) @@ -465,9 +479,14 @@ impl CommitView { .children(commit_diff_stat), ), ) - .children(github_url.map(|url| { - Button::new("view_on_github", "View on GitHub") - .icon(IconName::Github) + .children(remote_info.map(|(provider_name, url)| { + let icon = match provider_name.as_str() { + "GitHub" => IconName::Github, + _ => IconName::Link, + }; + + Button::new("view_on_provider", format!("View on {}", provider_name)) + .icon(icon) .icon_color(Color::Muted) .icon_size(IconSize::Small) .icon_position(IconPosition::Start) @@ -636,54 +655,6 @@ impl CommitView { } } -#[derive(Clone, Debug)] -struct CommitAvatarAsset { - sha: SharedString, - remote: GitRemote, -} - -impl std::hash::Hash for CommitAvatarAsset { - fn hash(&self, state: &mut H) { - self.sha.hash(state); - self.remote.host.name().hash(state); - } -} - -impl CommitAvatarAsset { - fn new(remote: GitRemote, sha: SharedString) -> Self { - Self { remote, sha } - } -} - -impl Asset for CommitAvatarAsset { - type Source = Self; - type Output = Option; - - fn load( - source: Self::Source, - cx: &mut App, - ) -> impl Future + Send + 'static { - let client = cx.http_client(); - async move { - match source - .remote - .host - .commit_author_avatar_url( - &source.remote.owner, - &source.remote.repo, - source.sha.clone(), - client, - ) - .await - { - Ok(Some(url)) => Some(SharedString::from(url.to_string())), - Ok(None) => None, - Err(_) => None, - } - } - } -} - impl language::File for GitBlob { fn as_local(&self) -> Option<&dyn language::LocalFile> { None @@ -1011,7 +982,9 @@ impl Render for CommitView { .size_full() .bg(cx.theme().colors().editor_background) .child(self.render_header(window, cx)) - .child(div().flex_grow().child(self.editor.clone())) + .when(!self.editor.read(cx).is_empty(cx), |this| { + this.child(div().flex_grow().child(self.editor.clone())) + }) } } diff --git a/crates/git_ui/src/conflict_view.rs b/crates/git_ui/src/conflict_view.rs index 2f954bfe1045d9819c1f7c276346a6f811c09108..813e63ab8c96e736cf0cc126526a683b418c2137 100644 --- a/crates/git_ui/src/conflict_view.rs +++ b/crates/git_ui/src/conflict_view.rs @@ -111,6 +111,7 @@ fn excerpt_for_buffer_updated( ); } +#[ztracing::instrument(skip_all)] fn buffer_added(editor: &mut Editor, buffer: Entity, cx: &mut Context) { let Some(project) = editor.project() else { return; @@ -166,6 +167,7 @@ fn buffers_removed(editor: &mut Editor, removed_buffer_ids: &[BufferId], cx: &mu editor.remove_blocks(removed_block_ids, None, cx); } +#[ztracing::instrument(skip_all)] fn conflicts_updated( editor: &mut Editor, conflict_set: Entity, @@ -311,6 +313,7 @@ fn conflicts_updated( } } +#[ztracing::instrument(skip_all)] fn update_conflict_highlighting( editor: &mut Editor, conflict: &ConflictRegion, diff --git a/crates/git_ui/src/file_history_view.rs b/crates/git_ui/src/file_history_view.rs index 5b3588d29678ec406749ec45be3de154fd71c5f8..f48160719ba5d9b00b8961b75e9ea402c80dd06a 100644 --- a/crates/git_ui/src/file_history_view.rs +++ b/crates/git_ui/src/file_history_view.rs @@ -4,7 +4,8 @@ use git::repository::{FileHistory, FileHistoryEntry, RepoPath}; use git::{GitHostingProviderRegistry, GitRemote, parse_git_remote_url}; use gpui::{ AnyElement, AnyEntity, App, Asset, Context, Entity, EventEmitter, FocusHandle, Focusable, - IntoElement, Render, Task, UniformListScrollHandle, WeakEntity, Window, actions, uniform_list, + IntoElement, Render, ScrollStrategy, Task, UniformListScrollHandle, WeakEntity, Window, + actions, uniform_list, }; use project::{ Project, ProjectPath, @@ -191,6 +192,93 @@ impl FileHistoryView { task.detach(); } + fn select_next(&mut self, _: &menu::SelectNext, _: &mut Window, cx: &mut Context) { + let entry_count = self.history.entries.len(); + let ix = match self.selected_entry { + _ if entry_count == 0 => None, + None => Some(0), + Some(ix) => { + if ix == entry_count - 1 { + Some(0) + } else { + Some(ix + 1) + } + } + }; + self.select_ix(ix, cx); + } + + fn select_previous( + &mut self, + _: &menu::SelectPrevious, + _: &mut Window, + cx: &mut Context, + ) { + let entry_count = self.history.entries.len(); + let ix = match self.selected_entry { + _ if entry_count == 0 => None, + None => Some(entry_count - 1), + Some(ix) => { + if ix == 0 { + Some(entry_count - 1) + } else { + Some(ix - 1) + } + } + }; + self.select_ix(ix, cx); + } + + fn select_first(&mut self, _: &menu::SelectFirst, _: &mut Window, cx: &mut Context) { + let entry_count = self.history.entries.len(); + let ix = if entry_count != 0 { Some(0) } else { None }; + self.select_ix(ix, cx); + } + + fn select_last(&mut self, _: &menu::SelectLast, _: &mut Window, cx: &mut Context) { + let entry_count = self.history.entries.len(); + let ix = if entry_count != 0 { + Some(entry_count - 1) + } else { + None + }; + self.select_ix(ix, cx); + } + + fn select_ix(&mut self, ix: Option, cx: &mut Context) { + self.selected_entry = ix; + if let Some(ix) = ix { + self.scroll_handle.scroll_to_item(ix, ScrollStrategy::Top); + } + cx.notify(); + } + + fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { + self.open_commit_view(window, cx); + } + + fn open_commit_view(&mut self, window: &mut Window, cx: &mut Context) { + let Some(entry) = self + .selected_entry + .and_then(|ix| self.history.entries.get(ix)) + else { + return; + }; + + if let Some(repo) = self.repository.upgrade() { + let sha_str = entry.sha.to_string(); + CommitView::open( + sha_str, + repo.downgrade(), + self.workspace.clone(), + None, + Some(self.history.path.clone()), + window, + cx, + ); + } + } + fn render_commit_avatar( &self, sha: &SharedString, @@ -245,12 +333,8 @@ impl FileHistoryView { time_format::TimestampFormat::Relative, ); - let sha = entry.sha.clone(); - let repo = self.repository.clone(); - let workspace = self.workspace.clone(); - let file_path = self.history.path.clone(); - ListItem::new(("commit", ix)) + .toggle_state(Some(ix) == self.selected_entry) .child( h_flex() .h_8() @@ -301,18 +385,7 @@ impl FileHistoryView { this.selected_entry = Some(ix); cx.notify(); - if let Some(repo) = repo.upgrade() { - let sha_str = sha.to_string(); - CommitView::open( - sha_str, - repo.downgrade(), - workspace.clone(), - None, - Some(file_path.clone()), - window, - cx, - ); - } + this.open_commit_view(window, cx); })) .into_any_element() } @@ -380,6 +453,14 @@ impl Render for FileHistoryView { let entry_count = self.history.entries.len(); v_flex() + .id("file_history_view") + .key_context("FileHistoryView") + .track_focus(&self.focus_handle) + .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::select_previous)) + .on_action(cx.listener(Self::select_first)) + .on_action(cx.listener(Self::select_last)) + .on_action(cx.listener(Self::confirm)) .size_full() .bg(cx.theme().colors().editor_background) .child( @@ -552,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 a58fe058195a5e93a585e3d8fd51399e6ea4a569..d91db250e2350ca57f74233d88b43197edd196d1 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -13,13 +13,15 @@ use agent_settings::AgentSettings; use anyhow::Context as _; use askpass::AskPassDelegate; use cloud_llm_client::CompletionIntent; +use collections::{BTreeMap, HashMap, HashSet}; use db::kvp::KEY_VALUE_STORE; +use editor::RewrapOptions; use editor::{ Direction, Editor, EditorElement, EditorMode, MultiBuffer, MultiBufferOffset, actions::ExpandAllDiffHunks, }; use futures::StreamExt as _; -use git::blame::ParsedCommitMessage; +use git::commit::ParsedCommitMessage; use git::repository::{ Branch, CommitDetails, CommitOptions, CommitSummary, DiffType, FetchOptions, GitCommitter, PushOptions, Remote, RemoteCommandOutput, ResetMode, Upstream, UpstreamTracking, @@ -29,21 +31,22 @@ use git::stash::GitStash; use git::status::StageStatus; use git::{Amend, Signoff, ToggleStaged, repository::RepoPath, status::FileStatus}; use git::{ - ExpandCommitEditor, RestoreTrackedFiles, StageAll, StashAll, StashApply, StashPop, - TrashUntrackedFiles, UnstageAll, + ExpandCommitEditor, GitHostingProviderRegistry, RestoreTrackedFiles, StageAll, StashAll, + StashApply, StashPop, TrashUntrackedFiles, UnstageAll, }; use gpui::{ - Action, AsyncApp, AsyncWindowContext, ClickEvent, Corner, DismissEvent, Entity, EventEmitter, - FocusHandle, Focusable, KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior, - MouseButton, MouseDownEvent, Point, PromptLevel, ScrollStrategy, Subscription, Task, - UniformListScrollHandle, WeakEntity, actions, anchored, deferred, uniform_list, + Action, AsyncApp, AsyncWindowContext, Bounds, ClickEvent, Corner, DismissEvent, Entity, + EventEmitter, FocusHandle, Focusable, KeyContext, MouseButton, MouseDownEvent, Point, + PromptLevel, ScrollStrategy, Subscription, Task, UniformListScrollHandle, WeakEntity, actions, + anchored, deferred, point, size, uniform_list, }; 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 menu; use multi_buffer::ExcerptInfo; use notifications::status_toast::{StatusToast, ToastIcon}; use panel::{ @@ -55,20 +58,22 @@ use project::{ git_store::{GitStoreEvent, Repository, RepositoryEvent, RepositoryId, pending_op}, project_settings::{GitPathStyle, ProjectSettings}, }; +use prompt_store::{BuiltInPrompt, PromptId, PromptStore, RULES_FILE_NAMES}; use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsStore, StatusStyle}; use std::future::Future; use std::ops::Range; use std::path::Path; -use std::{collections::HashSet, sync::Arc, time::Duration, usize}; +use std::{sync::Arc, time::Duration, usize}; use strum::{IntoEnumIterator, VariantNames}; use time::OffsetDateTime; use ui::{ - ButtonLike, Checkbox, CommonAnimationExt, ContextMenu, ElevationIndex, PopoverMenu, ScrollAxes, - Scrollbars, SplitButton, Tooltip, WithScrollbar, prelude::*, + ButtonLike, Checkbox, CommonAnimationExt, ContextMenu, ElevationIndex, IndentGuideColors, + PopoverMenu, RenderedIndentGuide, ScrollAxes, Scrollbars, SplitButton, Tooltip, WithScrollbar, + prelude::*, }; use util::paths::PathStyle; -use util::{ResultExt, TryFutureExt, maybe}; +use util::{ResultExt, TryFutureExt, maybe, rel_path::RelPath}; use workspace::SERIALIZATION_THROTTLE_TIME; use workspace::{ Workspace, @@ -88,10 +93,24 @@ actions!( FocusEditor, /// Focuses on the changes list. FocusChanges, + /// Select next git panel menu item, and show it in the diff view + NextEntry, + /// Select previous git panel menu item, and show it in the diff view + PreviousEntry, + /// Select first git panel menu item, and show it in the diff view + FirstEntry, + /// Select last git panel menu item, and show it in the diff view + LastEntry, /// Toggles automatic co-author suggestions. ToggleFillCoAuthors, /// Toggles sorting entries by path vs status. 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, ] ); @@ -122,6 +141,7 @@ struct GitMenuState { has_new_changes: bool, sort_by_path: bool, has_stash_items: bool, + tree_view: bool, } fn git_panel_context_menu( @@ -166,20 +186,33 @@ fn git_panel_context_menu( ) .separator() .entry( - if state.sort_by_path { - "Sort by Status" + if state.tree_view { + "Flat View" } else { - "Sort by Path" + "Tree View" }, - Some(Box::new(ToggleSortByPath)), - move |window, cx| window.dispatch_action(Box::new(ToggleSortByPath), cx), + Some(Box::new(ToggleTreeView)), + move |window, cx| window.dispatch_action(Box::new(ToggleTreeView), cx), ) + .when(!state.tree_view, |this| { + this.entry( + if state.sort_by_path { + "Sort by Status" + } else { + "Sort by Path" + }, + Some(Box::new(ToggleSortByPath)), + move |window, cx| window.dispatch_action(Box::new(ToggleSortByPath), cx), + ) + }) }) } const GIT_PANEL_KEY: &str = "GitPanel"; const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50); +// TODO: We should revise this part. It seems the indentation width is not aligned with the one in project panel +const TREE_INDENT: f32 = 16.0; pub fn register(workspace: &mut Workspace) { workspace.register_action(|workspace, _: &ToggleFocus, window, cx| { @@ -204,7 +237,7 @@ struct SerializedGitPanel { signoff_enabled: bool, } -#[derive(Debug, PartialEq, Eq, Clone, Copy)] +#[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)] enum Section { Conflict, Tracked, @@ -240,6 +273,8 @@ impl GitHeaderEntry { #[derive(Debug, PartialEq, Eq, Clone)] enum GitListEntry { Status(GitStatusEntry), + TreeStatus(GitTreeStatusEntry), + Directory(GitTreeDirEntry), Header(GitHeaderEntry), } @@ -247,11 +282,215 @@ impl GitListEntry { fn status_entry(&self) -> Option<&GitStatusEntry> { match self { GitListEntry::Status(entry) => Some(entry), + GitListEntry::TreeStatus(entry) => Some(&entry.entry), + _ => None, + } + } + + fn directory_entry(&self) -> Option<&GitTreeDirEntry> { + match self { + GitListEntry::Directory(entry) => Some(entry), _ => None, } } } +enum GitPanelViewMode { + Flat, + Tree(TreeViewState), +} + +impl GitPanelViewMode { + fn from_settings(cx: &App) -> Self { + if GitPanelSettings::get_global(cx).tree_view { + GitPanelViewMode::Tree(TreeViewState::default()) + } else { + GitPanelViewMode::Flat + } + } + + fn tree_state(&self) -> Option<&TreeViewState> { + match self { + GitPanelViewMode::Tree(state) => Some(state), + GitPanelViewMode::Flat => None, + } + } + + fn tree_state_mut(&mut self) -> Option<&mut TreeViewState> { + match self { + GitPanelViewMode::Tree(state) => Some(state), + GitPanelViewMode::Flat => None, + } + } +} + +#[derive(Default)] +struct TreeViewState { + // Maps visible index to actual entry index. + // Length equals the number of visible entries. + // This is needed because some entries (like collapsed directories) may be hidden. + logical_indices: Vec, + expanded_dirs: HashMap, + directory_descendants: HashMap>, +} + +impl TreeViewState { + fn build_tree_entries( + &mut self, + section: Section, + mut entries: Vec, + seen_directories: &mut HashSet, + ) -> Vec<(GitListEntry, bool)> { + if entries.is_empty() { + return Vec::new(); + } + + entries.sort_by(|a, b| a.repo_path.cmp(&b.repo_path)); + + let mut root = TreeNode::default(); + for entry in entries { + let components: Vec<&str> = entry.repo_path.components().collect(); + if components.is_empty() { + root.files.push(entry); + continue; + } + + let mut current = &mut root; + let mut current_path = String::new(); + + for (ix, component) in components.iter().enumerate() { + if ix == components.len() - 1 { + current.files.push(entry.clone()); + } else { + if !current_path.is_empty() { + current_path.push('/'); + } + current_path.push_str(component); + let dir_path = RepoPath::new(¤t_path) + .expect("repo path from status entry component"); + + let component = SharedString::from(component.to_string()); + + current = current + .children + .entry(component.clone()) + .or_insert_with(|| TreeNode { + name: component, + path: Some(dir_path), + ..Default::default() + }); + } + } + } + + let (flattened, _) = self.flatten_tree(&root, section, 0, seen_directories); + flattened + } + + fn flatten_tree( + &mut self, + node: &TreeNode, + section: Section, + depth: usize, + seen_directories: &mut HashSet, + ) -> (Vec<(GitListEntry, bool)>, Vec) { + let mut all_statuses = Vec::new(); + let mut flattened = Vec::new(); + + for child in node.children.values() { + let (terminal, name) = Self::compact_directory_chain(child); + let Some(path) = terminal.path.clone().or_else(|| child.path.clone()) else { + continue; + }; + let (child_flattened, mut child_statuses) = + self.flatten_tree(terminal, section, depth + 1, seen_directories); + let key = TreeKey { section, path }; + let expanded = *self.expanded_dirs.get(&key).unwrap_or(&true); + self.expanded_dirs.entry(key.clone()).or_insert(true); + seen_directories.insert(key.clone()); + + self.directory_descendants + .insert(key.clone(), child_statuses.clone()); + + flattened.push(( + GitListEntry::Directory(GitTreeDirEntry { + key, + name, + depth, + expanded, + }), + true, + )); + + if expanded { + flattened.extend(child_flattened); + } else { + flattened.extend(child_flattened.into_iter().map(|(child, _)| (child, false))); + } + + all_statuses.append(&mut child_statuses); + } + + for file in &node.files { + all_statuses.push(file.clone()); + flattened.push(( + GitListEntry::TreeStatus(GitTreeStatusEntry { + entry: file.clone(), + depth, + }), + true, + )); + } + + (flattened, all_statuses) + } + + fn compact_directory_chain(mut node: &TreeNode) -> (&TreeNode, SharedString) { + let mut parts = vec![node.name.clone()]; + while node.files.is_empty() && node.children.len() == 1 { + let Some(child) = node.children.values().next() else { + continue; + }; + if child.path.is_none() { + break; + } + parts.push(child.name.clone()); + node = child; + } + let name = parts.join("/"); + (node, SharedString::from(name)) + } +} + +#[derive(Debug, PartialEq, Eq, Clone)] +struct GitTreeStatusEntry { + entry: GitStatusEntry, + depth: usize, +} + +#[derive(Debug, PartialEq, Eq, Clone, Hash)] +struct TreeKey { + section: Section, + path: RepoPath, +} + +#[derive(Debug, PartialEq, Eq, Clone)] +struct GitTreeDirEntry { + key: TreeKey, + name: SharedString, + depth: usize, + // staged_state: ToggleState, + expanded: bool, +} + +#[derive(Default)] +struct TreeNode { + name: SharedString, + path: Option, + children: BTreeMap, + files: Vec, +} + #[derive(Debug, PartialEq, Eq, Clone)] pub struct GitStatusEntry { pub(crate) repo_path: RepoPath, @@ -345,12 +584,15 @@ pub struct GitPanel { add_coauthors: bool, generate_commit_message_task: Option>>, entries: Vec, + view_mode: GitPanelViewMode, + entries_indices: HashMap, single_staged_entry: Option, single_tracked_entry: Option, focus_handle: FocusHandle, fs: Arc, new_count: usize, entry_count: usize, + changes_count: usize, new_staged_count: usize, pending_commit: Option>, amend_pending: bool, @@ -366,7 +608,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, @@ -433,14 +675,19 @@ impl GitPanel { cx.on_focus(&focus_handle, window, Self::focus_in).detach(); let mut was_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path; + let mut was_tree_view = GitPanelSettings::get_global(cx).tree_view; cx.observe_global_in::(window, move |this, window, cx| { - let is_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path; - if is_sort_by_path != was_sort_by_path { - this.entries.clear(); + let sort_by_path = GitPanelSettings::get_global(cx).sort_by_path; + let tree_view = GitPanelSettings::get_global(cx).tree_view; + if tree_view != was_tree_view { + this.view_mode = GitPanelViewMode::from_settings(cx); + } + if sort_by_path != was_sort_by_path || tree_view != was_tree_view { this.bulk_staging.take(); this.update_visible_entries(window, cx); } - was_sort_by_path = is_sort_by_path + was_sort_by_path = sort_by_path; + was_tree_view = tree_view; }) .detach(); @@ -506,10 +753,13 @@ impl GitPanel { add_coauthors: true, generate_commit_message_task: None, entries: Vec::new(), + view_mode: GitPanelViewMode::from_settings(cx), + entries_indices: HashMap::default(), focus_handle: cx.focus_handle(), fs, new_count: 0, new_staged_count: 0, + changes_count: 0, pending_commit: None, amend_pending: false, original_commit_message: None, @@ -543,70 +793,70 @@ impl GitPanel { }) } - pub fn entry_by_path(&self, path: &RepoPath, cx: &App) -> Option { - if GitPanelSettings::get_global(cx).sort_by_path { - return self - .entries - .binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(path)) - .ok(); - } - - if self.conflicted_count > 0 { - let conflicted_start = 1; - if let Ok(ix) = self.entries[conflicted_start..conflicted_start + self.conflicted_count] - .binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(path)) - { - return Some(conflicted_start + ix); - } - } - if self.tracked_count > 0 { - let tracked_start = if self.conflicted_count > 0 { - 1 + self.conflicted_count - } else { - 0 - } + 1; - if let Ok(ix) = self.entries[tracked_start..tracked_start + self.tracked_count] - .binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(path)) - { - return Some(tracked_start + ix); - } - } - if self.new_count > 0 { - let untracked_start = if self.conflicted_count > 0 { - 1 + self.conflicted_count - } else { - 0 - } + if self.tracked_count > 0 { - 1 + self.tracked_count - } else { - 0 - } + 1; - if let Ok(ix) = self.entries[untracked_start..untracked_start + self.new_count] - .binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(path)) - { - return Some(untracked_start + ix); - } - } - None + pub fn entry_by_path(&self, path: &RepoPath) -> Option { + self.entries_indices.get(path).copied() } pub fn select_entry_by_path( &mut self, path: ProjectPath, - _: &mut Window, + window: &mut Window, cx: &mut Context, ) { let Some(git_repo) = self.active_repository.as_ref() else { return; }; - let Some(repo_path) = git_repo.read(cx).project_path_to_repo_path(&path, cx) else { - return; + + let (repo_path, section) = { + let repo = git_repo.read(cx); + let Some(repo_path) = repo.project_path_to_repo_path(&path, cx) else { + return; + }; + + let section = repo + .status_for_path(&repo_path) + .map(|status| status.status) + .map(|status| { + if repo.had_conflict_on_last_merge_head_change(&repo_path) { + Section::Conflict + } else if status.is_created() { + Section::New + } else { + Section::Tracked + } + }); + + (repo_path, section) }; - let Some(ix) = self.entry_by_path(&repo_path, cx) else { + + let mut needs_rebuild = false; + if let (Some(section), Some(tree_state)) = (section, self.view_mode.tree_state_mut()) { + let mut current_dir = repo_path.parent(); + while let Some(dir) = current_dir { + let key = TreeKey { + section, + path: RepoPath::from_rel_path(dir), + }; + + if tree_state.expanded_dirs.get(&key) == Some(&false) { + tree_state.expanded_dirs.insert(key, true); + needs_rebuild = true; + } + + current_dir = dir.parent(); + } + } + + if needs_rebuild { + self.update_visible_entries(window, cx); + } + + let Some(ix) = self.entry_by_path(&repo_path) else { return; }; + self.selected_entry = Some(ix); - cx.notify(); + self.scroll_to_selected_entry(cx); } fn serialization_key(workspace: &Workspace) -> Option { @@ -694,24 +944,98 @@ impl GitPanel { } fn scroll_to_selected_entry(&mut self, cx: &mut Context) { - if let Some(selected_entry) = self.selected_entry { + let Some(selected_entry) = self.selected_entry else { + cx.notify(); + return; + }; + + let visible_index = match &self.view_mode { + GitPanelViewMode::Flat => Some(selected_entry), + GitPanelViewMode::Tree(state) => state + .logical_indices + .iter() + .position(|&ix| ix == selected_entry), + }; + + if let Some(visible_index) = visible_index { self.scroll_handle - .scroll_to_item(selected_entry, ScrollStrategy::Center); + .scroll_to_item(visible_index, ScrollStrategy::Center); } cx.notify(); } - fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context) { - if !self.entries.is_empty() { - self.selected_entry = Some(1); + 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(&menu::SelectNext, window, cx); + } else { + self.toggle_directory(&dir_entry.key, window, cx); + } + } else { + self.select_next(&menu::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(&menu::SelectPrevious, window, cx); + } + } else { + self.select_previous(&menu::SelectPrevious, window, cx); + } + } + + fn select_first( + &mut self, + _: &menu::SelectFirst, + _window: &mut Window, + cx: &mut Context, + ) { + 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); } } fn select_previous( &mut self, - _: &SelectPrevious, + _: &menu::SelectPrevious, _window: &mut Window, cx: &mut Context, ) { @@ -720,80 +1044,142 @@ 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) { + fn select_next(&mut self, _: &menu::SelectNext, _window: &mut Window, cx: &mut Context) { let item_count = self.entries.len(); if item_count == 0 { 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) { + fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context) { if self.entries.last().is_some() { self.selected_entry = Some(self.entries.len() - 1); self.scroll_to_selected_entry(cx); } } + /// Show diff view at selected entry, only if the diff view is open + fn move_diff_to_entry(&mut self, window: &mut Window, cx: &mut Context) { + maybe!({ + let workspace = self.workspace.upgrade()?; + + if let Some(project_diff) = workspace.read(cx).item_of_type::(cx) { + let entry = self.entries.get(self.selected_entry?)?.status_entry()?; + + project_diff.update(cx, |project_diff, cx| { + project_diff.move_to_entry(entry.clone(), window, cx); + }); + } + + Some(()) + }); + } + + fn first_entry(&mut self, _: &FirstEntry, window: &mut Window, cx: &mut Context) { + self.select_first(&menu::SelectFirst, window, cx); + self.move_diff_to_entry(window, cx); + } + + fn last_entry(&mut self, _: &LastEntry, window: &mut Window, cx: &mut Context) { + self.select_last(&menu::SelectLast, window, cx); + self.move_diff_to_entry(window, cx); + } + + fn next_entry(&mut self, _: &NextEntry, window: &mut Window, cx: &mut Context) { + self.select_next(&menu::SelectNext, window, cx); + self.move_diff_to_entry(window, cx); + } + + fn previous_entry(&mut self, _: &PreviousEntry, window: &mut Window, cx: &mut Context) { + self.select_previous(&menu::SelectPrevious, window, cx); + self.move_diff_to_entry(window, cx); + } + 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 = Some(1); - self.scroll_to_selected_entry(cx); - cx.notify(); + self.select_first(&menu::SelectFirst, window, cx); } } @@ -803,10 +1189,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> { @@ -827,7 +1211,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; }; @@ -837,7 +1221,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(()) }); @@ -940,14 +1324,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) @@ -1329,6 +1713,71 @@ impl GitPanel { .detach(); } + fn stage_status_for_entry(entry: &GitStatusEntry, repo: &Repository) -> StageStatus { + // Checking for current staged/unstaged file status is a chained operation: + // 1. first, we check for any pending operation recorded in repository + // 2. if there are no pending ops either running or finished, we then ask the repository + // for the most up-to-date file status read from disk - we do this since `entry` arg to this function `render_entry` + // is likely to be staled, and may lead to weird artifacts in the form of subsecond auto-uncheck/check on + // the checkbox's state (or flickering) which is undesirable. + // 3. finally, if there is no info about this `entry` in the repo, we fall back to whatever status is encoded + // in `entry` arg. + repo.pending_ops_for_path(&entry.repo_path) + .map(|ops| { + if ops.staging() || ops.staged() { + StageStatus::Staged + } else { + StageStatus::Unstaged + } + }) + .or_else(|| { + repo.status_for_path(&entry.repo_path) + .map(|status| status.status.staging()) + }) + .unwrap_or(entry.staging) + } + + fn stage_status_for_directory( + &self, + entry: &GitTreeDirEntry, + repo: &Repository, + ) -> StageStatus { + let GitPanelViewMode::Tree(tree_state) = &self.view_mode else { + util::debug_panic!("We should never render a directory entry while in flat view mode"); + return StageStatus::Unstaged; + }; + + let Some(descendants) = tree_state.directory_descendants.get(&entry.key) else { + return StageStatus::Unstaged; + }; + + let mut fully_staged_count = 0usize; + let mut any_staged_or_partially_staged = false; + + for descendant in descendants { + match GitPanel::stage_status_for_entry(descendant, repo) { + StageStatus::Staged => { + fully_staged_count += 1; + any_staged_or_partially_staged = true; + } + StageStatus::PartiallyStaged => { + any_staged_or_partially_staged = true; + } + StageStatus::Unstaged => {} + } + } + + if descendants.is_empty() { + StageStatus::Unstaged + } else if fully_staged_count == descendants.len() { + StageStatus::Staged + } else if any_staged_or_partially_staged { + StageStatus::PartiallyStaged + } else { + StageStatus::Unstaged + } + } + pub fn stage_all(&mut self, _: &StageAll, _window: &mut Window, cx: &mut Context) { self.change_all_files_stage(true, cx); } @@ -1343,50 +1792,101 @@ impl GitPanel { _window: &mut Window, cx: &mut Context, ) { - let Some(active_repository) = self.active_repository.as_ref() else { + let Some(active_repository) = self.active_repository.clone() else { return; }; - let repo = active_repository.read(cx); - let (stage, repo_paths) = match entry { - GitListEntry::Status(status_entry) => { - let repo_paths = vec![status_entry.clone()]; - let stage = if repo - .pending_ops_for_path(&status_entry.repo_path) - .map(|ops| ops.staging() || ops.staged()) - .or_else(|| { - repo.status_for_path(&status_entry.repo_path) - .map(|status| status.status.staging().has_staged()) - }) - .unwrap_or(status_entry.staging.has_staged()) - { - if let Some(op) = self.bulk_staging.clone() - && op.anchor == status_entry.repo_path - { - self.bulk_staging = None; - } - false - } else { - self.set_bulk_staging_anchor(status_entry.repo_path.clone(), cx); - true - }; - (stage, repo_paths) - } - GitListEntry::Header(section) => { - let goal_staged_state = !self.header_state(section.header).selected(); - let entries = self - .entries - .iter() - .filter_map(|entry| entry.status_entry()) - .filter(|status_entry| { - section.contains(status_entry, repo) - && status_entry.staging.as_bool() != Some(goal_staged_state) - }) - .cloned() - .collect::>(); + let mut set_anchor: Option = None; + let mut clear_anchor = None; + + let (stage, repo_paths) = { + let repo = active_repository.read(cx); + match entry { + GitListEntry::Status(status_entry) => { + let repo_paths = vec![status_entry.clone()]; + let stage = match GitPanel::stage_status_for_entry(status_entry, &repo) { + StageStatus::Staged => { + if let Some(op) = self.bulk_staging.clone() + && op.anchor == status_entry.repo_path + { + clear_anchor = Some(op.anchor); + } + false + } + StageStatus::Unstaged | StageStatus::PartiallyStaged => { + set_anchor = Some(status_entry.repo_path.clone()); + true + } + }; + (stage, repo_paths) + } + GitListEntry::TreeStatus(status_entry) => { + let repo_paths = vec![status_entry.entry.clone()]; + let stage = match GitPanel::stage_status_for_entry(&status_entry.entry, &repo) { + StageStatus::Staged => { + if let Some(op) = self.bulk_staging.clone() + && op.anchor == status_entry.entry.repo_path + { + clear_anchor = Some(op.anchor); + } + false + } + StageStatus::Unstaged | StageStatus::PartiallyStaged => { + set_anchor = Some(status_entry.entry.repo_path.clone()); + true + } + }; + (stage, repo_paths) + } + GitListEntry::Header(section) => { + let goal_staged_state = !self.header_state(section.header).selected(); + let entries = self + .entries + .iter() + .filter_map(|entry| entry.status_entry()) + .filter(|status_entry| { + section.contains(status_entry, &repo) + && GitPanel::stage_status_for_entry(status_entry, &repo).as_bool() + != Some(goal_staged_state) + }) + .cloned() + .collect::>(); - (goal_staged_state, entries) + (goal_staged_state, entries) + } + GitListEntry::Directory(entry) => { + let goal_staged_state = match self.stage_status_for_directory(entry, repo) { + StageStatus::Staged => StageStatus::Unstaged, + StageStatus::Unstaged | StageStatus::PartiallyStaged => StageStatus::Staged, + }; + let goal_stage = goal_staged_state == StageStatus::Staged; + + let entries = self + .view_mode + .tree_state() + .and_then(|state| state.directory_descendants.get(&entry.key)) + .cloned() + .unwrap_or_default() + .into_iter() + .filter(|status_entry| { + GitPanel::stage_status_for_entry(status_entry, &repo) + != goal_staged_state + }) + .collect::>(); + (goal_stage, entries) + } } }; + if let Some(anchor) = clear_anchor { + if let Some(op) = self.bulk_staging.clone() + && op.anchor == anchor + { + self.bulk_staging = None; + } + } + if let Some(anchor) = set_anchor { + self.set_bulk_staging_anchor(anchor, cx); + } + self.change_file_stage(stage, repo_paths, cx); } @@ -1560,16 +2060,26 @@ impl GitPanel { } } - fn commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context) { + fn on_commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context) { + if self.commit(&self.commit_editor.focus_handle(cx), window, cx) { + telemetry::event!("Git Committed", source = "Git Panel"); + } + } + + /// Commits staged changes with the current commit message. + /// + /// Returns `true` if the commit was executed, `false` otherwise. + pub(crate) fn commit( + &mut self, + commit_editor_focus_handle: &FocusHandle, + window: &mut Window, + cx: &mut Context, + ) -> bool { if self.amend_pending { - return; + return false; } - if self - .commit_editor - .focus_handle(cx) - .contains_focused(window, cx) - { - telemetry::event!("Git Committed", source = "Git Panel"); + + if commit_editor_focus_handle.contains_focused(window, cx) { self.commit_changes( CommitOptions { amend: false, @@ -1577,24 +2087,39 @@ impl GitPanel { }, window, cx, - ) + ); + true } else { cx.propagate(); + false } } - fn amend(&mut self, _: &git::Amend, window: &mut Window, cx: &mut Context) { - if self - .commit_editor - .focus_handle(cx) - .contains_focused(window, cx) - { + fn on_amend(&mut self, _: &git::Amend, window: &mut Window, cx: &mut Context) { + if self.amend(&self.commit_editor.focus_handle(cx), window, cx) { + telemetry::event!("Git Amended", source = "Git Panel"); + } + } + + /// Amends the most recent commit with staged changes and/or an updated commit message. + /// + /// Uses a two-stage workflow where the first invocation loads the commit + /// message for editing, second invocation performs the amend. Returns + /// `true` if the amend was executed, `false` otherwise. + pub(crate) fn amend( + &mut self, + commit_editor_focus_handle: &FocusHandle, + window: &mut Window, + cx: &mut Context, + ) -> bool { + if commit_editor_focus_handle.contains_focused(window, cx) { if self.head_commit(cx).is_some() { if !self.amend_pending { self.set_amend_pending(true, cx); - self.load_last_commit_message_if_empty(cx); + self.load_last_commit_message(cx); + + return false; } else { - telemetry::event!("Git Amended", source = "Git Panel"); self.commit_changes( CommitOptions { amend: true, @@ -1603,13 +2128,16 @@ impl GitPanel { window, cx, ); + + return true; } } + return false; } else { cx.propagate(); + return false; } } - pub fn head_commit(&self, cx: &App) -> Option { self.active_repository .as_ref() @@ -1617,13 +2145,11 @@ impl GitPanel { .cloned() } - pub fn load_last_commit_message_if_empty(&mut self, cx: &mut Context) { - if !self.commit_editor.read(cx).is_empty(cx) { - return; - } + pub fn load_last_commit_message(&mut self, cx: &mut Context) { let Some(head_commit) = self.head_commit(cx) else { return; }; + let recent_sha = head_commit.sha.to_string(); let detail_task = self.load_commit_details(recent_sha, cx); cx.spawn(async move |this, cx| { @@ -1666,7 +2192,13 @@ impl GitPanel { let editor = cx.new(|cx| Editor::for_buffer(buffer, None, window, cx)); let wrapped_message = editor.update(cx, |editor, cx| { editor.select_all(&Default::default(), window, cx); - editor.rewrap(&Default::default(), window, cx); + editor.rewrap_impl( + RewrapOptions { + override_language_settings: false, + preserve_existing_whitespace: true, + }, + cx, + ); editor.text(cx) }); if wrapped_message.trim().is_empty() { @@ -1717,7 +2249,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; }; @@ -1759,11 +2294,16 @@ impl GitPanel { let result = task.await; this.update_in(cx, |this, window, cx| { this.pending_commit.take(); + match result { Ok(()) => { - this.commit_editor - .update(cx, |editor, cx| editor.clear(window, cx)); - this.original_commit_message = None; + if options.amend { + this.set_amend_pending(false, cx); + } else { + this.commit_editor + .update(cx, |editor, cx| editor.clear(window, cx)); + this.original_commit_message = None; + } } Err(e) => this.show_error_toast("commit", e, cx), } @@ -1772,9 +2312,6 @@ impl GitPanel { }); self.pending_commit = Some(task); - if options.amend { - self.set_amend_pending(false, cx); - } } pub(crate) fn uncommit(&mut self, window: &mut Window, cx: &mut Context) { @@ -1999,6 +2536,82 @@ impl GitPanel { compressed } + async fn load_project_rules( + project: &Entity, + repo_work_dir: &Arc, + cx: &mut AsyncApp, + ) -> Option { + let rules_path = cx + .update(|cx| { + for worktree in project.read(cx).worktrees(cx) { + let worktree_abs_path = worktree.read(cx).abs_path(); + if !worktree_abs_path.starts_with(&repo_work_dir) { + continue; + } + + let worktree_snapshot = worktree.read(cx).snapshot(); + for rules_name in RULES_FILE_NAMES { + if let Ok(rel_path) = RelPath::unix(rules_name) { + if let Some(entry) = worktree_snapshot.entry_for_path(rel_path) { + if entry.is_file() { + return Some(ProjectPath { + worktree_id: worktree.read(cx).id(), + path: entry.path.clone(), + }); + } + } + } + } + } + None + }) + .ok()??; + + let buffer = project + .update(cx, |project, cx| project.open_buffer(rules_path, cx)) + .ok()? + .await + .ok()?; + + let content = buffer + .read_with(cx, |buffer, _| buffer.text()) + .ok()? + .trim() + .to_string(); + + if content.is_empty() { + None + } else { + Some(content) + } + } + + async fn load_commit_message_prompt( + is_using_legacy_zed_pro: bool, + cx: &mut AsyncApp, + ) -> String { + // 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 BuiltInPrompt::CommitMessage.default_content().to_string(); + } + + let load = async { + let store = cx.update(|cx| PromptStore::global(cx)).ok()?.await.ok()?; + store + .update(cx, |s, cx| { + s.load(PromptId::BuiltIn(BuiltInPrompt::CommitMessage), cx) + }) + .ok()? + .await + .ok() + }; + load.await + .unwrap_or_else(|| BuiltInPrompt::CommitMessage.default_content().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) { @@ -2026,8 +2639,17 @@ impl GitPanel { }); let temperature = AgentSettings::temperature_for_model(&model, cx); + 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, cx| { + self.generate_commit_message_task = Some(cx.spawn(async move |this, mut cx| { async move { let _defer = cx.on_drop(&this, |this, _cx| { this.generate_commit_message_task.take(); @@ -2060,19 +2682,33 @@ impl GitPanel { const MAX_DIFF_BYTES: usize = 20_000; diff_text = Self::compress_commit_diff(&diff_text, MAX_DIFF_BYTES); + 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(); - let content = if text_empty { - format!("{PROMPT}\nHere are the changes in this commit:\n{diff_text}") + 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\ + \n{rules}\n\n" + ), + None => String::new(), + }; + + let subject_section = if text_empty { + String::new() } else { - format!("{PROMPT}\nHere is the user's subject line:\n{subject}\nHere are the changes in this commit:\n{diff_text}\n") + format!("\nHere is the user's subject line:\n{subject}") }; - const PROMPT: &str = include_str!("commit_message_prompt.txt"); + let content = format!( + "{prompt}{rules_section}{subject_section}\nHere are the changes in this commit:\n{diff_text}" + ); let request = LanguageModelRequest { thread_id: None, @@ -2701,6 +3337,29 @@ impl GitPanel { } } + fn toggle_tree_view(&mut self, _: &ToggleTreeView, _: &mut Window, cx: &mut Context) { + let current_setting = GitPanelSettings::get_global(cx).tree_view; + if let Some(workspace) = self.workspace.upgrade() { + let workspace = workspace.read(cx); + let fs = workspace.app_state().fs.clone(); + cx.update_global::(|store, _cx| { + store.update_settings_file(fs, move |settings, _cx| { + settings.git_panel.get_or_insert_default().tree_view = Some(!current_setting); + }); + }) + } + } + + fn toggle_directory(&mut self, key: &TreeKey, window: &mut Window, cx: &mut Context) { + if let Some(state) = self.view_mode.tree_state_mut() { + let expanded = state.expanded_dirs.entry(key.clone()).or_insert(true); + *expanded = !*expanded; + self.update_visible_entries(window, cx); + } else { + util::debug_panic!("Attempted to toggle directory in flat Git Panel state"); + } + } + fn fill_co_authors(&mut self, message: &mut String, cx: &mut Context) { const CO_AUTHOR_PREFIX: &str = "Co-authored-by: "; @@ -2810,27 +3469,34 @@ impl GitPanel { let bulk_staging = self.bulk_staging.take(); let last_staged_path_prev_index = bulk_staging .as_ref() - .and_then(|op| self.entry_by_path(&op.anchor, cx)); + .and_then(|op| self.entry_by_path(&op.anchor)); self.entries.clear(); + self.entries_indices.clear(); self.single_staged_entry.take(); self.single_tracked_entry.take(); self.conflicted_count = 0; self.conflicted_staged_count = 0; + self.changes_count = 0; self.new_count = 0; self.tracked_count = 0; self.new_staged_count = 0; self.tracked_staged_count = 0; self.entry_count = 0; + self.max_width_item_index = None; let sort_by_path = GitPanelSettings::get_global(cx).sort_by_path; + let is_tree_view = matches!(self.view_mode, GitPanelViewMode::Tree(_)); + let group_by_status = is_tree_view || !sort_by_path; let mut changed_entries = Vec::new(); let mut new_entries = Vec::new(); let mut conflict_entries = Vec::new(); let mut single_staged_entry = None; let mut staged_count = 0; - let mut max_width_item: Option<(RepoPath, usize)> = None; + let mut seen_directories = HashSet::default(); + let mut max_width_estimate = 0usize; + let mut max_width_item_index = None; let Some(repo) = self.active_repository.as_ref() else { // Just clear entries if no repository is active. @@ -2843,6 +3509,7 @@ impl GitPanel { self.stash_entries = repo.cached_stash(); for entry in repo.cached_status() { + self.changes_count += 1; let is_conflict = repo.had_conflict_on_last_merge_head_change(&entry.repo_path); let is_new = entry.status.is_created(); let staging = entry.status.staging(); @@ -2867,26 +3534,9 @@ impl GitPanel { single_staged_entry = Some(entry.clone()); } - let width_estimate = Self::item_width_estimate( - entry.parent_dir(path_style).map(|s| s.len()).unwrap_or(0), - entry.display_name(path_style).len(), - ); - - match max_width_item.as_mut() { - Some((repo_path, estimate)) => { - if width_estimate > *estimate { - *repo_path = entry.repo_path.clone(); - *estimate = width_estimate; - } - } - None => max_width_item = Some((entry.repo_path.clone(), width_estimate)), - } - - if sort_by_path { - changed_entries.push(entry); - } else if is_conflict { + if group_by_status && is_conflict { conflict_entries.push(entry); - } else if is_new { + } else if group_by_status && is_new { new_entries.push(entry); } else { changed_entries.push(entry); @@ -2921,57 +3571,122 @@ impl GitPanel { self.single_tracked_entry = changed_entries.first().cloned(); } - if !conflict_entries.is_empty() { - self.entries.push(GitListEntry::Header(GitHeaderEntry { - header: Section::Conflict, - })); - self.entries - .extend(conflict_entries.into_iter().map(GitListEntry::Status)); + let mut push_entry = + |this: &mut Self, + entry: GitListEntry, + is_visible: bool, + logical_indices: Option<&mut Vec>| { + if let Some(estimate) = + this.width_estimate_for_list_entry(is_tree_view, &entry, path_style) + { + if estimate > max_width_estimate { + max_width_estimate = estimate; + max_width_item_index = Some(this.entries.len()); + } + } + + if let Some(repo_path) = entry.status_entry().map(|status| status.repo_path.clone()) + { + this.entries_indices.insert(repo_path, this.entries.len()); + } + + if let (Some(indices), true) = (logical_indices, is_visible) { + indices.push(this.entries.len()); + } + + this.entries.push(entry); + }; + + macro_rules! take_section_entries { + () => { + [ + (Section::Conflict, std::mem::take(&mut conflict_entries)), + (Section::Tracked, std::mem::take(&mut changed_entries)), + (Section::New, std::mem::take(&mut new_entries)), + ] + }; } - if !changed_entries.is_empty() { - if !sort_by_path { - self.entries.push(GitListEntry::Header(GitHeaderEntry { - header: Section::Tracked, - })); + match &mut self.view_mode { + GitPanelViewMode::Tree(tree_state) => { + tree_state.logical_indices.clear(); + tree_state.directory_descendants.clear(); + + // This is just to get around the borrow checker + // because push_entry mutably borrows self + let mut tree_state = std::mem::take(tree_state); + + for (section, entries) in take_section_entries!() { + if entries.is_empty() { + continue; + } + + push_entry( + self, + GitListEntry::Header(GitHeaderEntry { header: section }), + true, + Some(&mut tree_state.logical_indices), + ); + + for (entry, is_visible) in + tree_state.build_tree_entries(section, entries, &mut seen_directories) + { + push_entry( + self, + entry, + is_visible, + Some(&mut tree_state.logical_indices), + ); + } + } + + tree_state + .expanded_dirs + .retain(|key, _| seen_directories.contains(key)); + self.view_mode = GitPanelViewMode::Tree(tree_state); } - self.entries - .extend(changed_entries.into_iter().map(GitListEntry::Status)); - } - if !new_entries.is_empty() { - self.entries.push(GitListEntry::Header(GitHeaderEntry { - header: Section::New, - })); - self.entries - .extend(new_entries.into_iter().map(GitListEntry::Status)); - } + GitPanelViewMode::Flat => { + for (section, entries) in take_section_entries!() { + if entries.is_empty() { + continue; + } - if let Some((repo_path, _)) = max_width_item { - self.max_width_item_index = self.entries.iter().position(|entry| match entry { - GitListEntry::Status(git_status_entry) => git_status_entry.repo_path == repo_path, - GitListEntry::Header(_) => false, - }); + if section != Section::Tracked || !sort_by_path { + push_entry( + self, + GitListEntry::Header(GitHeaderEntry { header: section }), + true, + None, + ); + } + + for entry in entries { + push_entry(self, GitListEntry::Status(entry), true, None); + } + } + } } + self.max_width_item_index = max_width_item_index; + self.update_counts(repo); let bulk_staging_anchor_new_index = bulk_staging .as_ref() .filter(|op| op.repo_id == repo.id) - .and_then(|op| self.entry_by_path(&op.anchor, cx)); + .and_then(|op| self.entry_by_path(&op.anchor)); if bulk_staging_anchor_new_index == last_staged_path_prev_index && let Some(index) = bulk_staging_anchor_new_index && let Some(entry) = self.entries.get(index) && let Some(entry) = entry.status_entry() - && repo - .pending_ops_for_path(&entry.repo_path) - .map(|ops| ops.staging() || ops.staged()) - .unwrap_or(entry.staging.has_staged()) + && GitPanel::stage_status_for_entry(entry, &repo) + .as_bool() + .unwrap_or(false) { 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()); @@ -3007,15 +3722,13 @@ impl GitPanel { self.new_staged_count = 0; self.tracked_staged_count = 0; self.entry_count = 0; - for entry in &self.entries { - let Some(status_entry) = entry.status_entry() else { - continue; - }; + + for status_entry in self.entries.iter().filter_map(|entry| entry.status_entry()) { self.entry_count += 1; - let is_staging_or_staged = repo - .pending_ops_for_path(&status_entry.repo_path) - .map(|ops| ops.staging() || ops.staged()) - .unwrap_or(status_entry.staging.has_staged()); + let is_staging_or_staged = GitPanel::stage_status_for_entry(status_entry, repo) + .as_bool() + .unwrap_or(false); + if repo.had_conflict_on_last_merge_head_change(&status_entry.repo_path) { self.conflicted_count += 1; if is_staging_or_staged { @@ -3112,6 +3825,7 @@ impl GitPanel { .icon(ToastIcon::new(IconName::GitBranchAlt).color(Color::Muted)) .action(text, move |_, cx| cx.open_url(&link)), } + .dismiss_button(true) }); workspace.toggle_status_toast(status_toast, cx) }); @@ -3129,10 +3843,48 @@ impl GitPanel { self.has_staged_changes() } - // eventually we'll need to take depth into account here - // if we add a tree view - fn item_width_estimate(path: usize, file_name: usize) -> usize { - path + file_name + fn status_width_estimate( + tree_view: bool, + entry: &GitStatusEntry, + path_style: PathStyle, + depth: usize, + ) -> usize { + if tree_view { + Self::item_width_estimate(0, entry.display_name(path_style).len(), depth) + } else { + Self::item_width_estimate( + entry.parent_dir(path_style).map(|s| s.len()).unwrap_or(0), + entry.display_name(path_style).len(), + 0, + ) + } + } + + fn width_estimate_for_list_entry( + &self, + tree_view: bool, + entry: &GitListEntry, + path_style: PathStyle, + ) -> Option { + match entry { + GitListEntry::Status(status) => Some(Self::status_width_estimate( + tree_view, status, path_style, 0, + )), + GitListEntry::TreeStatus(status) => Some(Self::status_width_estimate( + tree_view, + &status.entry, + path_style, + status.depth, + )), + GitListEntry::Directory(dir) => { + Some(Self::item_width_estimate(0, dir.name.len(), dir.depth)) + } + GitListEntry::Header(_) => None, + } + } + + fn item_width_estimate(path: usize, file_name: usize, depth: usize) -> usize { + path + file_name + depth * 2 } fn render_overflow_menu(&self, id: impl Into) -> impl IntoElement { @@ -3159,6 +3911,7 @@ impl GitPanel { has_new_changes, sort_by_path: GitPanelSettings::get_global(cx).sort_by_path, has_stash_items, + tree_view: GitPanelSettings::get_global(cx).tree_view, }, window, cx, @@ -3393,10 +4146,10 @@ impl GitPanel { ("Stage All", StageAll.boxed_clone(), true, "git add --all") }; - let change_string = match self.entry_count { + let change_string = match self.changes_count { 0 => "No Changes".to_string(), 1 => "1 Change".to_string(), - _ => format!("{} Changes", self.entry_count), + count => format!("{} Changes", count), }; Some( @@ -3518,7 +4271,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() @@ -3818,7 +4571,7 @@ impl GitPanel { let repo = self.active_repository.as_ref()?.read(cx); let project_path = (file.worktree_id(cx), file.path().clone()).into(); let repo_path = repo.project_path_to_repo_path(&project_path, cx)?; - let ix = self.entry_by_path(&repo_path, cx)?; + let ix = self.entry_by_path(&repo_path)?; let entry = self.entries.get(ix)?; let is_staging_or_staged = repo @@ -3869,7 +4622,10 @@ impl GitPanel { window: &mut Window, cx: &mut Context, ) -> impl IntoElement { - let entry_count = self.entries.len(); + let (is_tree_view, entry_count) = match &self.view_mode { + GitPanelViewMode::Tree(state) => (true, state.logical_indices.len()), + GitPanelViewMode::Flat => (false, self.entries.len()), + }; v_flex() .flex_1() @@ -3889,10 +4645,33 @@ impl GitPanel { cx.processor(move |this, range: Range, window, cx| { let mut items = Vec::with_capacity(range.end - range.start); - for ix in range { + for ix in range.into_iter().map(|ix| match &this.view_mode { + GitPanelViewMode::Tree(state) => state.logical_indices[ix], + GitPanelViewMode::Flat => ix, + }) { match &this.entries.get(ix) { Some(GitListEntry::Status(entry)) => { - items.push(this.render_entry( + items.push(this.render_status_entry( + ix, + entry, + 0, + has_write_access, + window, + cx, + )); + } + Some(GitListEntry::TreeStatus(entry)) => { + items.push(this.render_status_entry( + ix, + &entry.entry, + entry.depth, + has_write_access, + window, + cx, + )); + } + Some(GitListEntry::Directory(entry)) => { + items.push(this.render_directory_entry( ix, entry, has_write_access, @@ -3916,12 +4695,56 @@ impl GitPanel { items }), ) + .when(is_tree_view, |list| { + let indent_size = px(TREE_INDENT); + list.with_decoration( + ui::indent_guides(indent_size, IndentGuideColors::panel(cx)) + .with_compute_indents_fn( + cx.entity(), + |this, range, _window, _cx| { + range + .map(|ix| match this.entries.get(ix) { + Some(GitListEntry::Directory(dir)) => dir.depth, + Some(GitListEntry::TreeStatus(status)) => { + status.depth + } + _ => 0, + }) + .collect() + }, + ) + .with_render_fn(cx.entity(), |_, params, _, _| { + // Magic number to align the tree item is 3 here + // because we're using 12px as the left-side padding + // and 3 makes the alignment work with the bounding box of the icon + let left_offset = px(TREE_INDENT + 3_f32); + let indent_size = params.indent_size; + let item_height = params.item_height; + + params + .indent_guides + .into_iter() + .map(|layout| { + let bounds = Bounds::new( + point( + layout.offset.x * indent_size + left_offset, + layout.offset.y * item_height, + ), + size(px(1.), layout.length * item_height), + ); + RenderedIndentGuide { + bounds, + layout, + is_active: false, + hitbox: None, + } + }) + .collect() + }), + ) + }) .size_full() .flex_grow() - .with_sizing_behavior(ListSizingBehavior::Auto) - .with_horizontal_sizing_behavior( - ListHorizontalSizingBehavior::Unconstrained, - ) .with_width_from_item(self.max_width_item_index) .track_scroll(&self.scroll_handle), ) @@ -3945,7 +4768,7 @@ impl GitPanel { } fn entry_label(&self, label: impl Into, color: Color) -> Label { - Label::new(label.into()).color(color).single_line() + Label::new(label.into()).color(color) } fn list_item_height(&self) -> Rems { @@ -3967,8 +4790,8 @@ impl GitPanel { .h(self.list_item_height()) .w_full() .items_end() - .px(rems(0.75)) // ~12px - .pb(rems(0.3125)) // ~ 5px + .px_3() + .pb_1() .child( Label::new(header.title()) .color(Color::Muted) @@ -4011,7 +4834,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(); @@ -4025,10 +4848,10 @@ impl GitPanel { git::AddToGitignore.boxed_clone(), ) .separator() - .action("Open Diff", Confirm.boxed_clone()) - .action("Open File", SecondaryConfirm.boxed_clone()) + .action("Open Diff", menu::Confirm.boxed_clone()) + .action("Open File", menu::SecondaryConfirm.boxed_clone()) .separator() - .action_disabled_when(is_created, "File History", Box::new(git::FileHistory)) + .action_disabled_when(is_created, "View File History", Box::new(git::FileHistory)) }); self.selected_entry = Some(ix); self.set_context_menu(context_menu, position, window, cx); @@ -4049,6 +4872,7 @@ impl GitPanel { has_new_changes: self.new_count > 0, sort_by_path: GitPanelSettings::get_global(cx).sort_by_path, has_stash_items: self.stash_entries.entries.len() > 0, + tree_view: GitPanelSettings::get_global(cx).tree_view, }, window, cx, @@ -4080,14 +4904,16 @@ impl GitPanel { cx.notify(); } - fn render_entry( + fn render_status_entry( &self, ix: usize, entry: &GitStatusEntry, + depth: usize, has_write_access: bool, window: &Window, cx: &Context, ) -> AnyElement { + let tree_view = GitPanelSettings::get_global(cx).tree_view; let path_style = self.project.read(cx).path_style(cx); let git_path_style = ProjectSettings::get_global(cx).git.path_style; let display_name = entry.display_name(path_style); @@ -4100,10 +4926,13 @@ impl GitPanel { let has_conflict = status.is_conflicted(); let is_modified = status.is_modified(); let is_deleted = status.is_deleted(); + let is_created = status.is_created(); let label_color = if status_style == StatusStyle::LabelColor { if has_conflict { Color::VersionControlConflict + } else if is_created { + Color::VersionControlAdded } else if is_modified { Color::VersionControlModified } else if is_deleted { @@ -4134,23 +4963,12 @@ impl GitPanel { .active_repository(cx) .expect("active repository must be set"); let repo = active_repo.read(cx); - // Checking for current staged/unstaged file status is a chained operation: - // 1. first, we check for any pending operation recorded in repository - // 2. if there are no pending ops either running or finished, we then ask the repository - // for the most up-to-date file status read from disk - we do this since `entry` arg to this function `render_entry` - // is likely to be staled, and may lead to weird artifacts in the form of subsecond auto-uncheck/check on - // the checkbox's state (or flickering) which is undesirable. - // 3. finally, if there is no info about this `entry` in the repo, we fall back to whatever status is encoded - // in `entry` arg. - let is_staging_or_staged = repo - .pending_ops_for_path(&entry.repo_path) - .map(|ops| ops.staging() || ops.staged()) - .or_else(|| { - repo.status_for_path(&entry.repo_path) - .and_then(|status| status.status.staging().as_bool()) - }) - .or_else(|| entry.staging.as_bool()); - let mut is_staged: ToggleState = is_staging_or_staged.into(); + let stage_status = GitPanel::stage_status_for_entry(entry, &repo); + let mut is_staged: ToggleState = match stage_status { + StageStatus::Staged => ToggleState::Selected, + StageStatus::Unstaged => ToggleState::Unselected, + StageStatus::PartiallyStaged => ToggleState::Indeterminate, + }; if self.show_placeholders && !self.has_staged_changes() && !entry.status.is_created() { is_staged = ToggleState::Selected; } @@ -4161,50 +4979,117 @@ impl GitPanel { let marked_bg_alpha = 0.12; let state_opacity_step = 0.04; + let info_color = cx.theme().status().info; + let base_bg = match (selected, marked) { - (true, true) => cx - .theme() - .status() - .info - .alpha(selected_bg_alpha + marked_bg_alpha), - (true, false) => cx.theme().status().info.alpha(selected_bg_alpha), - (false, true) => cx.theme().status().info.alpha(marked_bg_alpha), + (true, true) => info_color.alpha(selected_bg_alpha + marked_bg_alpha), + (true, false) => info_color.alpha(selected_bg_alpha), + (false, true) => info_color.alpha(marked_bg_alpha), _ => cx.theme().colors().ghost_element_background, }; - let hover_bg = if selected { - cx.theme() - .status() - .info - .alpha(selected_bg_alpha + state_opacity_step) + let (hover_bg, active_bg) = if selected { + ( + info_color.alpha(selected_bg_alpha + state_opacity_step), + info_color.alpha(selected_bg_alpha + state_opacity_step * 2.0), + ) } else { - cx.theme().colors().ghost_element_hover + ( + cx.theme().colors().ghost_element_hover, + cx.theme().colors().ghost_element_active, + ) }; - let active_bg = if selected { - cx.theme() - .status() - .info - .alpha(selected_bg_alpha + state_opacity_step * 2.0) - } else { - cx.theme().colors().ghost_element_active - }; - h_flex() - .id(id) - .h(self.list_item_height()) - .w_full() - .items_center() - .border_1() - .when(selected && self.focus_handle.is_focused(window), |el| { - el.border_color(cx.theme().colors().border_focused) - }) - .px(rems(0.75)) // ~12px - .overflow_hidden() - .flex_none() - .gap_1p5() - .bg(base_bg) - .hover(|this| this.bg(hover_bg)) - .active(|this| this.bg(active_bg)) + let name_row = h_flex() + .min_w_0() + .flex_1() + .gap_1() + .child(git_status_icon(status)) + .map(|this| { + if tree_view { + this.pl(px(depth as f32 * TREE_INDENT)).child( + self.entry_label(display_name, label_color) + .when(status.is_deleted(), Label::strikethrough) + .truncate(), + ) + } else { + this.child(self.path_formatted( + entry.parent_dir(path_style), + path_color, + display_name, + label_color, + path_style, + git_path_style, + status.is_deleted(), + )) + } + }); + + h_flex() + .id(id) + .h(self.list_item_height()) + .w_full() + .pl_3() + .pr_1() + .gap_1p5() + .border_1() + .border_r_2() + .when(selected && self.focus_handle.is_focused(window), |el| { + el.border_color(cx.theme().colors().panel_focused_border) + }) + .bg(base_bg) + .hover(|s| s.bg(hover_bg)) + .active(|s| s.bg(active_bg)) + .child(name_row) + .child( + div() + .id(checkbox_wrapper_id) + .flex_none() + .occlude() + .cursor_pointer() + .child( + Checkbox::new(checkbox_id, is_staged) + .disabled(!has_write_access) + .fill() + .elevation(ElevationIndex::Surface) + .on_click_ext({ + let entry = entry.clone(); + let this = cx.weak_entity(); + move |_, click, window, cx| { + this.update(cx, |this, cx| { + if !has_write_access { + return; + } + if click.modifiers().shift { + this.stage_bulk(ix, cx); + } else { + let list_entry = + if GitPanelSettings::get_global(cx).tree_view { + GitListEntry::TreeStatus(GitTreeStatusEntry { + entry: entry.clone(), + depth, + }) + } else { + GitListEntry::Status(entry.clone()) + }; + this.toggle_staged_for_entry(&list_entry, window, cx); + } + cx.stop_propagation(); + }) + .ok(); + } + }) + .tooltip(move |_window, cx| { + let action = match stage_status { + StageStatus::Staged => "Unstage", + StageStatus::Unstaged | StageStatus::PartiallyStaged => "Stage", + }; + let tooltip_name = action.to_string(); + + Tooltip::for_action(tooltip_name, &ToggleStaged, cx) + }), + ), + ) .on_click({ cx.listener(move |this, event: &ClickEvent, window, cx| { this.selected_entry = Some(ix); @@ -4213,7 +5098,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); } }) }) @@ -4234,6 +5119,97 @@ impl GitPanel { cx.stop_propagation(); }, ) + .into_any_element() + } + + fn render_directory_entry( + &self, + ix: usize, + entry: &GitTreeDirEntry, + has_write_access: bool, + window: &Window, + cx: &Context, + ) -> AnyElement { + // TODO: Have not yet plugin the self.marked_entries. Not sure when and why we need that + let selected = self.selected_entry == Some(ix); + let label_color = Color::Muted; + + let id: ElementId = ElementId::Name(format!("dir_{}_{}", entry.name, ix).into()); + let checkbox_id: ElementId = + ElementId::Name(format!("dir_checkbox_{}_{}", entry.name, ix).into()); + let checkbox_wrapper_id: ElementId = + ElementId::Name(format!("dir_checkbox_wrapper_{}_{}", entry.name, ix).into()); + + let selected_bg_alpha = 0.08; + let state_opacity_step = 0.04; + + let info_color = cx.theme().status().info; + let colors = cx.theme().colors(); + + let (base_bg, hover_bg, active_bg) = if selected { + ( + info_color.alpha(selected_bg_alpha), + info_color.alpha(selected_bg_alpha + state_opacity_step), + info_color.alpha(selected_bg_alpha + state_opacity_step * 2.0), + ) + } else { + ( + colors.ghost_element_background, + colors.ghost_element_hover, + colors.ghost_element_active, + ) + }; + + let folder_icon = if entry.expanded { + IconName::FolderOpen + } else { + IconName::Folder + }; + + let stage_status = if let Some(repo) = &self.active_repository { + self.stage_status_for_directory(entry, repo.read(cx)) + } else { + util::debug_panic!( + "Won't have entries to render without an active repository in Git Panel" + ); + StageStatus::PartiallyStaged + }; + + let toggle_state: ToggleState = match stage_status { + StageStatus::Staged => ToggleState::Selected, + StageStatus::Unstaged => ToggleState::Unselected, + StageStatus::PartiallyStaged => ToggleState::Indeterminate, + }; + + let name_row = h_flex() + .min_w_0() + .gap_1() + .pl(px(entry.depth as f32 * TREE_INDENT)) + .child( + Icon::new(folder_icon) + .size(IconSize::Small) + .color(Color::Muted), + ) + .child(self.entry_label(entry.name.clone(), label_color).truncate()); + + h_flex() + .id(id) + .h(self.list_item_height()) + .min_w_0() + .w_full() + .pl_3() + .pr_1() + .gap_1p5() + .justify_between() + .border_1() + .border_r_2() + .when(selected && self.focus_handle.is_focused(window), |el| { + el.border_color(cx.theme().colors().panel_focused_border) + }) + .bg(base_bg) + .hover(|s| s.bg(hover_bg)) + .active(|s| s.bg(active_bg)) + .child(name_row) .child( div() .id(checkbox_wrapper_id) @@ -4241,70 +5217,49 @@ impl GitPanel { .occlude() .cursor_pointer() .child( - Checkbox::new(checkbox_id, is_staged) + Checkbox::new(checkbox_id, toggle_state) .disabled(!has_write_access) .fill() .elevation(ElevationIndex::Surface) - .on_click_ext({ + .on_click({ let entry = entry.clone(); let this = cx.weak_entity(); - move |_, click, window, cx| { + move |_, window, cx| { this.update(cx, |this, cx| { if !has_write_access { return; } - if click.modifiers().shift { - this.stage_bulk(ix, cx); - } else { - this.toggle_staged_for_entry( - &GitListEntry::Status(entry.clone()), - window, - cx, - ); - } + this.toggle_staged_for_entry( + &GitListEntry::Directory(entry.clone()), + window, + cx, + ); cx.stop_propagation(); }) .ok(); } }) .tooltip(move |_window, cx| { - // If is_staging_or_staged is None, this implies the file was partially staged, and so - // we allow the user to stage it in full by displaying `Stage` in the tooltip. - let action = if is_staging_or_staged.unwrap_or(false) { - "Unstage" - } else { - "Stage" + let action = match stage_status { + StageStatus::Staged => "Unstage", + StageStatus::Unstaged | StageStatus::PartiallyStaged => "Stage", }; - let tooltip_name = action.to_string(); - - Tooltip::for_action(tooltip_name, &ToggleStaged, cx) + Tooltip::simple(format!("{action} folder"), cx) }), ), ) - .child(git_status_icon(status)) - .child( - h_flex() - .items_center() - .flex_1() - .child(h_flex().items_center().flex_1().map(|this| { - self.path_formatted( - this, - entry.parent_dir(path_style), - path_color, - display_name, - label_color, - path_style, - git_path_style, - status.is_deleted(), - ) - })), - ) + .on_click({ + let key = entry.key.clone(); + cx.listener(move |this, _event: &ClickEvent, window, cx| { + this.selected_entry = Some(ix); + this.toggle_directory(&key, window, cx); + }) + }) .into_any_element() } fn path_formatted( &self, - parent: Div, directory: Option, path_color: Color, file_name: String, @@ -4313,41 +5268,31 @@ impl GitPanel { git_path_style: GitPathStyle, strikethrough: bool, ) -> Div { - parent - .when(git_path_style == GitPathStyle::FileNameFirst, |this| { - this.child( - self.entry_label( - match directory.as_ref().is_none_or(|d| d.is_empty()) { - true => file_name.clone(), - false => format!("{file_name} "), - }, - label_color, - ) - .when(strikethrough, Label::strikethrough), - ) - }) - .when_some(directory, |this, dir| { - match ( - !dir.is_empty(), - git_path_style == GitPathStyle::FileNameFirst, - ) { - (true, true) => this.child( - self.entry_label(dir, path_color) - .when(strikethrough, Label::strikethrough), - ), - (true, false) => this.child( - self.entry_label( - format!("{dir}{}", path_style.primary_separator()), - path_color, - ) + let file_name_first = git_path_style == GitPathStyle::FileNameFirst; + let file_path_first = git_path_style == GitPathStyle::FilePathFirst; + + let file_name = format!("{} ", file_name); + + h_flex() + .min_w_0() + .overflow_hidden() + .when(file_path_first, |this| this.flex_row_reverse()) + .child( + div().flex_none().child( + self.entry_label(file_name, label_color) .when(strikethrough, Label::strikethrough), - ), - _ => this, - } - }) - .when(git_path_style == GitPathStyle::FilePathFirst, |this| { + ), + ) + .when_some(directory, |this, dir| { + let path_name = if file_name_first { + dir + } else { + format!("{dir}{}", path_style.primary_separator()) + }; + this.child( - self.entry_label(file_name, label_color) + self.entry_label(path_name, path_color) + .truncate() .when(strikethrough, Label::strikethrough), ) }) @@ -4361,6 +5306,9 @@ impl GitPanel { self.amend_pending } + /// Sets the pending amend state, ensuring that the original commit message + /// is either saved, when `value` is `true` and there's no pending amend, or + /// restored, when `value` is `false` and there's a pending amend. pub fn set_amend_pending(&mut self, value: bool, cx: &mut Context) { if value && !self.amend_pending { let current_message = self.commit_message_buffer(cx).read(cx).text(); @@ -4444,7 +5392,7 @@ impl GitPanel { let Some(op) = self.bulk_staging.as_ref() else { return; }; - let Some(mut anchor_index) = self.entry_by_path(&op.anchor, cx) else { + let Some(mut anchor_index) = self.entry_by_path(&op.anchor) else { return; }; if let Some(entry) = self.entries.get(index) @@ -4478,7 +5426,7 @@ impl GitPanel { pub(crate) fn toggle_amend_pending(&mut self, cx: &mut Context) { self.set_amend_pending(!self.amend_pending, cx); if self.amend_pending { - self.load_last_commit_message_if_empty(cx); + self.load_last_commit_message(cx); } } } @@ -4509,8 +5457,8 @@ impl Render for GitPanel { .when(has_write_access && !project.is_read_only(cx), |this| { this.on_action(cx.listener(Self::toggle_staged_for_selected)) .on_action(cx.listener(Self::stage_range)) - .on_action(cx.listener(GitPanel::commit)) - .on_action(cx.listener(GitPanel::amend)) + .on_action(cx.listener(GitPanel::on_commit)) + .on_action(cx.listener(GitPanel::on_amend)) .on_action(cx.listener(GitPanel::toggle_signoff_enabled)) .on_action(cx.listener(Self::stage_all)) .on_action(cx.listener(Self::unstage_all)) @@ -4524,10 +5472,16 @@ 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)) .on_action(cx.listener(Self::select_last)) + .on_action(cx.listener(Self::first_entry)) + .on_action(cx.listener(Self::next_entry)) + .on_action(cx.listener(Self::previous_entry)) + .on_action(cx.listener(Self::last_entry)) .on_action(cx.listener(Self::close_panel)) .on_action(cx.listener(Self::open_diff)) .on_action(cx.listener(Self::open_file)) @@ -4539,6 +5493,7 @@ impl Render for GitPanel { git_panel.on_action(cx.listener(Self::toggle_fill_co_authors)) }) .on_action(cx.listener(Self::toggle_sort_by_path)) + .on_action(cx.listener(Self::toggle_tree_view)) .size_full() .overflow_hidden() .bg(cx.theme().colors().panel_background) @@ -4677,6 +5632,7 @@ impl GitPanelMessageTooltip { window: &mut Window, cx: &mut App, ) -> Entity { + let remote_url = repository.read(cx).default_remote_url(); cx.new(|cx| { cx.spawn_in(window, async move |this, cx| { let (details, workspace) = git_panel.update(cx, |git_panel, cx| { @@ -4686,16 +5642,21 @@ impl GitPanelMessageTooltip { ) })?; let details = details.await?; + let provider_registry = cx + .update(|_, app| GitHostingProviderRegistry::default_global(app)) + .ok(); let commit_details = crate::commit_tooltip::CommitDetails { sha: details.sha.clone(), author_name: details.author_name.clone(), author_email: details.author_email.clone(), commit_time: OffsetDateTime::from_unix_timestamp(details.commit_timestamp)?, - message: Some(ParsedCommitMessage { - message: details.message, - ..Default::default() - }), + message: Some(ParsedCommitMessage::parse( + details.sha.to_string(), + details.message.to_string(), + remote_url.as_deref(), + provider_registry, + )), }; this.update(cx, |this: &mut GitPanelMessageTooltip, cx| { @@ -4768,10 +5729,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() @@ -4859,7 +5824,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), @@ -5850,6 +6819,94 @@ mod tests { }); } + #[gpui::test] + async fn test_amend(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + "/root", + json!({ + "project": { + ".git": {}, + "src": { + "main.rs": "fn main() {}" + } + } + }), + ) + .await; + + fs.set_status_for_repo( + Path::new(path!("/root/project/.git")), + &[("src/main.rs", StatusCode::Modified.worktree())], + ); + + let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await; + let workspace = + cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + // Wait for the project scanning to finish so that `head_commit(cx)` is + // actually set, otherwise no head commit would be available from which + // to fetch the latest commit message from. + cx.executor().run_until_parked(); + + let panel = workspace.update(cx, GitPanel::new).unwrap(); + panel.read_with(cx, |panel, cx| { + assert!(panel.active_repository.is_some()); + assert!(panel.head_commit(cx).is_some()); + }); + + panel.update_in(cx, |panel, window, cx| { + // Update the commit editor's message to ensure that its contents + // are later restored, after amending is finished. + panel.commit_message_buffer(cx).update(cx, |buffer, cx| { + buffer.set_text("refactor: update main.rs", cx); + }); + + // Start amending the previous commit. + panel.focus_editor(&Default::default(), window, cx); + panel.on_amend(&Amend, window, cx); + }); + + // Since `GitPanel.amend` attempts to fetch the latest commit message in + // a background task, we need to wait for it to complete before being + // able to assert that the commit message editor's state has been + // updated. + cx.run_until_parked(); + + panel.update_in(cx, |panel, window, cx| { + assert_eq!( + panel.commit_message_buffer(cx).read(cx).text(), + "initial commit" + ); + assert_eq!( + panel.original_commit_message, + Some("refactor: update main.rs".to_string()) + ); + + // Finish amending the previous commit. + panel.focus_editor(&Default::default(), window, cx); + panel.on_amend(&Amend, window, cx); + }); + + // Since the actual commit logic is run in a background task, we need to + // await its completion to actually ensure that the commit message + // editor's contents are set to the original message and haven't been + // cleared. + cx.run_until_parked(); + + panel.update_in(cx, |panel, _window, cx| { + // After amending, the commit editor's message should be restored to + // the original message. + assert_eq!( + panel.commit_message_buffer(cx).read(cx).text(), + "refactor: update main.rs" + ); + assert!(panel.original_commit_message.is_none()); + }); + } + #[gpui::test] async fn test_open_diff(cx: &mut TestAppContext) { init_test(cx); @@ -5896,7 +6953,7 @@ mod tests { // the Project Diff's active path. panel.update_in(cx, |panel, window, cx| { panel.selected_entry = Some(1); - panel.open_diff(&Confirm, window, cx); + panel.open_diff(&menu::Confirm, window, cx); }); cx.run_until_parked(); @@ -5912,6 +6969,128 @@ mod tests { }); } + #[gpui::test] + async fn test_tree_view_reveals_collapsed_parent_on_select_entry_by_path( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "src": { + "a": { + "foo.rs": "fn foo() {}", + }, + "b": { + "bar.rs": "fn bar() {}", + }, + }, + }), + ) + .await; + + fs.set_status_for_repo( + path!("/project/.git").as_ref(), + &[ + ("src/a/foo.rs", StatusCode::Modified.worktree()), + ("src/b/bar.rs", StatusCode::Modified.worktree()), + ], + ); + + let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await; + let workspace = + cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + cx.read(|cx| { + project + .read(cx) + .worktrees(cx) + .next() + .unwrap() + .read(cx) + .as_local() + .unwrap() + .scan_complete() + }) + .await; + + cx.executor().run_until_parked(); + + cx.update(|_window, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.git_panel.get_or_insert_default().tree_view = Some(true); + }) + }); + }); + + let panel = workspace.update(cx, GitPanel::new).unwrap(); + + let handle = cx.update_window_entity(&panel, |panel, _, _| { + std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(())) + }); + cx.executor().advance_clock(2 * UPDATE_DEBOUNCE); + handle.await; + + let src_key = panel.read_with(cx, |panel, _| { + panel + .entries + .iter() + .find_map(|entry| match entry { + GitListEntry::Directory(dir) if dir.key.path == repo_path("src") => { + Some(dir.key.clone()) + } + _ => None, + }) + .expect("src directory should exist in tree view") + }); + + panel.update_in(cx, |panel, window, cx| { + panel.toggle_directory(&src_key, window, cx); + }); + + panel.read_with(cx, |panel, _| { + let state = panel + .view_mode + .tree_state() + .expect("tree view state should exist"); + assert_eq!(state.expanded_dirs.get(&src_key).copied(), Some(false)); + }); + + let worktree_id = + cx.read(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id()); + let project_path = ProjectPath { + worktree_id, + path: RelPath::unix("src/a/foo.rs").unwrap().into_arc(), + }; + + panel.update_in(cx, |panel, window, cx| { + panel.select_entry_by_path(project_path, window, cx); + }); + + panel.read_with(cx, |panel, _| { + let state = panel + .view_mode + .tree_state() + .expect("tree view state should exist"); + assert_eq!(state.expanded_dirs.get(&src_key).copied(), Some(true)); + + let selected_ix = panel.selected_entry.expect("selection should be set"); + assert!(state.logical_indices.contains(&selected_ix)); + + let selected_entry = panel + .entries + .get(selected_ix) + .and_then(|entry| entry.status_entry()) + .expect("selected entry should be a status entry"); + assert_eq!(selected_entry.repo_path, repo_path("src/a/foo.rs")); + }); + } + fn assert_entry_paths(entries: &[GitListEntry], expected_paths: &[Option<&str>]) { assert_eq!(entries.len(), expected_paths.len()); for (entry, expected_path) in entries.iter().zip(expected_paths) { diff --git a/crates/git_ui/src/git_panel_settings.rs b/crates/git_ui/src/git_panel_settings.rs index 2a6c1e8882b3f9cce02060dbf8efb6a4826b6995..6b5334e55544b465864fe3afb780c4673bb5961e 100644 --- a/crates/git_ui/src/git_panel_settings.rs +++ b/crates/git_ui/src/git_panel_settings.rs @@ -24,6 +24,7 @@ pub struct GitPanelSettings { pub fallback_branch_name: String, pub sort_by_path: bool, pub collapse_untracked_diff: bool, + pub tree_view: bool, } impl ScrollbarVisibility for GitPanelSettings { @@ -56,6 +57,7 @@ impl Settings for GitPanelSettings { fallback_branch_name: git_panel.fallback_branch_name.unwrap(), sort_by_path: git_panel.sort_by_path.unwrap(), collapse_untracked_diff: git_panel.collapse_untracked_diff.unwrap(), + tree_view: git_panel.tree_view.unwrap(), } } } 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 e560bba0d36ad9901fffa9b5aad4dbd88e3108b6..0e0632d9d049f54a648f65c55a96d639c9103e4d 100644 --- a/crates/git_ui/src/project_diff.rs +++ b/crates/git_ui/src/project_diff.rs @@ -74,6 +74,13 @@ pub struct ProjectDiff { _subscription: Subscription, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RefreshReason { + DiffChanged, + StatusesChanged, + EditorSaved, +} + const CONFLICT_SORT_PREFIX: u64 = 1; const TRACKED_SORT_PREFIX: u64 = 2; const NEW_SORT_PREFIX: u64 = 3; @@ -149,6 +156,10 @@ impl ProjectDiff { .items_of_type::(cx) .find(|item| matches!(item.read(cx).diff_base(cx), DiffBase::Head)); let project_diff = if let Some(existing) = existing { + existing.update(cx, |project_diff, cx| { + project_diff.move_to_beginning(window, cx); + }); + workspace.activate_item(&existing, true, true, window, cx); existing } else { @@ -278,7 +289,7 @@ impl ProjectDiff { BranchDiffEvent::FileListChanged => { this._task = window.spawn(cx, { let this = cx.weak_entity(); - async |cx| Self::refresh(this, cx).await + async |cx| Self::refresh(this, RefreshReason::StatusesChanged, cx).await }) } }, @@ -297,7 +308,7 @@ impl ProjectDiff { this._task = { window.spawn(cx, { let this = cx.weak_entity(); - async |cx| Self::refresh(this, cx).await + async |cx| Self::refresh(this, RefreshReason::StatusesChanged, cx).await }) } } @@ -308,7 +319,7 @@ impl ProjectDiff { let task = window.spawn(cx, { let this = cx.weak_entity(); - async |cx| Self::refresh(this, cx).await + async |cx| Self::refresh(this, RefreshReason::StatusesChanged, cx).await }); Self { @@ -358,6 +369,14 @@ impl ProjectDiff { }) } + fn move_to_beginning(&mut self, window: &mut Window, cx: &mut Context) { + self.editor.update(cx, |editor, cx| { + editor.primary_editor().update(cx, |editor, cx| { + editor.move_to_beginning(&Default::default(), window, cx); + }); + }); + } + fn move_to_path(&mut self, path_key: PathKey, window: &mut Window, cx: &mut Context) { if let Some(position) = self.multibuffer.read(cx).location_for_path(&path_key, cx) { self.editor.update(cx, |editor, cx| { @@ -448,24 +467,32 @@ impl ProjectDiff { window: &mut Window, cx: &mut Context, ) { - if let EditorEvent::SelectionsChanged { local: true } = event { - let Some(project_path) = self.active_path(cx) else { - return; - }; - self.workspace - .update(cx, |workspace, cx| { - if let Some(git_panel) = workspace.panel::(cx) { - git_panel.update(cx, |git_panel, cx| { - git_panel.select_entry_by_path(project_path, window, cx) - }) - } - }) - .ok(); + match event { + EditorEvent::SelectionsChanged { local: true } => { + let Some(project_path) = self.active_path(cx) else { + return; + }; + self.workspace + .update(cx, |workspace, cx| { + if let Some(git_panel) = workspace.panel::(cx) { + git_panel.update(cx, |git_panel, cx| { + git_panel.select_entry_by_path(project_path, window, cx) + }) + } + }) + .ok(); + } + EditorEvent::Saved => { + self._task = cx.spawn_in(window, async move |this, cx| { + Self::refresh(this, RefreshReason::EditorSaved, cx).await + }); + } + _ => {} } 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) } } @@ -482,7 +509,7 @@ impl ProjectDiff { let subscription = cx.subscribe_in(&diff, window, move |this, _, _, window, cx| { this._task = window.spawn(cx, { let this = cx.weak_entity(); - async |cx| Self::refresh(this, cx).await + async |cx| Self::refresh(this, RefreshReason::DiffChanged, cx).await }) }); self.buffer_diff_subscriptions @@ -570,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) { @@ -581,14 +608,23 @@ impl ProjectDiff { } } - pub async fn refresh(this: WeakEntity, cx: &mut AsyncWindowContext) -> Result<()> { + pub async fn refresh( + this: WeakEntity, + reason: RefreshReason, + cx: &mut AsyncWindowContext, + ) -> Result<()> { let mut path_keys = Vec::new(); let buffers_to_load = this.update(cx, |this, cx| { let (repo, buffers_to_load) = this.branch_diff.update(cx, |branch_diff, cx| { let load_buffers = branch_diff.load_buffers(cx); (branch_diff.repo().cloned(), load_buffers) }); - let mut previous_paths = this.multibuffer.read(cx).paths().collect::>(); + let mut previous_paths = this + .multibuffer + .read(cx) + .paths() + .cloned() + .collect::>(); if let Some(repo) = repo { let repo = repo.read(cx); @@ -605,8 +641,20 @@ impl ProjectDiff { this.multibuffer.update(cx, |multibuffer, cx| { for path in previous_paths { + if let Some(buffer) = multibuffer.buffer_for_path(&path, cx) { + let skip = match reason { + RefreshReason::DiffChanged | RefreshReason::EditorSaved => { + buffer.read(cx).is_dirty() + } + RefreshReason::StatusesChanged => false, + }; + if skip { + continue; + } + } + this.buffer_diff_subscriptions.remove(&path.path); - multibuffer.remove_excerpts_for_path(path, cx); + multibuffer.remove_excerpts_for_path(path.clone(), cx); } }); buffers_to_load @@ -619,7 +667,27 @@ impl ProjectDiff { yield_now().await; cx.update(|window, cx| { this.update(cx, |this, cx| { - this.register_buffer(path_key, entry.file_status, buffer, diff, window, cx) + let multibuffer = this.multibuffer.read(cx); + let skip = multibuffer.buffer(buffer.read(cx).remote_id()).is_some() + && multibuffer + .diff_for(buffer.read(cx).remote_id()) + .is_some_and(|prev_diff| prev_diff.entity_id() == diff.entity_id()) + && match reason { + RefreshReason::DiffChanged | RefreshReason::EditorSaved => { + buffer.read(cx).is_dirty() + } + RefreshReason::StatusesChanged => false, + }; + if !skip { + this.register_buffer( + path_key, + entry.file_status, + buffer, + diff, + window, + cx, + ) + } }) .ok(); })?; @@ -637,14 +705,17 @@ impl ProjectDiff { pub fn excerpt_paths(&self, cx: &App) -> Vec> { self.multibuffer .read(cx) - .excerpt_paths() + .paths() .map(|key| key.path.clone()) .collect() } } fn sort_prefix(repo: &Repository, repo_path: &RepoPath, status: FileStatus, cx: &App) -> u64 { - if GitPanelSettings::get_global(cx).sort_by_path { + let settings = GitPanelSettings::get_global(cx); + + // Tree view can only sort by path + if settings.sort_by_path || settings.tree_view { TRACKED_SORT_PREFIX } else if repo.had_conflict_on_last_merge_head_change(repo_path) { CONFLICT_SORT_PREFIX @@ -912,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, @@ -1082,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| { @@ -1647,9 +1718,13 @@ mod tests { .unindent(), ); - editor.update_in(cx, |editor, window, cx| { - editor.git_restore(&Default::default(), window, cx); - }); + editor + .update_in(cx, |editor, window, cx| { + editor.git_restore(&Default::default(), window, cx); + editor.save(SaveOptions::default(), project.clone(), window, cx) + }) + .await + .unwrap(); cx.run_until_parked(); assert_state_with_diff(&editor, cx, &"ˇ".unindent()); @@ -1838,8 +1913,8 @@ mod tests { cx, &" - original - + ˇdifferent - " + + different + ˇ" .unindent(), ); } diff --git a/crates/git_ui/src/worktree_picker.rs b/crates/git_ui/src/worktree_picker.rs index f6b3e47dec386d906e55e555600a93059d0766d0..fef5e16c80ddd26ae6dd0b2a5c0ad1d8e5b21b2c 100644 --- a/crates/git_ui/src/worktree_picker.rs +++ b/crates/git_ui/src/worktree_picker.rs @@ -1,4 +1,5 @@ use anyhow::Context as _; +use collections::HashSet; use fuzzy::StringMatchCandidate; use git::repository::Worktree as GitWorktree; @@ -9,7 +10,11 @@ use gpui::{ actions, rems, }; use picker::{Picker, PickerDelegate, PickerEditorPosition}; -use project::{DirectoryLister, git_store::Repository}; +use project::{ + DirectoryLister, + git_store::Repository, + trusted_worktrees::{PathTrust, RemoteHostLocation, TrustedWorktrees}, +}; use recent_projects::{RemoteConnectionModal, connect}; use remote::{RemoteConnectionOptions, remote_client::ConnectionIdentifier}; use std::{path::PathBuf, sync::Arc}; @@ -219,7 +224,6 @@ impl WorktreeListDelegate { window: &mut Window, cx: &mut Context>, ) { - let workspace = self.workspace.clone(); let Some(repo) = self.repo.clone() else { return; }; @@ -247,6 +251,7 @@ impl WorktreeListDelegate { let branch = worktree_branch.to_string(); let window_handle = window.window_handle(); + let workspace = self.workspace.clone(); cx.spawn_in(window, async move |_, cx| { let Some(paths) = worktree_path.await? else { return anyhow::Ok(()); @@ -257,8 +262,32 @@ impl WorktreeListDelegate { repo.create_worktree(branch.clone(), path.clone(), commit) })? .await??; - - let final_path = path.join(branch); + let new_worktree_path = path.join(branch); + + workspace.update(cx, |workspace, cx| { + if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) { + let repo_path = &repo.read(cx).snapshot().work_directory_abs_path; + let project = workspace.project(); + if let Some((parent_worktree, _)) = + project.read(cx).find_worktree(repo_path, cx) + { + trusted_worktrees.update(cx, |trusted_worktrees, cx| { + if trusted_worktrees.can_trust(parent_worktree.read(cx).id(), cx) { + trusted_worktrees.trust( + HashSet::from_iter([PathTrust::AbsPath( + new_worktree_path.clone(), + )]), + project + .read(cx) + .remote_connection_options(cx) + .map(RemoteHostLocation::from), + cx, + ); + } + }); + } + } + })?; let (connection_options, app_state, is_local) = workspace.update(cx, |workspace, cx| { @@ -274,7 +303,7 @@ impl WorktreeListDelegate { .update_in(cx, |workspace, window, cx| { workspace.open_workspace_for_paths( replace_current_window, - vec![final_path], + vec![new_worktree_path], window, cx, ) @@ -283,7 +312,7 @@ impl WorktreeListDelegate { } else if let Some(connection_options) = connection_options { open_remote_worktree( connection_options, - vec![final_path], + vec![new_worktree_path], app_state, window_handle, replace_current_window, @@ -421,6 +450,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/google_ai/src/google_ai.rs b/crates/google_ai/src/google_ai.rs index 3eff860e16f15fae76d8f9cb2523d2b91b611125..a7d82c584b208cec33075d65a53a74c963ec05b5 100644 --- a/crates/google_ai/src/google_ai.rs +++ b/crates/google_ai/src/google_ai.rs @@ -512,6 +512,8 @@ pub enum Model { Gemini25Pro, #[serde(rename = "gemini-3-pro-preview")] Gemini3Pro, + #[serde(rename = "gemini-3-flash-preview")] + Gemini3Flash, #[serde(rename = "custom")] Custom { name: String, @@ -534,6 +536,7 @@ impl Model { Self::Gemini25Flash => "gemini-2.5-flash", Self::Gemini25Pro => "gemini-2.5-pro", Self::Gemini3Pro => "gemini-3-pro-preview", + Self::Gemini3Flash => "gemini-3-flash-preview", Self::Custom { name, .. } => name, } } @@ -543,6 +546,7 @@ impl Model { Self::Gemini25Flash => "gemini-2.5-flash", Self::Gemini25Pro => "gemini-2.5-pro", Self::Gemini3Pro => "gemini-3-pro-preview", + Self::Gemini3Flash => "gemini-3-flash-preview", Self::Custom { name, .. } => name, } } @@ -553,6 +557,7 @@ impl Model { Self::Gemini25Flash => "Gemini 2.5 Flash", Self::Gemini25Pro => "Gemini 2.5 Pro", Self::Gemini3Pro => "Gemini 3 Pro", + Self::Gemini3Flash => "Gemini 3 Flash", Self::Custom { name, display_name, .. } => display_name.as_ref().unwrap_or(name), @@ -561,20 +566,22 @@ impl Model { pub fn max_token_count(&self) -> u64 { match self { - Self::Gemini25FlashLite => 1_048_576, - Self::Gemini25Flash => 1_048_576, - Self::Gemini25Pro => 1_048_576, - Self::Gemini3Pro => 1_048_576, + Self::Gemini25FlashLite + | Self::Gemini25Flash + | Self::Gemini25Pro + | Self::Gemini3Pro + | Self::Gemini3Flash => 1_048_576, Self::Custom { max_tokens, .. } => *max_tokens, } } pub fn max_output_tokens(&self) -> Option { match self { - Model::Gemini25FlashLite => Some(65_536), - Model::Gemini25Flash => Some(65_536), - Model::Gemini25Pro => Some(65_536), - Model::Gemini3Pro => Some(65_536), + Model::Gemini25FlashLite + | Model::Gemini25Flash + | Model::Gemini25Pro + | Model::Gemini3Pro + | Model::Gemini3Flash => Some(65_536), Model::Custom { .. } => None, } } @@ -599,6 +606,7 @@ impl Model { budget_tokens: None, } } + Self::Gemini3Flash => GoogleModelMode::Default, Self::Custom { mode, .. } => *mode, } } diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index 4985cc07383aac56d6975fa09a410a0cee6c549d..40376f476b6d80f6b5170840f295a71acdfebb7d 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -21,7 +21,6 @@ default = ["font-kit", "wayland", "x11", "windows-manifest"] test-support = [ "leak-detection", "collections/test-support", - "rand", "util/test-support", "http_client/test-support", "wayland", @@ -109,7 +108,7 @@ parking = "2.0.0" parking_lot.workspace = true postage.workspace = true profiling.workspace = true -rand = { optional = true, workspace = true } +rand.workspace = true raw-window-handle = "0.6" refineable.workspace = true resvg = { version = "0.45.0", default-features = false, features = [ @@ -158,8 +157,10 @@ media.workspace = true objc.workspace = true objc2 = { version = "0.6", optional = true } objc2-metal = { version = "0.3", optional = true } +mach2.workspace = true #TODO: replace with "objc2" metal.workspace = true +flume = "0.11" [target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "macos"))'.dependencies] pathfinder_geometry = "0.5" @@ -197,14 +198,14 @@ wayland-backend = { version = "0.3.3", features = [ "client_system", "dlopen", ], optional = true } -wayland-client = { version = "0.31.2", optional = true } -wayland-cursor = { version = "0.31.1", optional = true } -wayland-protocols = { version = "0.31.2", features = [ +wayland-client = { version = "0.31.11", optional = true } +wayland-cursor = { version = "0.31.11", optional = true } +wayland-protocols = { version = "0.32.9", features = [ "client", "staging", "unstable", ], optional = true } -wayland-protocols-plasma = { version = "0.2.0", features = [ +wayland-protocols-plasma = { version = "0.3.9", features = [ "client", ], optional = true } wayland-protocols-wlr = { version = "0.3.9", features = [ @@ -329,3 +330,7 @@ path = "examples/window_shadow.rs" [[example]] name = "grid_layout" path = "examples/grid_layout.rs" + +[[example]] +name = "mouse_pressure" +path = "examples/mouse_pressure.rs" diff --git a/crates/gpui/build.rs b/crates/gpui/build.rs index ec35ec0bc63113582a945c71198cd7bc14301dcc..c7ae7ac9f239f2f6ce3880f9329f2ba92b2174f3 100644 --- a/crates/gpui/build.rs +++ b/crates/gpui/build.rs @@ -84,6 +84,8 @@ mod macos { .allowlist_var("_dispatch_main_q") .allowlist_var("_dispatch_source_type_data_add") .allowlist_var("DISPATCH_QUEUE_PRIORITY_HIGH") + .allowlist_var("DISPATCH_QUEUE_PRIORITY_DEFAULT") + .allowlist_var("DISPATCH_QUEUE_PRIORITY_LOW") .allowlist_var("DISPATCH_TIME_NOW") .allowlist_function("dispatch_get_global_queue") .allowlist_function("dispatch_async_f") 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/mouse_pressure.rs b/crates/gpui/examples/mouse_pressure.rs new file mode 100644 index 0000000000000000000000000000000000000000..12790f988eedac3009ae619cadbc6f40c4af2e4b --- /dev/null +++ b/crates/gpui/examples/mouse_pressure.rs @@ -0,0 +1,66 @@ +use gpui::{ + App, Application, Bounds, Context, MousePressureEvent, PressureStage, Window, WindowBounds, + WindowOptions, div, prelude::*, px, rgb, size, +}; + +struct MousePressureExample { + pressure_stage: PressureStage, + pressure_amount: f32, +} + +impl Render for MousePressureExample { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .flex() + .flex_col() + .gap_3() + .bg(rgb(0x505050)) + .size(px(500.0)) + .justify_center() + .items_center() + .shadow_lg() + .border_1() + .border_color(rgb(0x0000ff)) + .text_xl() + .text_color(rgb(0xffffff)) + .child(format!("Pressure stage: {:?}", &self.pressure_stage)) + .child(format!("Pressure amount: {:.2}", &self.pressure_amount)) + .on_mouse_pressure(cx.listener(Self::on_mouse_pressure)) + } +} + +impl MousePressureExample { + fn on_mouse_pressure( + &mut self, + pressure_event: &MousePressureEvent, + _window: &mut Window, + cx: &mut Context, + ) { + self.pressure_amount = pressure_event.pressure; + self.pressure_stage = pressure_event.stage; + + cx.notify(); + } +} + +fn main() { + Application::new().run(|cx: &mut App| { + let bounds = Bounds::centered(None, size(px(500.), px(500.0)), cx); + + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + ..Default::default() + }, + |_, cx| { + cx.new(|_| MousePressureExample { + pressure_stage: PressureStage::Zero, + pressure_amount: 0.0, + }) + }, + ) + .unwrap(); + + cx.activate(true); + }); +} 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/examples/window.rs b/crates/gpui/examples/window.rs index 06003c4663ee5711283a85684c25b9f5d8c5b743..3f41f3d55f240e688965ac8248ac3d5b4ef40401 100644 --- a/crates/gpui/examples/window.rs +++ b/crates/gpui/examples/window.rs @@ -5,6 +5,7 @@ use gpui::{ struct SubWindow { custom_titlebar: bool, + is_dialog: bool, } fn button(text: &str, on_click: impl Fn(&mut Window, &mut App) + 'static) -> impl IntoElement { @@ -23,7 +24,10 @@ fn button(text: &str, on_click: impl Fn(&mut Window, &mut App) + 'static) -> imp } impl Render for SubWindow { - fn render(&mut self, _window: &mut Window, _: &mut Context) -> impl IntoElement { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let window_bounds = + WindowBounds::Windowed(Bounds::centered(None, size(px(250.0), px(200.0)), cx)); + div() .flex() .flex_col() @@ -52,8 +56,28 @@ impl Render for SubWindow { .child( div() .p_8() + .flex() + .flex_col() .gap_2() .child("SubWindow") + .when(self.is_dialog, |div| { + div.child(button("Open Nested Dialog", move |_, cx| { + cx.open_window( + WindowOptions { + window_bounds: Some(window_bounds), + kind: WindowKind::Dialog, + ..Default::default() + }, + |_, cx| { + cx.new(|_| SubWindow { + custom_titlebar: false, + is_dialog: true, + }) + }, + ) + .unwrap(); + })) + }) .child(button("Close", |window, _| { window.remove_window(); })), @@ -86,6 +110,7 @@ impl Render for WindowDemo { |_, cx| { cx.new(|_| SubWindow { custom_titlebar: false, + is_dialog: false, }) }, ) @@ -101,6 +126,39 @@ impl Render for WindowDemo { |_, cx| { cx.new(|_| SubWindow { custom_titlebar: false, + is_dialog: false, + }) + }, + ) + .unwrap(); + })) + .child(button("Floating", move |_, cx| { + cx.open_window( + WindowOptions { + window_bounds: Some(window_bounds), + kind: WindowKind::Floating, + ..Default::default() + }, + |_, cx| { + cx.new(|_| SubWindow { + custom_titlebar: false, + is_dialog: false, + }) + }, + ) + .unwrap(); + })) + .child(button("Dialog", move |_, cx| { + cx.open_window( + WindowOptions { + window_bounds: Some(window_bounds), + kind: WindowKind::Dialog, + ..Default::default() + }, + |_, cx| { + cx.new(|_| SubWindow { + custom_titlebar: false, + is_dialog: true, }) }, ) @@ -116,6 +174,7 @@ impl Render for WindowDemo { |_, cx| { cx.new(|_| SubWindow { custom_titlebar: true, + is_dialog: false, }) }, ) @@ -131,6 +190,7 @@ impl Render for WindowDemo { |_, cx| { cx.new(|_| SubWindow { custom_titlebar: false, + is_dialog: false, }) }, ) @@ -147,6 +207,7 @@ impl Render for WindowDemo { |_, cx| { cx.new(|_| SubWindow { custom_titlebar: false, + is_dialog: false, }) }, ) @@ -162,6 +223,7 @@ impl Render for WindowDemo { |_, cx| { cx.new(|_| SubWindow { custom_titlebar: false, + is_dialog: false, }) }, ) @@ -177,6 +239,7 @@ impl Render for WindowDemo { |_, cx| { cx.new(|_| SubWindow { custom_titlebar: false, + is_dialog: false, }) }, ) diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 2f4c7611dcf9d24302b3dda1d05c4c2b8711a68d..75600a9ee1b440a092a89456cbe8fbabe6fdccfa 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -38,10 +38,11 @@ use crate::{ AssetSource, BackgroundExecutor, Bounds, ClipboardItem, CursorStyle, DispatchPhase, DisplayId, EventEmitter, FocusHandle, FocusMap, ForegroundExecutor, Global, KeyBinding, KeyContext, Keymap, Keystroke, LayoutId, Menu, MenuItem, OwnedMenu, PathPromptOptions, Pixels, Platform, - PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, Point, PromptBuilder, - PromptButton, PromptHandle, PromptLevel, Render, RenderImage, RenderablePromptHandle, - Reservation, ScreenCaptureSource, SharedString, SubscriberSet, Subscription, SvgRenderer, Task, - TextSystem, Window, WindowAppearance, WindowHandle, WindowId, WindowInvalidator, + PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, Point, Priority, + PromptBuilder, PromptButton, PromptHandle, PromptLevel, Render, RenderImage, + RenderablePromptHandle, Reservation, ScreenCaptureSource, SharedString, SubscriberSet, + Subscription, SvgRenderer, Task, TextSystem, Window, WindowAppearance, WindowHandle, WindowId, + WindowInvalidator, colors::{Colors, GlobalColors}, current_platform, hash, init_app_menus, }; @@ -315,6 +316,7 @@ impl SystemWindowTabController { .find_map(|(group, tabs)| tabs.iter().find(|tab| tab.id == id).map(|_| group)); let current_group = current_group?; + // TODO: `.keys()` returns arbitrary order, what does "next" mean? let mut group_ids: Vec<_> = controller.tab_groups.keys().collect(); let idx = group_ids.iter().position(|g| *g == current_group)?; let next_idx = (idx + 1) % group_ids.len(); @@ -339,6 +341,7 @@ impl SystemWindowTabController { .find_map(|(group, tabs)| tabs.iter().find(|tab| tab.id == id).map(|_| group)); let current_group = current_group?; + // TODO: `.keys()` returns arbitrary order, what does "previous" mean? let mut group_ids: Vec<_> = controller.tab_groups.keys().collect(); let idx = group_ids.iter().position(|g| *g == current_group)?; let prev_idx = if idx == 0 { @@ -360,12 +363,9 @@ impl SystemWindowTabController { /// Get all tabs in the same window. pub fn tabs(&self, id: WindowId) -> Option<&Vec> { - let tab_group = self - .tab_groups - .iter() - .find_map(|(group, tabs)| tabs.iter().find(|tab| tab.id == id).map(|_| *group))?; - - self.tab_groups.get(&tab_group) + self.tab_groups + .values() + .find(|tabs| tabs.iter().any(|tab| tab.id == id)) } /// Initialize the visibility of the system window tab controller. @@ -440,7 +440,7 @@ impl SystemWindowTabController { /// Insert a tab into a tab group. pub fn add_tab(cx: &mut App, id: WindowId, tabs: Vec) { let mut controller = cx.global_mut::(); - let Some(tab) = tabs.clone().into_iter().find(|tab| tab.id == id) else { + let Some(tab) = tabs.iter().find(|tab| tab.id == id).cloned() else { return; }; @@ -503,16 +503,14 @@ impl SystemWindowTabController { return; }; + let initial_tabs_len = initial_tabs.len(); let mut all_tabs = initial_tabs.clone(); - for tabs in controller.tab_groups.values() { - all_tabs.extend( - tabs.iter() - .filter(|tab| !initial_tabs.contains(tab)) - .cloned(), - ); + + for (_, mut tabs) in controller.tab_groups.drain() { + tabs.retain(|tab| !all_tabs[..initial_tabs_len].contains(tab)); + all_tabs.extend(tabs); } - controller.tab_groups.clear(); controller.tab_groups.insert(0, all_tabs); } @@ -1494,6 +1492,24 @@ impl App { .spawn(async move { f(&mut cx).await }) } + /// Spawns the future returned by the given function on the main thread with + /// the given priority. The closure will be invoked with [AsyncApp], which + /// allows the application state to be accessed across await points. + pub fn spawn_with_priority(&self, priority: Priority, f: AsyncFn) -> Task + where + AsyncFn: AsyncFnOnce(&mut AsyncApp) -> R + 'static, + R: 'static, + { + if self.quitting { + debug_panic!("Can't spawn on main thread after on_app_quit") + }; + + let mut cx = self.to_async(); + + self.foreground_executor + .spawn_with_priority(priority, async move { f(&mut cx).await }) + } + /// Schedules the given function to be run at the end of the current effect cycle, allowing entities /// that are currently on the stack to be returned to the app. pub fn defer(&mut self, f: impl FnOnce(&mut App) + 'static) { @@ -1758,7 +1774,10 @@ impl App { /// Register a global handler for actions invoked via the keyboard. These handlers are run at /// the end of the bubble phase for actions, and so will only be invoked if there are no other /// handlers or if they called `cx.propagate()`. - pub fn on_action(&mut self, listener: impl Fn(&A, &mut Self) + 'static) { + pub fn on_action( + &mut self, + listener: impl Fn(&A, &mut Self) + 'static, + ) -> &mut Self { self.global_action_listeners .entry(TypeId::of::()) .or_default() @@ -1768,6 +1787,7 @@ impl App { listener(action, cx) } })); + self } /// Event handlers propagate events by default. Call this method to stop dispatching to @@ -1877,8 +1897,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 65bb5521e32bb6fcfac2bcd95009949499589df1..b780ca426c15c99030f24ee48bde978ad38526e7 100644 --- a/crates/gpui/src/app/context.rs +++ b/crates/gpui/src/app/context.rs @@ -1,7 +1,7 @@ use crate::{ AnyView, AnyWindowHandle, AppContext, AsyncApp, DispatchPhase, Effect, EntityId, EventEmitter, - FocusHandle, FocusOutEvent, Focusable, Global, KeystrokeObserver, Reservation, SubscriberSet, - Subscription, Task, WeakEntity, WeakFocusHandle, Window, WindowHandle, + FocusHandle, FocusOutEvent, Focusable, Global, KeystrokeObserver, Priority, Reservation, + SubscriberSet, Subscription, Task, WeakEntity, WeakFocusHandle, Window, WindowHandle, }; use anyhow::Result; use futures::FutureExt; @@ -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. @@ -667,6 +667,25 @@ impl<'a, T: 'static> Context<'a, T> { window.spawn(self, async move |cx| f(view, cx).await) } + /// Schedule a future to be run asynchronously with the given priority. + /// The given callback is invoked with a [`WeakEntity`] to avoid leaking the entity for a long-running process. + /// It's also given an [`AsyncWindowContext`], which can be used to access the state of the entity across await points. + /// The returned future will be polled on the main thread. + #[track_caller] + pub fn spawn_in_with_priority( + &self, + priority: Priority, + window: &Window, + f: AsyncFn, + ) -> Task + where + R: 'static, + AsyncFn: AsyncFnOnce(WeakEntity, &mut AsyncWindowContext) -> R + 'static, + { + let view = self.weak_entity(); + window.spawn_with_priority(priority, self, async move |cx| f(view, cx).await) + } + /// Register a callback to be invoked when the given global state changes. pub fn observe_global_in( &mut self, @@ -713,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 5be2e394e8edfd26a25c70c79c321a7fb8fdc8ba..9b982f9a1ca3c14b99dfc93e938aafe4e2f75cff 100644 --- a/crates/gpui/src/app/test_context.rs +++ b/crates/gpui/src/app/test_context.rs @@ -1045,7 +1045,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/bounds_tree.rs b/crates/gpui/src/bounds_tree.rs index d621609bf7334801059513e03dfd11b4036ea816..9cf86a2cc9b6def8fbf5ca7e94f7cd19236468cc 100644 --- a/crates/gpui/src/bounds_tree.rs +++ b/crates/gpui/src/bounds_tree.rs @@ -5,14 +5,91 @@ use std::{ ops::{Add, Sub}, }; +/// Maximum children per internal node (R-tree style branching factor). +/// Higher values = shorter tree = fewer cache misses, but more work per node. +const MAX_CHILDREN: usize = 12; + +/// A spatial tree optimized for finding maximum ordering among intersecting bounds. +/// +/// This is an R-tree variant specifically designed for the use case of assigning +/// z-order to overlapping UI elements. Key optimizations: +/// - Tracks the leaf with global max ordering for O(1) fast-path queries +/// - Uses higher branching factor (4) for lower tree height +/// - Aggressive pruning during search based on max_order metadata #[derive(Debug)] pub(crate) struct BoundsTree where U: Clone + Debug + Default + PartialEq, { - root: Option, + /// All nodes stored contiguously for cache efficiency. nodes: Vec>, - stack: Vec, + /// Index of the root node, if any. + root: Option, + /// Index of the leaf with the highest ordering (for fast-path lookups). + max_leaf: Option, + /// Reusable stack for tree traversal during insertion. + insert_path: Vec, + /// Reusable stack for search operations. + search_stack: Vec, +} + +/// A node in the bounds tree. +#[derive(Debug, Clone)] +struct Node +where + U: Clone + Debug + Default + PartialEq, +{ + /// Bounding box containing this node and all descendants. + bounds: Bounds, + /// Maximum ordering value in this subtree. + max_order: u32, + /// Node-specific data. + kind: NodeKind, +} + +#[derive(Debug, Clone)] +enum NodeKind { + /// Leaf node containing actual bounds data. + Leaf { + /// The ordering assigned to this bounds. + order: u32, + }, + /// Internal node with children. + Internal { + /// Indices of child nodes (2 to MAX_CHILDREN). + children: NodeChildren, + }, +} + +/// Fixed-size array for child indices, avoiding heap allocation. +#[derive(Debug, Clone)] +struct NodeChildren { + // Keeps an invariant where the max order child is always at the end + indices: [usize; MAX_CHILDREN], + len: u8, +} + +impl NodeChildren { + fn new() -> Self { + Self { + indices: [0; MAX_CHILDREN], + len: 0, + } + } + + fn push(&mut self, index: usize) { + debug_assert!((self.len as usize) < MAX_CHILDREN); + self.indices[self.len as usize] = index; + self.len += 1; + } + + fn len(&self) -> usize { + self.len as usize + } + + fn as_slice(&self) -> &[usize] { + &self.indices[..self.len as usize] + } } impl BoundsTree @@ -26,158 +103,250 @@ where + Half + Default, { + /// Clears all nodes from the tree. pub fn clear(&mut self) { - self.root = None; self.nodes.clear(); - self.stack.clear(); + self.root = None; + self.max_leaf = None; + self.insert_path.clear(); + self.search_stack.clear(); } + /// Inserts bounds into the tree and returns its assigned ordering. + /// + /// The ordering is one greater than the maximum ordering of any + /// existing bounds that intersect with the new bounds. pub fn insert(&mut self, new_bounds: Bounds) -> u32 { - // If the tree is empty, make the root the new leaf. - let Some(mut index) = self.root else { - let new_node = self.push_leaf(new_bounds, 1); - self.root = Some(new_node); - return 1; + // Find maximum ordering among intersecting bounds + let max_intersecting = self.find_max_ordering(&new_bounds); + let ordering = max_intersecting + 1; + + // Insert the new leaf + let new_leaf_idx = self.insert_leaf(new_bounds, ordering); + + // Update max_leaf tracking + self.max_leaf = match self.max_leaf { + None => Some(new_leaf_idx), + Some(old_idx) if self.nodes[old_idx].max_order < ordering => Some(new_leaf_idx), + some => some, }; - // Search for the best place to add the new leaf based on heuristics. - let mut max_intersecting_ordering = 0; - while let Node::Internal { - left, - right, - bounds: node_bounds, - .. - } = &mut self.nodes[index] - { - let left = *left; - let right = *right; - *node_bounds = node_bounds.union(&new_bounds); - self.stack.push(index); - - // Descend to the best-fit child, based on which one would increase - // the surface area the least. This attempts to keep the tree balanced - // in terms of surface area. If there is an intersection with the other child, - // add its keys to the intersections vector. - let left_cost = new_bounds.union(self.nodes[left].bounds()).half_perimeter(); - let right_cost = new_bounds - .union(self.nodes[right].bounds()) - .half_perimeter(); - if left_cost < right_cost { - max_intersecting_ordering = - self.find_max_ordering(right, &new_bounds, max_intersecting_ordering); - index = left; - } else { - max_intersecting_ordering = - self.find_max_ordering(left, &new_bounds, max_intersecting_ordering); - index = right; + ordering + } + + /// Finds the maximum ordering among all bounds that intersect with the query. + fn find_max_ordering(&mut self, query: &Bounds) -> u32 { + let Some(root_idx) = self.root else { + return 0; + }; + + // Fast path: check if the max-ordering leaf intersects + if let Some(max_idx) = self.max_leaf { + let max_node = &self.nodes[max_idx]; + if query.intersects(&max_node.bounds) { + return max_node.max_order; } } - // We've found a leaf ('index' now refers to a leaf node). - // We'll insert a new parent node above the leaf and attach our new leaf to it. - let sibling = index; - - // Check for collision with the located leaf node - let Node::Leaf { - bounds: sibling_bounds, - order: sibling_ordering, - .. - } = &self.nodes[index] - else { - unreachable!(); - }; - if sibling_bounds.intersects(&new_bounds) { - max_intersecting_ordering = cmp::max(max_intersecting_ordering, *sibling_ordering); + // Slow path: search the tree + self.search_stack.clear(); + self.search_stack.push(root_idx); + + let mut max_found = 0u32; + + while let Some(node_idx) = self.search_stack.pop() { + let node = &self.nodes[node_idx]; + + // Pruning: skip if this subtree can't improve our result + if node.max_order <= max_found { + continue; + } + + // Spatial pruning: skip if bounds don't intersect + if !query.intersects(&node.bounds) { + continue; + } + + match &node.kind { + NodeKind::Leaf { order } => { + max_found = cmp::max(max_found, *order); + } + NodeKind::Internal { children } => { + // Children are maintained with highest max_order at the end. + // Push in forward order to highest (last) is popped first. + for &child_idx in children.as_slice() { + if self.nodes[child_idx].max_order > max_found { + self.search_stack.push(child_idx); + } + } + } + } } - let ordering = max_intersecting_ordering + 1; - let new_node = self.push_leaf(new_bounds, ordering); - let new_parent = self.push_internal(sibling, new_node); + max_found + } - // If there was an old parent, we need to update its children indices. - if let Some(old_parent) = self.stack.last().copied() { - let Node::Internal { left, right, .. } = &mut self.nodes[old_parent] else { - unreachable!(); - }; + /// Inserts a leaf node with the given bounds and ordering. + /// Returns the index of the new leaf. + fn insert_leaf(&mut self, bounds: Bounds, order: u32) -> usize { + let new_leaf_idx = self.nodes.len(); + self.nodes.push(Node { + bounds: bounds.clone(), + max_order: order, + kind: NodeKind::Leaf { order }, + }); - if *left == sibling { - *left = new_parent; + let Some(root_idx) = self.root else { + // Tree is empty, new leaf becomes root + self.root = Some(new_leaf_idx); + return new_leaf_idx; + }; + + // If root is a leaf, create internal node with both + if matches!(self.nodes[root_idx].kind, NodeKind::Leaf { .. }) { + let root_bounds = self.nodes[root_idx].bounds.clone(); + let root_order = self.nodes[root_idx].max_order; + + let mut children = NodeChildren::new(); + // Max end invariant + if order > root_order { + children.push(root_idx); + children.push(new_leaf_idx); } else { - *right = new_parent; + children.push(new_leaf_idx); + children.push(root_idx); } - } else { - // If the old parent was the root, the new parent is the new root. - self.root = Some(new_parent); + + let new_root_idx = self.nodes.len(); + self.nodes.push(Node { + bounds: root_bounds.union(&bounds), + max_order: cmp::max(root_order, order), + kind: NodeKind::Internal { children }, + }); + self.root = Some(new_root_idx); + return new_leaf_idx; } - for node_index in self.stack.drain(..).rev() { - let Node::Internal { - max_order: max_ordering, - .. - } = &mut self.nodes[node_index] - else { - unreachable!() + // Descend to find the best internal node to insert into + self.insert_path.clear(); + let mut current_idx = root_idx; + + loop { + let current = &self.nodes[current_idx]; + let NodeKind::Internal { children } = ¤t.kind else { + unreachable!("Should only traverse internal nodes"); }; - if *max_ordering >= ordering { - break; - } - *max_ordering = ordering; - } - ordering - } + self.insert_path.push(current_idx); + + // Find the best child to descend into + let mut best_child_idx = children.as_slice()[0]; + let mut best_child_pos = 0; + let mut best_cost = bounds + .union(&self.nodes[best_child_idx].bounds) + .half_perimeter(); - fn find_max_ordering(&self, index: usize, bounds: &Bounds, mut max_ordering: u32) -> u32 { - match &self.nodes[index] { - Node::Leaf { - bounds: node_bounds, - order: ordering, - .. - } => { - if bounds.intersects(node_bounds) { - max_ordering = cmp::max(*ordering, max_ordering); + for (pos, &child_idx) in children.as_slice().iter().enumerate().skip(1) { + let cost = bounds.union(&self.nodes[child_idx].bounds).half_perimeter(); + if cost < best_cost { + best_cost = cost; + best_child_idx = child_idx; + best_child_pos = pos; } } - Node::Internal { - left, - right, - bounds: node_bounds, - max_order: node_max_ordering, - .. - } => { - if bounds.intersects(node_bounds) && max_ordering < *node_max_ordering { - let left_max_ordering = self.nodes[*left].max_ordering(); - let right_max_ordering = self.nodes[*right].max_ordering(); - if left_max_ordering > right_max_ordering { - max_ordering = self.find_max_ordering(*left, bounds, max_ordering); - max_ordering = self.find_max_ordering(*right, bounds, max_ordering); + + // Check if best child is a leaf or internal + if matches!(self.nodes[best_child_idx].kind, NodeKind::Leaf { .. }) { + // Best child is a leaf. Check if current node has room for another child. + if children.len() < MAX_CHILDREN { + // Add new leaf directly to this node + let node = &mut self.nodes[current_idx]; + + if let NodeKind::Internal { children } = &mut node.kind { + children.push(new_leaf_idx); + // Swap new leaf only if it has the highest max_order + if order <= node.max_order { + let last = children.len() - 1; + children.indices.swap(last - 1, last); + } + } + + node.bounds = node.bounds.union(&bounds); + node.max_order = cmp::max(node.max_order, order); + break; + } else { + // Node is full, create new internal with [best_leaf, new_leaf] + let sibling_bounds = self.nodes[best_child_idx].bounds.clone(); + let sibling_order = self.nodes[best_child_idx].max_order; + + let mut new_children = NodeChildren::new(); + // Max end invariant + if order > sibling_order { + new_children.push(best_child_idx); + new_children.push(new_leaf_idx); } else { - max_ordering = self.find_max_ordering(*right, bounds, max_ordering); - max_ordering = self.find_max_ordering(*left, bounds, max_ordering); + new_children.push(new_leaf_idx); + new_children.push(best_child_idx); + } + + let new_internal_idx = self.nodes.len(); + let new_internal_max = cmp::max(sibling_order, order); + self.nodes.push(Node { + bounds: sibling_bounds.union(&bounds), + max_order: new_internal_max, + kind: NodeKind::Internal { + children: new_children, + }, + }); + + // Replace the leaf with the new internal in parent + let parent = &mut self.nodes[current_idx]; + if let NodeKind::Internal { children } = &mut parent.kind { + let children_len = children.len(); + + children.indices[best_child_pos] = new_internal_idx; + + // If new internal has highest max_order, swap it to the end + // to maintain sorting invariant + if new_internal_max > parent.max_order { + children.indices.swap(best_child_pos, children_len - 1); + } } + break; } + } else { + // Best child is internal, continue descent + current_idx = best_child_idx; } } - max_ordering - } - fn push_leaf(&mut self, bounds: Bounds, order: u32) -> usize { - self.nodes.push(Node::Leaf { bounds, order }); - self.nodes.len() - 1 - } + // Propagate bounds and max_order updates up the tree + let mut updated_child_idx = None; + for &node_idx in self.insert_path.iter().rev() { + let node = &mut self.nodes[node_idx]; + node.bounds = node.bounds.union(&bounds); - fn push_internal(&mut self, left: usize, right: usize) -> usize { - let left_node = &self.nodes[left]; - let right_node = &self.nodes[right]; - let new_bounds = left_node.bounds().union(right_node.bounds()); - let max_ordering = cmp::max(left_node.max_ordering(), right_node.max_ordering()); - self.nodes.push(Node::Internal { - bounds: new_bounds, - left, - right, - max_order: max_ordering, - }); - self.nodes.len() - 1 + if node.max_order < order { + node.max_order = order; + + // Swap updated child to end (skip first iteration since the invariant is already handled by previous cases) + if let Some(child_idx) = updated_child_idx { + if let NodeKind::Internal { children } = &mut node.kind { + if let Some(pos) = children.as_slice().iter().position(|&c| c == child_idx) + { + let last = children.len() - 1; + if pos != last { + children.indices.swap(pos, last); + } + } + } + } + } + + updated_child_idx = Some(node_idx); + } + + new_leaf_idx } } @@ -187,50 +356,11 @@ where { fn default() -> Self { BoundsTree { - root: None, nodes: Vec::new(), - stack: Vec::new(), - } - } -} - -#[derive(Debug, Clone)] -enum Node -where - U: Clone + Debug + Default + PartialEq, -{ - Leaf { - bounds: Bounds, - order: u32, - }, - Internal { - left: usize, - right: usize, - bounds: Bounds, - max_order: u32, - }, -} - -impl Node -where - U: Clone + Debug + Default + PartialEq, -{ - fn bounds(&self) -> &Bounds { - match self { - Node::Leaf { bounds, .. } => bounds, - Node::Internal { bounds, .. } => bounds, - } - } - - fn max_ordering(&self) -> u32 { - match self { - Node::Leaf { - order: ordering, .. - } => *ordering, - Node::Internal { - max_order: max_ordering, - .. - } => *max_ordering, + root: None, + max_leaf: None, + insert_path: Vec::new(), + search_stack: Vec::new(), } } } diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 821f155f96d168e5319d9a8981ca4be75df7b854..cf55edefaf70c080e171a8e21b350fd3c6d82f75 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -20,8 +20,8 @@ use crate::{ DispatchPhase, Display, Element, ElementId, Entity, FocusHandle, Global, GlobalElementId, Hitbox, HitboxBehavior, HitboxId, InspectorElementId, IntoElement, IsZero, KeyContext, KeyDownEvent, KeyUpEvent, KeyboardButton, KeyboardClickEvent, LayoutId, ModifiersChangedEvent, - MouseButton, MouseClickEvent, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Overflow, - ParentElement, Pixels, Point, Render, ScrollWheelEvent, SharedString, Size, Style, + MouseButton, MouseClickEvent, MouseDownEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent, + Overflow, ParentElement, Pixels, Point, Render, ScrollWheelEvent, SharedString, Size, Style, StyleRefinement, Styled, Task, TooltipId, Visibility, Window, WindowControlArea, point, px, size, }; @@ -166,6 +166,38 @@ impl Interactivity { })); } + /// Bind the given callback to the mouse pressure event, during the bubble phase + /// the imperative API equivalent to [`InteractiveElement::on_mouse_pressure`]. + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + pub fn on_mouse_pressure( + &mut self, + listener: impl Fn(&MousePressureEvent, &mut Window, &mut App) + 'static, + ) { + self.mouse_pressure_listeners + .push(Box::new(move |event, phase, hitbox, window, cx| { + if phase == DispatchPhase::Bubble && hitbox.is_hovered(window) { + (listener)(event, window, cx) + } + })); + } + + /// Bind the given callback to the mouse pressure event, during the capture phase + /// the imperative API equivalent to [`InteractiveElement::on_mouse_pressure`]. + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + pub fn capture_mouse_pressure( + &mut self, + listener: impl Fn(&MousePressureEvent, &mut Window, &mut App) + 'static, + ) { + self.mouse_pressure_listeners + .push(Box::new(move |event, phase, hitbox, window, cx| { + if phase == DispatchPhase::Capture && hitbox.is_hovered(window) { + (listener)(event, window, cx) + } + })); + } + /// Bind the given callback to the mouse up event for the given button, during the bubble phase. /// The imperative API equivalent to [`InteractiveElement::on_mouse_up`]. /// @@ -622,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 { @@ -769,6 +801,30 @@ pub trait InteractiveElement: Sized { self } + /// Bind the given callback to the mouse pressure event, during the bubble phase + /// the fluent API equivalent to [`Interactivity::on_mouse_pressure`] + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + fn on_mouse_pressure( + mut self, + listener: impl Fn(&MousePressureEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.interactivity().on_mouse_pressure(listener); + self + } + + /// Bind the given callback to the mouse pressure event, during the capture phase + /// the fluent API equivalent to [`Interactivity::on_mouse_pressure`] + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + fn capture_mouse_pressure( + mut self, + listener: impl Fn(&MousePressureEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.interactivity().capture_mouse_pressure(listener); + self + } + /// Bind the given callback to the mouse down event, on any button, during the capture phase, /// when the mouse is outside of the bounds of this element. /// The fluent API equivalent to [`Interactivity::on_mouse_down_out`]. @@ -1197,7 +1253,8 @@ pub(crate) type MouseDownListener = Box; pub(crate) type MouseUpListener = Box; - +pub(crate) type MousePressureListener = + Box; pub(crate) type MouseMoveListener = Box; @@ -1521,6 +1578,7 @@ pub struct Interactivity { pub(crate) group_drag_over_styles: Vec<(TypeId, GroupStyle)>, pub(crate) mouse_down_listeners: Vec, pub(crate) mouse_up_listeners: Vec, + pub(crate) mouse_pressure_listeners: Vec, pub(crate) mouse_move_listeners: Vec, pub(crate) scroll_wheel_listeners: Vec, pub(crate) key_down_listeners: Vec, @@ -1714,6 +1772,7 @@ impl Interactivity { || self.group_hover_style.is_some() || self.hover_listener.is_some() || !self.mouse_up_listeners.is_empty() + || !self.mouse_pressure_listeners.is_empty() || !self.mouse_down_listeners.is_empty() || !self.mouse_move_listeners.is_empty() || !self.click_listeners.is_empty() @@ -2037,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(); @@ -2064,6 +2123,13 @@ impl Interactivity { }) } + for listener in self.mouse_pressure_listeners.drain(..) { + let hitbox = hitbox.clone(); + window.on_mouse_event(move |event: &MousePressureEvent, phase, window, cx| { + listener(event, phase, &hitbox, window, cx); + }) + } + for listener in self.mouse_move_listeners.drain(..) { let hitbox = hitbox.clone(); window.on_mouse_event(move |event: &MouseMoveEvent, phase, window, cx| { @@ -3193,7 +3259,11 @@ impl ScrollHandle { match active_item.strategy { ScrollStrategy::FirstVisible => { if state.overflow.y == Overflow::Scroll { - if bounds.top() + scroll_offset.y < state.bounds.top() { + let child_height = bounds.size.height; + let viewport_height = state.bounds.size.height; + if child_height > viewport_height { + scroll_offset.y = state.bounds.top() - bounds.top(); + } else if bounds.top() + scroll_offset.y < state.bounds.top() { scroll_offset.y = state.bounds.top() - bounds.top(); } else if bounds.bottom() + scroll_offset.y > state.bounds.bottom() { scroll_offset.y = state.bounds.bottom() - bounds.bottom(); @@ -3206,7 +3276,11 @@ impl ScrollHandle { } if state.overflow.x == Overflow::Scroll { - if bounds.left() + scroll_offset.x < state.bounds.left() { + let child_width = bounds.size.width; + let viewport_width = state.bounds.size.width; + if child_width > viewport_width { + scroll_offset.x = state.bounds.left() - bounds.left(); + } else if bounds.left() + scroll_offset.x < state.bounds.left() { scroll_offset.x = state.bounds.left() - bounds.left(); } else if bounds.right() + scroll_offset.x > state.bounds.right() { scroll_offset.x = state.bounds.right() - bounds.right(); @@ -3268,3 +3342,46 @@ impl ScrollHandle { self.0.borrow().child_bounds.len() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scroll_handle_aligns_wide_children_to_left_edge() { + let handle = ScrollHandle::new(); + { + let mut state = handle.0.borrow_mut(); + state.bounds = Bounds::new(point(px(0.), px(0.)), size(px(80.), px(20.))); + state.child_bounds = vec![Bounds::new(point(px(25.), px(0.)), size(px(200.), px(20.)))]; + state.overflow.x = Overflow::Scroll; + state.active_item = Some(ScrollActiveItem { + index: 0, + strategy: ScrollStrategy::default(), + }); + } + + handle.scroll_to_active_item(); + + assert_eq!(handle.offset().x, px(-25.)); + } + + #[test] + fn scroll_handle_aligns_tall_children_to_top_edge() { + let handle = ScrollHandle::new(); + { + let mut state = handle.0.borrow_mut(); + state.bounds = Bounds::new(point(px(0.), px(0.)), size(px(20.), px(80.))); + state.child_bounds = vec![Bounds::new(point(px(0.), px(25.)), size(px(20.), px(200.)))]; + state.overflow.y = Overflow::Scroll; + state.active_item = Some(ScrollActiveItem { + index: 0, + strategy: ScrollStrategy::default(), + }); + } + + handle.scroll_to_active_item(); + + assert_eq!(handle.offset().y, px(-25.)); + } +} 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 c0aa978c8eb0b217aa1cf7cd734664dc0736c355..6c2ecb341ff2fe446efd7823c107fd32a557feb5 100644 --- a/crates/gpui/src/executor.rs +++ b/crates/gpui/src/executor.rs @@ -1,6 +1,7 @@ -use crate::{App, PlatformDispatcher, RunnableMeta, RunnableVariant}; +use crate::{App, PlatformDispatcher, RunnableMeta, RunnableVariant, TaskTiming, profiler}; use async_task::Runnable; use futures::channel::mpsc; +use parking_lot::{Condvar, Mutex}; use smol::prelude::*; use std::{ fmt::Debug, @@ -46,6 +47,52 @@ pub struct ForegroundExecutor { not_send: PhantomData>, } +/// Realtime task priority +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[repr(u8)] +pub enum RealtimePriority { + /// Audio task + Audio, + /// Other realtime task + #[default] + Other, +} + +/// Task priority +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[repr(u8)] +pub enum Priority { + /// Realtime priority + /// + /// Spawning a task with this priority will spin it off on a separate thread dedicated just to that task. + Realtime(RealtimePriority), + /// High priority + /// + /// Only use for tasks that are critical to the user experience / responsiveness of the editor. + High, + /// Medium priority, probably suits most of your use cases. + #[default] + Medium, + /// Low priority + /// + /// Prioritize this for background work that can come in large quantities + /// to not starve the executor of resources for high priority tasks + Low, +} + +impl Priority { + #[allow(dead_code)] + pub(crate) const fn probability(&self) -> u32 { + match self { + // realtime priorities are not considered for probability scheduling + Priority::Realtime(_) => 0, + Priority::High => 60, + Priority::Medium => 30, + Priority::Low => 10, + } + } +} + /// Task is a primitive that allows work to happen in the background. /// /// It implements [`Future`] so you can `.await` on it. @@ -151,7 +198,77 @@ impl BackgroundExecutor { where R: Send + 'static, { - self.spawn_internal::(Box::pin(future), None) + self.spawn_with_priority(Priority::default(), future) + } + + /// Enqueues the given future to be run to completion on a background thread. + #[track_caller] + pub fn spawn_with_priority( + &self, + priority: Priority, + future: impl Future + Send + 'static, + ) -> Task + where + R: Send + 'static, + { + self.spawn_internal::(Box::pin(future), None, priority) + } + + /// Enqueues the given future to be run to completion on a background thread and blocking the current task on it. + /// + /// This allows to spawn background work that borrows from its scope. Note that the supplied future will run to + /// completion before the current task is resumed, even if the current task is slated for cancellation. + pub async fn await_on_background(&self, future: impl Future + Send) -> R + where + R: Send, + { + // We need to ensure that cancellation of the parent task does not drop the environment + // before the our own task has completed or got cancelled. + struct NotifyOnDrop<'a>(&'a (Condvar, Mutex)); + + impl Drop for NotifyOnDrop<'_> { + fn drop(&mut self) { + *self.0.1.lock() = true; + self.0.0.notify_all(); + } + } + + struct WaitOnDrop<'a>(&'a (Condvar, Mutex)); + + impl Drop for WaitOnDrop<'_> { + fn drop(&mut self) { + let mut done = self.0.1.lock(); + if !*done { + self.0.0.wait(&mut done); + } + } + } + + let dispatcher = self.dispatcher.clone(); + let location = core::panic::Location::caller(); + + let pair = &(Condvar::new(), Mutex::new(false)); + let _wait_guard = WaitOnDrop(pair); + + let (runnable, task) = unsafe { + async_task::Builder::new() + .metadata(RunnableMeta { location }) + .spawn_unchecked( + move |_| async { + let _notify_guard = NotifyOnDrop(pair); + future.await + }, + move |runnable| { + dispatcher.dispatch( + RunnableVariant::Meta(runnable), + None, + Priority::default(), + ) + }, + ) + }; + runnable.schedule(); + task.await } /// Enqueues the given future to be run to completion on a background thread. @@ -165,7 +282,7 @@ impl BackgroundExecutor { where R: Send + 'static, { - self.spawn_internal::(Box::pin(future), Some(label)) + self.spawn_internal::(Box::pin(future), Some(label), Priority::default()) } #[track_caller] @@ -173,15 +290,65 @@ 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(); - let location = core::panic::Location::caller(); - let (runnable, task) = async_task::Builder::new() - .metadata(RunnableMeta { location }) - .spawn( - move |_| future, - move |runnable| dispatcher.dispatch(RunnableVariant::Meta(runnable), label), + #[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); + + dispatcher.spawn_realtime( + realtime, + Box::new(move || { + while let Ok(runnable) = rx.recv() { + let start = Instant::now(); + let location = runnable.metadata().location; + let mut timing = TaskTiming { + location, + start, + end: None, + }; + profiler::add_task_timing(timing); + + runnable.run(); + + let end = Instant::now(); + timing.end = Some(end); + profiler::add_task_timing(timing); + } + }), ); + + async_task::Builder::new() + .metadata(RunnableMeta { location }) + .spawn( + move |_| future, + move |runnable| { + let _ = tx.send(runnable); + }, + ) + } else { + let location = core::panic::Location::caller(); + async_task::Builder::new() + .metadata(RunnableMeta { location }) + .spawn( + move |_| future, + move |runnable| { + dispatcher.dispatch(RunnableVariant::Meta(runnable), label, priority) + }, + ) + }; + runnable.schedule(); Task(TaskState::Spawned(task)) } @@ -354,11 +521,28 @@ impl BackgroundExecutor { where F: FnOnce(&mut Scope<'scope>), { - let mut scope = Scope::new(self.clone()); + let mut scope = Scope::new(self.clone(), Priority::default()); (scheduler)(&mut scope); let spawned = mem::take(&mut scope.futures) .into_iter() - .map(|f| self.spawn(f)) + .map(|f| self.spawn_with_priority(scope.priority, f)) + .collect::>(); + for task in spawned { + task.await; + } + } + + /// Scoped lets you start a number of tasks and waits + /// for all of them to complete before returning. + pub async fn scoped_priority<'scope, F>(&self, priority: Priority, scheduler: F) + where + F: FnOnce(&mut Scope<'scope>), + { + let mut scope = Scope::new(self.clone(), priority); + (scheduler)(&mut scope); + let spawned = mem::take(&mut scope.futures) + .into_iter() + .map(|f| self.spawn_with_priority(scope.priority, f)) .collect::>(); for task in spawned { task.await; @@ -494,6 +678,19 @@ impl ForegroundExecutor { /// Enqueues the given Task to run on the main thread at some point in the future. #[track_caller] pub fn spawn(&self, future: impl Future + 'static) -> Task + where + R: 'static, + { + self.spawn_with_priority(Priority::default(), future) + } + + /// Enqueues the given Task to run on the main thread at some point in the future. + #[track_caller] + pub fn spawn_with_priority( + &self, + priority: Priority, + future: impl Future + 'static, + ) -> Task where R: 'static, { @@ -505,16 +702,19 @@ impl ForegroundExecutor { dispatcher: Arc, future: AnyLocalFuture, location: &'static core::panic::Location<'static>, + priority: Priority, ) -> Task { let (runnable, task) = spawn_local_with_source_location( future, - move |runnable| dispatcher.dispatch_on_main_thread(RunnableVariant::Meta(runnable)), + move |runnable| { + dispatcher.dispatch_on_main_thread(RunnableVariant::Meta(runnable), priority) + }, RunnableMeta { location }, ); runnable.schedule(); Task(TaskState::Spawned(task)) } - inner::(dispatcher, Box::pin(future), location) + inner::(dispatcher, Box::pin(future), location, priority) } } @@ -590,6 +790,7 @@ where /// Scope manages a set of tasks that are enqueued and waited on together. See [`BackgroundExecutor::scoped`]. pub struct Scope<'a> { executor: BackgroundExecutor, + priority: Priority, futures: Vec + Send + 'static>>>, tx: Option>, rx: mpsc::Receiver<()>, @@ -597,10 +798,11 @@ pub struct Scope<'a> { } impl<'a> Scope<'a> { - fn new(executor: BackgroundExecutor) -> Self { + fn new(executor: BackgroundExecutor, priority: Priority) -> Self { let (tx, rx) = mpsc::channel(1); Self { executor, + priority, tx: Some(tx), rx, futures: Default::default(), diff --git a/crates/gpui/src/geometry.rs b/crates/gpui/src/geometry.rs index 4daec6d15367f3e12bab3cba658ccb3f261e9f46..fc735ba5e0e7e719ed12b6b1b168ec3ee49e22bb 100644 --- a/crates/gpui/src/geometry.rs +++ b/crates/gpui/src/geometry.rs @@ -1416,9 +1416,9 @@ where /// ``` pub fn contains(&self, point: &Point) -> bool { point.x >= self.origin.x - && point.x <= self.origin.x.clone() + self.size.width.clone() + && point.x < self.origin.x.clone() + self.size.width.clone() && point.y >= self.origin.y - && point.y <= self.origin.y.clone() + self.size.height.clone() + && point.y < self.origin.y.clone() + self.size.height.clone() } /// Checks if this bounds is completely contained within another bounds. @@ -2648,6 +2648,18 @@ impl Debug for Pixels { } } +impl std::iter::Sum for Pixels { + fn sum>(iter: I) -> Self { + iter.fold(Self::ZERO, |a, b| a + b) + } +} + +impl<'a> std::iter::Sum<&'a Pixels> for Pixels { + fn sum>(iter: I) -> Self { + iter.fold(Self::ZERO, |a, b| a + *b) + } +} + impl TryFrom<&'_ str> for Pixels { type Error = anyhow::Error; diff --git a/crates/gpui/src/gpui.rs b/crates/gpui/src/gpui.rs index bc70362047d7826519f6f7c734b7c5a84281b31f..76a61e286d3fe6c1acae8e4e628d4c9130f1305f 100644 --- a/crates/gpui/src/gpui.rs +++ b/crates/gpui/src/gpui.rs @@ -31,6 +31,8 @@ mod path_builder; mod platform; pub mod prelude; mod profiler; +#[cfg(target_os = "linux")] +mod queue; mod scene; mod shared_string; mod shared_uri; @@ -89,16 +91,20 @@ pub use keymap::*; pub use path_builder::*; pub use platform::*; pub use profiler::*; +#[cfg(target_os = "linux")] +pub(crate) use queue::{PriorityQueueReceiver, PriorityQueueSender}; pub use refineable::*; pub use scene::*; pub use shared_string::*; pub use shared_uri::*; pub use smol::Timer; +use std::{any::Any, future::Future}; pub use style::*; pub use styled::*; pub use subscription::*; pub use svg_renderer::*; pub(crate) use tab_stop::*; +use taffy::TaffyLayoutEngine; pub use taffy::{AvailableSpace, LayoutId}; #[cfg(any(test, feature = "test-support"))] pub use test::*; @@ -109,9 +115,6 @@ pub use util::{FutureExt, Timeout, arc_cow::ArcCow}; pub use view::*; pub use window::*; -use std::{any::Any, future::Future}; -use taffy::TaffyLayoutEngine; - /// The context trait, allows the different contexts in GPUI to be used /// interchangeably for certain operations. pub trait AppContext { diff --git a/crates/gpui/src/interactive.rs b/crates/gpui/src/interactive.rs index 03acf81addaad1ae9800ef476a2dc7d13e690cf7..a500ac46f0bbf96fc2b9d326a3a61da42c40b7ec 100644 --- a/crates/gpui/src/interactive.rs +++ b/crates/gpui/src/interactive.rs @@ -174,6 +174,40 @@ pub struct MouseClickEvent { pub up: MouseUpEvent, } +/// The stage of a pressure click event. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub enum PressureStage { + /// No pressure. + #[default] + Zero, + /// Normal click pressure. + Normal, + /// High pressure, enough to trigger a force click. + Force, +} + +/// A mouse pressure event from the platform. Generated when a force-sensitive trackpad is pressed hard. +/// Currently only implemented for macOS trackpads. +#[derive(Debug, Clone, Default)] +pub struct MousePressureEvent { + /// Pressure of the current stage as a float between 0 and 1 + pub pressure: f32, + /// The pressure stage of the event. + pub stage: PressureStage, + /// The position of the mouse on the window. + pub position: Point, + /// The modifiers that were held down when the mouse pressure changed. + pub modifiers: Modifiers, +} + +impl Sealed for MousePressureEvent {} +impl InputEvent for MousePressureEvent { + fn to_platform_input(self) -> PlatformInput { + PlatformInput::MousePressure(self) + } +} +impl MouseEvent for MousePressureEvent {} + /// A click event that was generated by a keyboard button being pressed and released. #[derive(Clone, Debug, Default)] pub struct KeyboardClickEvent { @@ -571,6 +605,8 @@ pub enum PlatformInput { MouseDown(MouseDownEvent), /// The mouse was released. MouseUp(MouseUpEvent), + /// Mouse pressure. + MousePressure(MousePressureEvent), /// The mouse was moved. MouseMove(MouseMoveEvent), /// The mouse exited the window. @@ -590,6 +626,7 @@ impl PlatformInput { PlatformInput::MouseDown(event) => Some(event), PlatformInput::MouseUp(event) => Some(event), PlatformInput::MouseMove(event) => Some(event), + PlatformInput::MousePressure(event) => Some(event), PlatformInput::MouseExited(event) => Some(event), PlatformInput::ScrollWheel(event) => Some(event), PlatformInput::FileDrop(event) => Some(event), @@ -604,6 +641,7 @@ impl PlatformInput { PlatformInput::MouseDown(_) => None, PlatformInput::MouseUp(_) => None, PlatformInput::MouseMove(_) => None, + PlatformInput::MousePressure(_) => None, PlatformInput::MouseExited(_) => None, PlatformInput::ScrollWheel(_) => None, PlatformInput::FileDrop(_) => None, @@ -667,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..1b92b9fe3ffabdbeec4bc7450adc1439e8e223eb 100644 --- a/crates/gpui/src/key_dispatch.rs +++ b/crates/gpui/src/key_dispatch.rs @@ -462,6 +462,17 @@ impl DispatchTree { (bindings, partial, context_stack) } + /// Find the bindings that can follow the current input sequence. + pub fn possible_next_bindings_for_input( + &self, + input: &[Keystroke], + context_stack: &[KeyContext], + ) -> Vec { + self.keymap + .borrow() + .possible_next_bindings_for_input(input, context_stack) + } + /// dispatch_key processes the keystroke /// input should be set to the value of `pending` from the previous call to dispatch_key. /// This returns three instructions to the input handler: @@ -610,8 +621,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 +630,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 +734,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 +1094,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/keymap.rs b/crates/gpui/src/keymap.rs index 33d956917055942cce365e9069cbb007e202eaf2..d5398ff0447849ca5bfcdbbb5a838af0cbc22836 100644 --- a/crates/gpui/src/keymap.rs +++ b/crates/gpui/src/keymap.rs @@ -215,6 +215,41 @@ impl Keymap { Some(contexts.len()) } } + + /// Find the bindings that can follow the current input sequence. + pub fn possible_next_bindings_for_input( + &self, + input: &[Keystroke], + context_stack: &[KeyContext], + ) -> Vec { + let mut bindings = self + .bindings() + .enumerate() + .rev() + .filter_map(|(ix, binding)| { + let depth = self.binding_enabled(binding, context_stack)?; + let pending = binding.match_keystrokes(input); + match pending { + None => None, + Some(is_pending) => { + if !is_pending || is_no_action(&*binding.action) { + return None; + } + Some((depth, BindingIndex(ix), binding)) + } + } + }) + .collect::>(); + + bindings.sort_by(|(depth_a, ix_a, _), (depth_b, ix_b, _)| { + depth_b.cmp(depth_a).then(ix_b.cmp(ix_a)) + }); + + bindings + .into_iter() + .map(|(_, _, binding)| binding.clone()) + .collect::>() + } } #[cfg(test)] diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 370043fb6b8ec7f5df251931d1363f577327caaa..22f4c46921132a7b8badfb7afd4fd38058c638b4 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -39,9 +39,10 @@ use crate::{ Action, AnyWindowHandle, App, AsyncWindowContext, BackgroundExecutor, Bounds, DEFAULT_WINDOW_SIZE, DevicePixels, DispatchEventResult, Font, FontId, FontMetrics, FontRun, ForegroundExecutor, GlyphId, GpuSpecs, ImageSource, Keymap, LineLayout, Pixels, PlatformInput, - Point, RenderGlyphParams, RenderImage, RenderImageParams, RenderSvgParams, Scene, ShapedGlyph, - ShapedRun, SharedString, Size, SvgRenderer, SystemWindowTab, Task, TaskLabel, TaskTiming, - ThreadTaskTimings, Window, WindowControlArea, hash, point, px, size, + Point, Priority, RealtimePriority, RenderGlyphParams, RenderImage, RenderImageParams, + RenderSvgParams, Scene, ShapedGlyph, ShapedRun, SharedString, Size, SvgRenderer, + SystemWindowTab, Task, TaskLabel, TaskTiming, ThreadTaskTimings, Window, WindowControlArea, + hash, point, px, size, }; use anyhow::Result; use async_task::Runnable; @@ -289,6 +290,13 @@ pub trait PlatformDisplay: Send + Sync + Debug { /// Get the bounds for this display fn bounds(&self) -> Bounds; + /// Get the visible bounds for this display, excluding taskbar/dock areas. + /// This is the usable area where windows can be placed without being obscured. + /// Defaults to the full display bounds if not overridden. + fn visible_bounds(&self) -> Bounds { + self.bounds() + } + /// Get the default bounds for this display to place a window fn default_bounds(&self) -> Bounds { let bounds = self.bounds(); @@ -580,9 +588,10 @@ pub trait PlatformDispatcher: Send + Sync { fn get_all_timings(&self) -> Vec; fn get_current_thread_timings(&self) -> Vec; fn is_main_thread(&self) -> bool; - fn dispatch(&self, runnable: RunnableVariant, label: Option); - fn dispatch_on_main_thread(&self, runnable: RunnableVariant); + fn dispatch(&self, runnable: RunnableVariant, label: Option, priority: Priority); + fn dispatch_on_main_thread(&self, runnable: RunnableVariant, priority: Priority); fn dispatch_after(&self, duration: Duration, runnable: RunnableVariant); + fn spawn_realtime(&self, priority: RealtimePriority, f: Box); fn now(&self) -> Instant { Instant::now() @@ -1339,6 +1348,10 @@ pub enum WindowKind { /// docks, notifications or wallpapers. #[cfg(all(target_os = "linux", feature = "wayland"))] LayerShell(layer_shell::LayerShellOptions), + + /// A window that appears on top of its parent window and blocks interaction with it + /// until the modal window is closed + Dialog, } /// The appearance of the window, as defined by the operating system. diff --git a/crates/gpui/src/platform/linux/dispatcher.rs b/crates/gpui/src/platform/linux/dispatcher.rs index d0c32140f3642e037df326f4e2beae16c59dd883..c8ae7269edd495669baa6ab0e22e745917f143b2 100644 --- a/crates/gpui/src/platform/linux/dispatcher.rs +++ b/crates/gpui/src/platform/linux/dispatcher.rs @@ -1,17 +1,21 @@ -use crate::{ - GLOBAL_THREAD_TIMINGS, PlatformDispatcher, RunnableVariant, THREAD_TIMINGS, TaskLabel, - TaskTiming, ThreadTaskTimings, -}; use calloop::{ - EventLoop, + EventLoop, PostAction, channel::{self, Sender}, timer::TimeoutAction, }; +use util::ResultExt; + use std::{ + mem::MaybeUninit, thread, time::{Duration, Instant}, }; -use util::ResultExt; + +use crate::{ + GLOBAL_THREAD_TIMINGS, PlatformDispatcher, Priority, PriorityQueueReceiver, + PriorityQueueSender, RealtimePriority, RunnableVariant, THREAD_TIMINGS, TaskLabel, TaskTiming, + ThreadTaskTimings, profiler, +}; struct TimerAfter { duration: Duration, @@ -19,9 +23,9 @@ struct TimerAfter { } pub(crate) struct LinuxDispatcher { - main_sender: Sender, + main_sender: PriorityQueueCalloopSender, timer_sender: Sender, - background_sender: flume::Sender, + background_sender: PriorityQueueSender, _background_threads: Vec>, main_thread_id: thread::ThreadId, } @@ -29,18 +33,20 @@ pub(crate) struct LinuxDispatcher { const MIN_THREADS: usize = 2; impl LinuxDispatcher { - pub fn new(main_sender: Sender) -> Self { - let (background_sender, background_receiver) = flume::unbounded::(); + pub fn new(main_sender: PriorityQueueCalloopSender) -> Self { + let (background_sender, background_receiver) = PriorityQueueReceiver::new(); let thread_count = std::thread::available_parallelism().map_or(MIN_THREADS, |i| i.get().max(MIN_THREADS)); + // These thread should really be lower prio then the foreground + // executor let mut background_threads = (0..thread_count) .map(|i| { - let receiver = background_receiver.clone(); + let mut receiver = background_receiver.clone(); std::thread::Builder::new() .name(format!("Worker-{i}")) .spawn(move || { - for runnable in receiver { + for runnable in receiver.iter() { let start = Instant::now(); let mut location = match runnable { @@ -51,7 +57,7 @@ impl LinuxDispatcher { start, end: None, }; - Self::add_task_timing(timing); + profiler::add_task_timing(timing); runnable.run(); timing @@ -63,7 +69,7 @@ impl LinuxDispatcher { start, end: None, }; - Self::add_task_timing(timing); + profiler::add_task_timing(timing); runnable.run(); timing @@ -72,7 +78,7 @@ impl LinuxDispatcher { let end = Instant::now(); location.end = Some(end); - Self::add_task_timing(location); + profiler::add_task_timing(location); log::trace!( "background thread {}: ran runnable. took: {:?}", @@ -113,7 +119,7 @@ impl LinuxDispatcher { start, end: None, }; - Self::add_task_timing(timing); + profiler::add_task_timing(timing); runnable.run(); timing @@ -124,7 +130,7 @@ impl LinuxDispatcher { start, end: None, }; - Self::add_task_timing(timing); + profiler::add_task_timing(timing); runnable.run(); timing @@ -133,7 +139,7 @@ impl LinuxDispatcher { let end = Instant::now(); timing.end = Some(end); - Self::add_task_timing(timing); + profiler::add_task_timing(timing); } TimeoutAction::Drop }, @@ -157,22 +163,6 @@ impl LinuxDispatcher { main_thread_id: thread::current().id(), } } - - 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); - }); - } } impl PlatformDispatcher for LinuxDispatcher { @@ -199,22 +189,26 @@ impl PlatformDispatcher for LinuxDispatcher { thread::current().id() == self.main_thread_id } - fn dispatch(&self, runnable: RunnableVariant, _: Option) { - self.background_sender.send(runnable).unwrap(); + fn dispatch(&self, runnable: RunnableVariant, _: Option, priority: Priority) { + self.background_sender + .send(priority, runnable) + .unwrap_or_else(|_| panic!("blocking sender returned without value")); } - fn dispatch_on_main_thread(&self, runnable: RunnableVariant) { - self.main_sender.send(runnable).unwrap_or_else(|runnable| { - // NOTE: Runnable may wrap a Future that is !Send. - // - // This is usually safe because we only poll it on the main thread. - // However if the send fails, we know that: - // 1. main_receiver has been dropped (which implies the app is shutting down) - // 2. we are on a background thread. - // It is not safe to drop something !Send on the wrong thread, and - // the app will exit soon anyway, so we must forget the runnable. - std::mem::forget(runnable); - }); + fn dispatch_on_main_thread(&self, runnable: RunnableVariant, priority: Priority) { + self.main_sender + .send(priority, runnable) + .unwrap_or_else(|runnable| { + // NOTE: Runnable may wrap a Future that is !Send. + // + // This is usually safe because we only poll it on the main thread. + // However if the send fails, we know that: + // 1. main_receiver has been dropped (which implies the app is shutting down) + // 2. we are on a background thread. + // It is not safe to drop something !Send on the wrong thread, and + // the app will exit soon anyway, so we must forget the runnable. + std::mem::forget(runnable); + }); } fn dispatch_after(&self, duration: Duration, runnable: RunnableVariant) { @@ -222,4 +216,255 @@ impl PlatformDispatcher for LinuxDispatcher { .send(TimerAfter { duration, runnable }) .ok(); } + + fn spawn_realtime(&self, priority: RealtimePriority, f: Box) { + std::thread::spawn(move || { + // SAFETY: always safe to call + let thread_id = unsafe { libc::pthread_self() }; + + let policy = match priority { + RealtimePriority::Audio => libc::SCHED_FIFO, + RealtimePriority::Other => libc::SCHED_RR, + }; + let sched_priority = match priority { + RealtimePriority::Audio => 65, + RealtimePriority::Other => 45, + }; + + // SAFETY: all sched_param members are valid when initialized to zero. + let mut sched_param = + unsafe { MaybeUninit::::zeroed().assume_init() }; + sched_param.sched_priority = sched_priority; + // SAFETY: sched_param is a valid initialized structure + let result = unsafe { libc::pthread_setschedparam(thread_id, policy, &sched_param) }; + if result != 0 { + log::warn!("failed to set realtime thread priority to {:?}", priority); + } + + f(); + }); + } +} + +pub struct PriorityQueueCalloopSender { + sender: PriorityQueueSender, + ping: calloop::ping::Ping, +} + +impl PriorityQueueCalloopSender { + fn new(tx: PriorityQueueSender, ping: calloop::ping::Ping) -> Self { + Self { sender: tx, ping } + } + + fn send(&self, priority: Priority, item: T) -> Result<(), crate::queue::SendError> { + let res = self.sender.send(priority, item); + if res.is_ok() { + self.ping.ping(); + } + res + } +} + +impl Drop for PriorityQueueCalloopSender { + fn drop(&mut self) { + self.ping.ping(); + } +} + +pub struct PriorityQueueCalloopReceiver { + receiver: PriorityQueueReceiver, + source: calloop::ping::PingSource, + ping: calloop::ping::Ping, +} + +impl PriorityQueueCalloopReceiver { + pub fn new() -> (PriorityQueueCalloopSender, Self) { + let (ping, source) = calloop::ping::make_ping().expect("Failed to create a Ping."); + + let (tx, rx) = PriorityQueueReceiver::new(); + + ( + PriorityQueueCalloopSender::new(tx, ping.clone()), + Self { + receiver: rx, + source, + ping, + }, + ) + } +} + +use calloop::channel::Event; + +#[derive(Debug)] +pub struct ChannelError(calloop::ping::PingError); + +impl std::fmt::Display for ChannelError { + #[cfg_attr(feature = "nightly_coverage", coverage(off))] + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(&self.0, f) + } +} + +impl std::error::Error for ChannelError { + #[cfg_attr(feature = "nightly_coverage", coverage(off))] + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.0) + } } + +impl calloop::EventSource for PriorityQueueCalloopReceiver { + type Event = Event; + type Metadata = (); + type Ret = (); + type Error = ChannelError; + + fn process_events( + &mut self, + readiness: calloop::Readiness, + token: calloop::Token, + mut callback: F, + ) -> Result + where + F: FnMut(Self::Event, &mut Self::Metadata) -> Self::Ret, + { + let mut clear_readiness = false; + let mut disconnected = false; + + let action = self + .source + .process_events(readiness, token, |(), &mut ()| { + let mut is_empty = true; + + let mut receiver = self.receiver.clone(); + for runnable in receiver.try_iter() { + match runnable { + Ok(r) => { + callback(Event::Msg(r), &mut ()); + is_empty = false; + } + Err(_) => { + disconnected = true; + } + } + } + + if disconnected { + callback(Event::Closed, &mut ()); + } + + if is_empty { + clear_readiness = true; + } + }) + .map_err(ChannelError)?; + + if disconnected { + Ok(PostAction::Remove) + } else if clear_readiness { + Ok(action) + } else { + // Re-notify the ping source so we can try again. + self.ping.ping(); + Ok(PostAction::Continue) + } + } + + fn register( + &mut self, + poll: &mut calloop::Poll, + token_factory: &mut calloop::TokenFactory, + ) -> calloop::Result<()> { + self.source.register(poll, token_factory) + } + + fn reregister( + &mut self, + poll: &mut calloop::Poll, + token_factory: &mut calloop::TokenFactory, + ) -> calloop::Result<()> { + self.source.reregister(poll, token_factory) + } + + fn unregister(&mut self, poll: &mut calloop::Poll) -> calloop::Result<()> { + self.source.unregister(poll) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn calloop_works() { + let mut event_loop = calloop::EventLoop::try_new().unwrap(); + let handle = event_loop.handle(); + + let (tx, rx) = PriorityQueueCalloopReceiver::new(); + + struct Data { + got_msg: bool, + got_closed: bool, + } + + let mut data = Data { + got_msg: false, + got_closed: false, + }; + + let _channel_token = handle + .insert_source(rx, move |evt, &mut (), data: &mut Data| match evt { + Event::Msg(()) => { + data.got_msg = true; + } + + Event::Closed => { + data.got_closed = true; + } + }) + .unwrap(); + + // nothing is sent, nothing is received + event_loop + .dispatch(Some(::std::time::Duration::ZERO), &mut data) + .unwrap(); + + assert!(!data.got_msg); + assert!(!data.got_closed); + // a message is send + + tx.send(Priority::Medium, ()).unwrap(); + event_loop + .dispatch(Some(::std::time::Duration::ZERO), &mut data) + .unwrap(); + + assert!(data.got_msg); + assert!(!data.got_closed); + + // the sender is dropped + drop(tx); + event_loop + .dispatch(Some(::std::time::Duration::ZERO), &mut data) + .unwrap(); + + assert!(data.got_msg); + assert!(data.got_closed); + } +} + +// running 1 test +// test platform::linux::dispatcher::tests::tomato ... FAILED + +// failures: + +// ---- platform::linux::dispatcher::tests::tomato stdout ---- +// [crates/gpui/src/platform/linux/dispatcher.rs:262:9] +// returning 1 tasks to process +// [crates/gpui/src/platform/linux/dispatcher.rs:480:75] evt = Msg( +// (), +// ) +// returning 0 tasks to process + +// thread 'platform::linux::dispatcher::tests::tomato' (478301) panicked at crates/gpui/src/platform/linux/dispatcher.rs:515:9: +// assertion failed: data.got_closed +// note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace diff --git a/crates/gpui/src/platform/linux/platform.rs b/crates/gpui/src/platform/linux/platform.rs index f5056741df016cfe88c83379d7b1afd85b9900ca..06a81ec342e9d528a081456583f3ba0f3fb77b6f 100644 --- a/crates/gpui/src/platform/linux/platform.rs +++ b/crates/gpui/src/platform/linux/platform.rs @@ -14,7 +14,7 @@ use std::{ }; use anyhow::{Context as _, anyhow}; -use calloop::{LoopSignal, channel::Channel}; +use calloop::LoopSignal; use futures::channel::oneshot; use util::ResultExt as _; use util::command::{new_smol_command, new_std_command}; @@ -25,8 +25,8 @@ use crate::{ Action, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DisplayId, ForegroundExecutor, Keymap, LinuxDispatcher, Menu, MenuItem, OwnedMenu, PathPromptOptions, Pixels, Platform, PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, - PlatformTextSystem, PlatformWindow, Point, Result, RunnableVariant, Task, WindowAppearance, - WindowParams, px, + PlatformTextSystem, PlatformWindow, Point, PriorityQueueCalloopReceiver, Result, + RunnableVariant, Task, WindowAppearance, WindowParams, px, }; #[cfg(any(feature = "wayland", feature = "x11"))] @@ -149,8 +149,8 @@ pub(crate) struct LinuxCommon { } impl LinuxCommon { - pub fn new(signal: LoopSignal) -> (Self, Channel) { - let (main_sender, main_receiver) = calloop::channel::channel::(); + pub fn new(signal: LoopSignal) -> (Self, PriorityQueueCalloopReceiver) { + let (main_sender, main_receiver) = PriorityQueueCalloopReceiver::new(); #[cfg(any(feature = "wayland", feature = "x11"))] let text_system = Arc::new(crate::CosmicTextSystem::new()); @@ -649,8 +649,9 @@ pub(super) fn open_uri_internal( .activation_token(activation_token.clone().map(ashpd::ActivationToken::from)) .send_uri(&uri) .await + .and_then(|e| e.response()) { - Ok(_) => return, + Ok(()) => return, Err(e) => log::error!("Failed to open with dbus: {}", e), } diff --git a/crates/gpui/src/platform/linux/wayland/client.rs b/crates/gpui/src/platform/linux/wayland/client.rs index 1a7011c582ab162c8ed6c7277d3dd1f5b8c60239..b6bfbec0679f9413fceef2bb37e7bd304371707e 100644 --- a/crates/gpui/src/platform/linux/wayland/client.rs +++ b/crates/gpui/src/platform/linux/wayland/client.rs @@ -36,12 +36,6 @@ use wayland_client::{ wl_shm_pool, wl_surface, }, }; -use wayland_protocols::wp::cursor_shape::v1::client::{ - wp_cursor_shape_device_v1, wp_cursor_shape_manager_v1, -}; -use wayland_protocols::wp::fractional_scale::v1::client::{ - wp_fractional_scale_manager_v1, wp_fractional_scale_v1, -}; use wayland_protocols::wp::primary_selection::zv1::client::zwp_primary_selection_offer_v1::{ self, ZwpPrimarySelectionOfferV1, }; @@ -61,6 +55,14 @@ use wayland_protocols::xdg::decoration::zv1::client::{ zxdg_decoration_manager_v1, zxdg_toplevel_decoration_v1, }; use wayland_protocols::xdg::shell::client::{xdg_surface, xdg_toplevel, xdg_wm_base}; +use wayland_protocols::{ + wp::cursor_shape::v1::client::{wp_cursor_shape_device_v1, wp_cursor_shape_manager_v1}, + xdg::dialog::v1::client::xdg_wm_dialog_v1::{self, XdgWmDialogV1}, +}; +use wayland_protocols::{ + wp::fractional_scale::v1::client::{wp_fractional_scale_manager_v1, wp_fractional_scale_v1}, + xdg::dialog::v1::client::xdg_dialog_v1::XdgDialogV1, +}; use wayland_protocols_plasma::blur::client::{org_kde_kwin_blur, org_kde_kwin_blur_manager}; use wayland_protocols_wlr::layer_shell::v1::client::{zwlr_layer_shell_v1, zwlr_layer_surface_v1}; use xkbcommon::xkb::ffi::XKB_KEYMAP_FORMAT_TEXT_V1; @@ -77,10 +79,10 @@ use crate::{ LinuxKeyboardLayout, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseExitEvent, MouseMoveEvent, MouseUpEvent, NavigationDirection, Pixels, PlatformDisplay, PlatformInput, PlatformKeyboardLayout, Point, ResultExt as _, SCROLL_LINES, ScrollDelta, - ScrollWheelEvent, Size, TouchPhase, WindowParams, point, px, size, + ScrollWheelEvent, Size, TouchPhase, WindowParams, point, profiler, px, size, }; use crate::{ - LinuxDispatcher, RunnableVariant, TaskTiming, + RunnableVariant, TaskTiming, platform::{PlatformWindow, blade::BladeContext}, }; use crate::{ @@ -122,6 +124,7 @@ pub struct Globals { pub layer_shell: Option, pub blur_manager: Option, pub text_input_manager: Option, + pub dialog: Option, pub executor: ForegroundExecutor, } @@ -132,6 +135,7 @@ impl Globals { qh: QueueHandle, seat: wl_seat::WlSeat, ) -> Self { + let dialog_v = XdgWmDialogV1::interface().version; Globals { activation: globals.bind(&qh, 1..=1, ()).ok(), compositor: globals @@ -160,6 +164,7 @@ impl Globals { layer_shell: globals.bind(&qh, 1..=5, ()).ok(), blur_manager: globals.bind(&qh, 1..=1, ()).ok(), text_input_manager: globals.bind(&qh, 1..=1, ()).ok(), + dialog: globals.bind(&qh, dialog_v..=dialog_v, ()).ok(), executor, qh, } @@ -503,7 +508,7 @@ impl WaylandClient { start, end: None, }; - LinuxDispatcher::add_task_timing(timing); + profiler::add_task_timing(timing); runnable.run(); timing @@ -515,7 +520,7 @@ impl WaylandClient { start, end: None, }; - LinuxDispatcher::add_task_timing(timing); + profiler::add_task_timing(timing); runnable.run(); timing @@ -524,7 +529,7 @@ impl WaylandClient { let end = Instant::now(); timing.end = Some(end); - LinuxDispatcher::add_task_timing(timing); + profiler::add_task_timing(timing); }); } } @@ -729,10 +734,7 @@ impl LinuxClient for WaylandClient { ) -> anyhow::Result> { let mut state = self.0.borrow_mut(); - let parent = state - .keyboard_focused_window - .as_ref() - .and_then(|w| w.toplevel()); + let parent = state.keyboard_focused_window.clone(); let (window, surface_id) = WaylandWindow::new( handle, @@ -751,7 +753,12 @@ impl LinuxClient for WaylandClient { fn set_cursor_style(&self, style: CursorStyle) { let mut state = self.0.borrow_mut(); - let need_update = state.cursor_style != Some(style); + let need_update = state.cursor_style != Some(style) + && (state.mouse_focused_window.is_none() + || state + .mouse_focused_window + .as_ref() + .is_some_and(|w| !w.is_blocked())); if need_update { let serial = state.serial_tracker.get(SerialKind::MouseEnter); @@ -1011,7 +1018,7 @@ impl Dispatch for WaylandClientStatePtr { } } -fn get_window( +pub(crate) fn get_window( mut state: &mut RefMut, surface_id: &ObjectId, ) -> Option { @@ -1654,6 +1661,30 @@ impl Dispatch for WaylandClientStatePtr { state.mouse_location = Some(point(px(surface_x as f32), px(surface_y as f32))); if let Some(window) = state.mouse_focused_window.clone() { + if window.is_blocked() { + let default_style = CursorStyle::Arrow; + if state.cursor_style != Some(default_style) { + let serial = state.serial_tracker.get(SerialKind::MouseEnter); + state.cursor_style = Some(default_style); + + if let Some(cursor_shape_device) = &state.cursor_shape_device { + cursor_shape_device.set_shape(serial, default_style.to_shape()); + } else { + // cursor-shape-v1 isn't supported, set the cursor using a surface. + let wl_pointer = state + .wl_pointer + .clone() + .expect("window is focused by pointer"); + let scale = window.primary_output_scale(); + state.cursor.set_icon( + &wl_pointer, + serial, + default_style.to_icon_names(), + scale, + ); + } + } + } if state .keyboard_focused_window .as_ref() @@ -2225,3 +2256,27 @@ impl Dispatch } } } + +impl Dispatch for WaylandClientStatePtr { + fn event( + _: &mut Self, + _: &XdgWmDialogV1, + _: ::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + } +} + +impl Dispatch for WaylandClientStatePtr { + fn event( + _state: &mut Self, + _proxy: &XdgDialogV1, + _event: ::Event, + _data: &(), + _conn: &Connection, + _qhandle: &QueueHandle, + ) { + } +} diff --git a/crates/gpui/src/platform/linux/wayland/window.rs b/crates/gpui/src/platform/linux/wayland/window.rs index 3334ae28a31927b2150e79fc513855fa699c55ba..6b4dad3b3917d025a80594b5ece63c26bbadde69 100644 --- a/crates/gpui/src/platform/linux/wayland/window.rs +++ b/crates/gpui/src/platform/linux/wayland/window.rs @@ -7,7 +7,7 @@ use std::{ }; use blade_graphics as gpu; -use collections::HashMap; +use collections::{FxHashSet, HashMap}; use futures::channel::oneshot::Receiver; use raw_window_handle as rwh; @@ -20,7 +20,7 @@ use wayland_protocols::xdg::shell::client::xdg_surface; use wayland_protocols::xdg::shell::client::xdg_toplevel::{self}; use wayland_protocols::{ wp::fractional_scale::v1::client::wp_fractional_scale_v1, - xdg::shell::client::xdg_toplevel::XdgToplevel, + xdg::dialog::v1::client::xdg_dialog_v1::XdgDialogV1, }; use wayland_protocols_plasma::blur::client::org_kde_kwin_blur; use wayland_protocols_wlr::layer_shell::v1::client::zwlr_layer_surface_v1; @@ -29,7 +29,7 @@ use crate::{ AnyWindowHandle, Bounds, Decorations, Globals, GpuSpecs, Modifiers, Output, Pixels, PlatformDisplay, PlatformInput, Point, PromptButton, PromptLevel, RequestFrameOptions, ResizeEdge, Size, Tiling, WaylandClientStatePtr, WindowAppearance, WindowBackgroundAppearance, - WindowBounds, WindowControlArea, WindowControls, WindowDecorations, WindowParams, + WindowBounds, WindowControlArea, WindowControls, WindowDecorations, WindowParams, get_window, layer_shell::LayerShellNotSupportedError, px, size, }; use crate::{ @@ -87,6 +87,8 @@ struct InProgressConfigure { pub struct WaylandWindowState { surface_state: WaylandSurfaceState, acknowledged_first_configure: bool, + parent: Option, + children: FxHashSet, pub surface: wl_surface::WlSurface, app_id: Option, appearance: WindowAppearance, @@ -126,7 +128,7 @@ impl WaylandSurfaceState { surface: &wl_surface::WlSurface, globals: &Globals, params: &WindowParams, - parent: Option, + parent: Option, ) -> anyhow::Result { // For layer_shell windows, create a layer surface instead of an xdg surface if let WindowKind::LayerShell(options) = ¶ms.kind { @@ -178,10 +180,28 @@ impl WaylandSurfaceState { .get_xdg_surface(&surface, &globals.qh, surface.id()); let toplevel = xdg_surface.get_toplevel(&globals.qh, surface.id()); - if params.kind == WindowKind::Floating { - toplevel.set_parent(parent.as_ref()); + let xdg_parent = parent.as_ref().and_then(|w| w.toplevel()); + + if params.kind == WindowKind::Floating || params.kind == WindowKind::Dialog { + toplevel.set_parent(xdg_parent.as_ref()); } + let dialog = if params.kind == WindowKind::Dialog { + let dialog = globals.dialog.as_ref().map(|dialog| { + let xdg_dialog = dialog.get_xdg_dialog(&toplevel, &globals.qh, ()); + xdg_dialog.set_modal(); + xdg_dialog + }); + + if let Some(parent) = parent.as_ref() { + parent.add_child(surface.id()); + } + + dialog + } else { + None + }; + if let Some(size) = params.window_min_size { toplevel.set_min_size(size.width.0 as i32, size.height.0 as i32); } @@ -198,6 +218,7 @@ impl WaylandSurfaceState { xdg_surface, toplevel, decoration, + dialog, })) } } @@ -206,6 +227,7 @@ pub struct WaylandXdgSurfaceState { xdg_surface: xdg_surface::XdgSurface, toplevel: xdg_toplevel::XdgToplevel, decoration: Option, + dialog: Option, } pub struct WaylandLayerSurfaceState { @@ -258,7 +280,13 @@ impl WaylandSurfaceState { xdg_surface, toplevel, decoration: _decoration, + dialog, }) => { + // drop the dialog before toplevel so compositor can explicitly unapply it's effects + if let Some(dialog) = dialog { + dialog.destroy(); + } + // The role object (toplevel) must always be destroyed before the xdg_surface. // See https://wayland.app/protocols/xdg-shell#xdg_surface:request:destroy toplevel.destroy(); @@ -288,6 +316,7 @@ impl WaylandWindowState { globals: Globals, gpu_context: &BladeContext, options: WindowParams, + parent: Option, ) -> anyhow::Result { let renderer = { let raw_window = RawWindow { @@ -319,6 +348,8 @@ impl WaylandWindowState { Ok(Self { surface_state, acknowledged_first_configure: false, + parent, + children: FxHashSet::default(), surface, app_id: None, blur: None, @@ -391,6 +422,10 @@ impl Drop for WaylandWindow { fn drop(&mut self) { let mut state = self.0.state.borrow_mut(); let surface_id = state.surface.id(); + if let Some(parent) = state.parent.as_ref() { + parent.state.borrow_mut().children.remove(&surface_id); + } + let client = state.client.clone(); state.renderer.destroy(); @@ -448,10 +483,10 @@ impl WaylandWindow { client: WaylandClientStatePtr, params: WindowParams, appearance: WindowAppearance, - parent: Option, + parent: Option, ) -> anyhow::Result<(Self, ObjectId)> { let surface = globals.compositor.create_surface(&globals.qh, ()); - let surface_state = WaylandSurfaceState::new(&surface, &globals, ¶ms, parent)?; + let surface_state = WaylandSurfaceState::new(&surface, &globals, ¶ms, parent.clone())?; if let Some(fractional_scale_manager) = globals.fractional_scale_manager.as_ref() { fractional_scale_manager.get_fractional_scale(&surface, &globals.qh, surface.id()); @@ -473,6 +508,7 @@ impl WaylandWindow { globals, gpu_context, params, + parent, )?)), callbacks: Rc::new(RefCell::new(Callbacks::default())), }); @@ -501,6 +537,16 @@ impl WaylandWindowStatePtr { Rc::ptr_eq(&self.state, &other.state) } + pub fn add_child(&self, child: ObjectId) { + let mut state = self.state.borrow_mut(); + state.children.insert(child); + } + + pub fn is_blocked(&self) -> bool { + let state = self.state.borrow(); + !state.children.is_empty() + } + pub fn frame(&self) { let mut state = self.state.borrow_mut(); state.surface.frame(&state.globals.qh, state.surface.id()); @@ -818,6 +864,9 @@ impl WaylandWindowStatePtr { } pub fn handle_ime(&self, ime: ImeInput) { + if self.is_blocked() { + return; + } let mut state = self.state.borrow_mut(); if let Some(mut input_handler) = state.input_handler.take() { drop(state); @@ -894,6 +943,21 @@ impl WaylandWindowStatePtr { } pub fn close(&self) { + let state = self.state.borrow(); + let client = state.client.get_client(); + #[allow(clippy::mutable_key_type)] + let children = state.children.clone(); + drop(state); + + for child in children { + let mut client_state = client.borrow_mut(); + let window = get_window(&mut client_state, &child); + drop(client_state); + + if let Some(child) = window { + child.close(); + } + } let mut callbacks = self.callbacks.borrow_mut(); if let Some(fun) = callbacks.close.take() { fun() @@ -901,6 +965,9 @@ impl WaylandWindowStatePtr { } pub fn handle_input(&self, input: PlatformInput) { + if self.is_blocked() { + return; + } if let Some(ref mut fun) = self.callbacks.borrow_mut().input && !fun(input.clone()).propagate { @@ -1025,13 +1092,26 @@ impl PlatformWindow for WaylandWindow { fn resize(&mut self, size: Size) { let state = self.borrow(); let state_ptr = self.0.clone(); - let dp_size = size.to_device_pixels(self.scale_factor()); + + // Keep window geometry consistent with configure handling. On Wayland, window geometry is + // surface-local: resizing should not attempt to translate the window; the compositor + // controls placement. We also account for client-side decoration insets and tiling. + let window_geometry = inset_by_tiling( + Bounds { + origin: Point::default(), + size, + }, + state.inset(), + state.tiling, + ) + .map(|v| v.0 as i32) + .map_size(|v| if v <= 0 { 1 } else { v }); state.surface_state.set_geometry( - state.bounds.origin.x.0 as i32, - state.bounds.origin.y.0 as i32, - dp_size.width.0, - dp_size.height.0, + window_geometry.origin.x, + window_geometry.origin.y, + window_geometry.size.width, + window_geometry.size.height, ); state diff --git a/crates/gpui/src/platform/linux/x11/client.rs b/crates/gpui/src/platform/linux/x11/client.rs index aa16dc7ad1d9030665ace646ba2ac295df8c27b3..7feec41d433158325592d566f83a6063f7a7196e 100644 --- a/crates/gpui/src/platform/linux/x11/client.rs +++ b/crates/gpui/src/platform/linux/x11/client.rs @@ -1,4 +1,4 @@ -use crate::{Capslock, LinuxDispatcher, ResultExt as _, RunnableVariant, TaskTiming, xcb_flush}; +use crate::{Capslock, ResultExt as _, RunnableVariant, TaskTiming, profiler, xcb_flush}; use anyhow::{Context as _, anyhow}; use ashpd::WindowIdentifier; use calloop::{ @@ -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, @@ -222,7 +222,7 @@ pub struct X11ClientState { pub struct X11ClientStatePtr(pub Weak>); impl X11ClientStatePtr { - fn get_client(&self) -> Option { + pub fn get_client(&self) -> Option { self.0.upgrade().map(X11Client) } @@ -322,7 +322,7 @@ impl X11Client { start, end: None, }; - LinuxDispatcher::add_task_timing(timing); + profiler::add_task_timing(timing); runnable.run(); timing @@ -334,7 +334,7 @@ impl X11Client { start, end: None, }; - LinuxDispatcher::add_task_timing(timing); + profiler::add_task_timing(timing); runnable.run(); timing @@ -343,7 +343,7 @@ impl X11Client { let end = Instant::now(); timing.end = Some(end); - LinuxDispatcher::add_task_timing(timing); + profiler::add_task_timing(timing); }); } } @@ -752,7 +752,7 @@ impl X11Client { } } - fn get_window(&self, win: xproto::Window) -> Option { + pub(crate) fn get_window(&self, win: xproto::Window) -> Option { let state = self.0.borrow(); state .windows @@ -789,12 +789,12 @@ impl X11Client { let [atom, arg1, arg2, arg3, arg4] = event.data.as_data32(); let mut state = self.0.borrow_mut(); - if atom == state.atoms.WM_DELETE_WINDOW { + if atom == state.atoms.WM_DELETE_WINDOW && window.should_close() { // window "x" button clicked by user - if window.should_close() { - // Rest of the close logic is handled in drop_window() - window.close(); - } + // Rest of the close logic is handled in drop_window() + drop(state); + window.close(); + state = self.0.borrow_mut(); } else if atom == state.atoms._NET_WM_SYNC_REQUEST { window.state.borrow_mut().last_sync_counter = Some(x11rb::protocol::sync::Int64 { @@ -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); @@ -1205,6 +1216,33 @@ impl X11Client { Event::XinputMotion(event) => { let window = self.get_window(event.event)?; let mut state = self.0.borrow_mut(); + if window.is_blocked() { + // We want to set the cursor to the default arrow + // when the window is blocked + let style = CursorStyle::Arrow; + + let current_style = state + .cursor_styles + .get(&window.x_window) + .unwrap_or(&CursorStyle::Arrow); + if *current_style != style + && let Some(cursor) = state.get_cursor_icon(style) + { + state.cursor_styles.insert(window.x_window, style); + check_reply( + || "Failed to set cursor style", + state.xcb_connection.change_window_attributes( + window.x_window, + &ChangeWindowAttributesAux { + cursor: Some(cursor), + ..Default::default() + }, + ), + ) + .log_err(); + state.xcb_connection.flush().log_err(); + }; + } let pressed_button = pressed_button_from_mask(event.button_mask[0]); let position = point( px(event.event_x as f32 / u16::MAX as f32 / state.scale_factor), @@ -1478,7 +1516,7 @@ impl LinuxClient for X11Client { let parent_window = state .keyboard_focused_window .and_then(|focused_window| state.windows.get(&focused_window)) - .map(|window| window.window.x_window); + .map(|w| w.window.clone()); let x_window = state .xcb_connection .generate_id() @@ -1533,7 +1571,15 @@ impl LinuxClient for X11Client { .cursor_styles .get(&focused_window) .unwrap_or(&CursorStyle::Arrow); - if *current_style == style { + + let window = state + .mouse_focused_window + .and_then(|w| state.windows.get(&w)); + + let should_change = *current_style != style + && (window.is_none() || window.is_some_and(|w| !w.is_blocked())); + + if !should_change { return; } @@ -2516,3 +2562,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/linux/x11/window.rs b/crates/gpui/src/platform/linux/x11/window.rs index fe197a670177689ce776b6b55d439483c43921e0..1986ff6cce6b1930bdc3527eced5f2d5b8f45117 100644 --- a/crates/gpui/src/platform/linux/x11/window.rs +++ b/crates/gpui/src/platform/linux/x11/window.rs @@ -11,6 +11,7 @@ use crate::{ }; use blade_graphics as gpu; +use collections::FxHashSet; use raw_window_handle as rwh; use util::{ResultExt, maybe}; use x11rb::{ @@ -74,6 +75,7 @@ x11rb::atom_manager! { _NET_WM_WINDOW_TYPE, _NET_WM_WINDOW_TYPE_NOTIFICATION, _NET_WM_WINDOW_TYPE_DIALOG, + _NET_WM_STATE_MODAL, _NET_WM_SYNC, _NET_SUPPORTED, _MOTIF_WM_HINTS, @@ -249,6 +251,8 @@ pub struct Callbacks { pub struct X11WindowState { pub destroyed: bool, + parent: Option, + children: FxHashSet, client: X11ClientStatePtr, executor: ForegroundExecutor, atoms: XcbAtoms, @@ -394,7 +398,7 @@ impl X11WindowState { atoms: &XcbAtoms, scale_factor: f32, appearance: WindowAppearance, - parent_window: Option, + parent_window: Option, ) -> anyhow::Result { let x_screen_index = params .display_id @@ -546,8 +550,8 @@ impl X11WindowState { )?; } - if params.kind == WindowKind::Floating { - if let Some(parent_window) = parent_window { + if params.kind == WindowKind::Floating || params.kind == WindowKind::Dialog { + if let Some(parent_window) = parent_window.as_ref().map(|w| w.x_window) { // WM_TRANSIENT_FOR hint indicating the main application window. For floating windows, we set // a parent window (WM_TRANSIENT_FOR) such that the window manager knows where to // place the floating window in relation to the main window. @@ -563,11 +567,23 @@ impl X11WindowState { ), )?; } + } + + let parent = if params.kind == WindowKind::Dialog + && let Some(parent) = parent_window + { + parent.add_child(x_window); + + Some(parent) + } else { + None + }; + if params.kind == WindowKind::Dialog { // _NET_WM_WINDOW_TYPE_DIALOG indicates that this is a dialog (floating) window // https://specifications.freedesktop.org/wm-spec/1.4/ar01s05.html check_reply( - || "X11 ChangeProperty32 setting window type for floating window failed.", + || "X11 ChangeProperty32 setting window type for dialog window failed.", xcb.change_property32( xproto::PropMode::REPLACE, x_window, @@ -576,6 +592,20 @@ impl X11WindowState { &[atoms._NET_WM_WINDOW_TYPE_DIALOG], ), )?; + + // We set the modal state for dialog windows, so that the window manager + // can handle it appropriately (e.g., prevent interaction with the parent window + // while the dialog is open). + check_reply( + || "X11 ChangeProperty32 setting modal state for dialog window failed.", + xcb.change_property32( + xproto::PropMode::REPLACE, + x_window, + atoms._NET_WM_STATE, + xproto::AtomEnum::ATOM, + &[atoms._NET_WM_STATE_MODAL], + ), + )?; } check_reply( @@ -667,6 +697,8 @@ impl X11WindowState { let display = Rc::new(X11Display::new(xcb, scale_factor, x_screen_index)?); Ok(Self { + parent, + children: FxHashSet::default(), client, executor, display, @@ -720,6 +752,11 @@ pub(crate) struct X11Window(pub X11WindowStatePtr); impl Drop for X11Window { fn drop(&mut self) { let mut state = self.0.state.borrow_mut(); + + if let Some(parent) = state.parent.as_ref() { + parent.state.borrow_mut().children.remove(&self.0.x_window); + } + state.renderer.destroy(); let destroy_x_window = maybe!({ @@ -734,8 +771,6 @@ impl Drop for X11Window { .log_err(); if destroy_x_window.is_some() { - // Mark window as destroyed so that we can filter out when X11 events - // for it still come in. state.destroyed = true; let this_ptr = self.0.clone(); @@ -773,7 +808,7 @@ impl X11Window { atoms: &XcbAtoms, scale_factor: f32, appearance: WindowAppearance, - parent_window: Option, + parent_window: Option, ) -> anyhow::Result { let ptr = X11WindowStatePtr { state: Rc::new(RefCell::new(X11WindowState::new( @@ -979,7 +1014,31 @@ impl X11WindowStatePtr { Ok(()) } + pub fn add_child(&self, child: xproto::Window) { + let mut state = self.state.borrow_mut(); + state.children.insert(child); + } + + pub fn is_blocked(&self) -> bool { + let state = self.state.borrow(); + !state.children.is_empty() + } + pub fn close(&self) { + let state = self.state.borrow(); + let client = state.client.clone(); + #[allow(clippy::mutable_key_type)] + let children = state.children.clone(); + drop(state); + + if let Some(client) = client.get_client() { + for child in children { + if let Some(child_window) = client.get_window(child) { + child_window.close(); + } + } + } + let mut callbacks = self.callbacks.borrow_mut(); if let Some(fun) = callbacks.close.take() { fun() @@ -994,6 +1053,9 @@ impl X11WindowStatePtr { } pub fn handle_input(&self, input: PlatformInput) { + if self.is_blocked() { + return; + } if let Some(ref mut fun) = self.callbacks.borrow_mut().input && !fun(input.clone()).propagate { @@ -1016,6 +1078,9 @@ impl X11WindowStatePtr { } pub fn handle_ime_commit(&self, text: String) { + if self.is_blocked() { + return; + } let mut state = self.state.borrow_mut(); if let Some(mut input_handler) = state.input_handler.take() { drop(state); @@ -1026,6 +1091,9 @@ impl X11WindowStatePtr { } pub fn handle_ime_preedit(&self, text: String) { + if self.is_blocked() { + return; + } let mut state = self.state.borrow_mut(); if let Some(mut input_handler) = state.input_handler.take() { drop(state); @@ -1036,6 +1104,9 @@ impl X11WindowStatePtr { } pub fn handle_ime_unmark(&self) { + if self.is_blocked() { + return; + } let mut state = self.state.borrow_mut(); if let Some(mut input_handler) = state.input_handler.take() { drop(state); @@ -1046,6 +1117,9 @@ impl X11WindowStatePtr { } pub fn handle_ime_delete(&self) { + if self.is_blocked() { + return; + } let mut state = self.state.borrow_mut(); if let Some(mut input_handler) = state.input_handler.take() { drop(state); diff --git a/crates/gpui/src/platform/mac.rs b/crates/gpui/src/platform/mac.rs index 76d636b457517da64cf66988325652ddea56c5d3..aa056846e6bc56e53d95c41a44444dbb89a16237 100644 --- a/crates/gpui/src/platform/mac.rs +++ b/crates/gpui/src/platform/mac.rs @@ -135,6 +135,8 @@ unsafe impl objc::Encode for NSRange { } } +/// Allow NSString::alloc use here because it sets autorelease +#[allow(clippy::disallowed_methods)] unsafe fn ns_string(string: &str) -> id { unsafe { NSString::alloc(nil).init_str(string).autorelease() } } diff --git a/crates/gpui/src/platform/mac/attributed_string.rs b/crates/gpui/src/platform/mac/attributed_string.rs index 5f313ac699d6e1a096c4bcf807fd6c080d0064da..42fe1e5bf7a396a4eaa8ade26977a207d43b49b5 100644 --- a/crates/gpui/src/platform/mac/attributed_string.rs +++ b/crates/gpui/src/platform/mac/attributed_string.rs @@ -50,10 +50,12 @@ impl NSMutableAttributedString for id {} #[cfg(test)] mod tests { + use crate::platform::mac::ns_string; + use super::*; use cocoa::appkit::NSImage; use cocoa::base::nil; - use cocoa::foundation::NSString; + use cocoa::foundation::NSAutoreleasePool; #[test] #[ignore] // This was SIGSEGV-ing on CI but not locally; need to investigate https://github.com/zed-industries/zed/actions/runs/10362363230/job/28684225486?pr=15782#step:4:1348 fn test_nsattributed_string() { @@ -68,26 +70,34 @@ mod tests { impl NSTextAttachment for id {} unsafe { - let image: id = msg_send![class!(NSImage), alloc]; - image.initWithContentsOfFile_(NSString::alloc(nil).init_str("test.jpeg")); + let image: id = { + let img: id = msg_send![class!(NSImage), alloc]; + let img: id = msg_send![img, initWithContentsOfFile: ns_string("test.jpeg")]; + let img: id = msg_send![img, autorelease]; + img + }; let _size = image.size(); - let string = NSString::alloc(nil).init_str("Test String"); - let attr_string = NSMutableAttributedString::alloc(nil).init_attributed_string(string); - let hello_string = NSString::alloc(nil).init_str("Hello World"); - let hello_attr_string = - NSAttributedString::alloc(nil).init_attributed_string(hello_string); + let string = ns_string("Test String"); + let attr_string = NSMutableAttributedString::alloc(nil) + .init_attributed_string(string) + .autorelease(); + let hello_string = ns_string("Hello World"); + let hello_attr_string = NSAttributedString::alloc(nil) + .init_attributed_string(hello_string) + .autorelease(); attr_string.appendAttributedString_(hello_attr_string); - let attachment = NSTextAttachment::alloc(nil); + let attachment: id = msg_send![NSTextAttachment::alloc(nil), autorelease]; let _: () = msg_send![attachment, setImage: image]; let image_attr_string = msg_send![class!(NSAttributedString), attributedStringWithAttachment: attachment]; attr_string.appendAttributedString_(image_attr_string); - let another_string = NSString::alloc(nil).init_str("Another String"); - let another_attr_string = - NSAttributedString::alloc(nil).init_attributed_string(another_string); + let another_string = ns_string("Another String"); + let another_attr_string = NSAttributedString::alloc(nil) + .init_attributed_string(another_string) + .autorelease(); attr_string.appendAttributedString_(another_attr_string); let _len: cocoa::foundation::NSUInteger = msg_send![attr_string, length]; diff --git a/crates/gpui/src/platform/mac/dispatcher.rs b/crates/gpui/src/platform/mac/dispatcher.rs index 8a2f42234eea960669cb212853c437ec680a7fd7..1dfea82d58cbf2387571cabdcd7fbcfcf785c735 100644 --- a/crates/gpui/src/platform/mac/dispatcher.rs +++ b/crates/gpui/src/platform/mac/dispatcher.rs @@ -3,11 +3,22 @@ #![allow(non_snake_case)] use crate::{ - GLOBAL_THREAD_TIMINGS, PlatformDispatcher, RunnableMeta, RunnableVariant, THREAD_TIMINGS, - TaskLabel, TaskTiming, ThreadTaskTimings, + GLOBAL_THREAD_TIMINGS, PlatformDispatcher, Priority, RealtimePriority, RunnableMeta, + RunnableVariant, THREAD_TIMINGS, TaskLabel, TaskTiming, ThreadTaskTimings, }; +use anyhow::Context; use async_task::Runnable; +use mach2::{ + kern_return::KERN_SUCCESS, + mach_time::mach_timebase_info_data_t, + thread_policy::{ + THREAD_EXTENDED_POLICY, THREAD_EXTENDED_POLICY_COUNT, THREAD_PRECEDENCE_POLICY, + THREAD_PRECEDENCE_POLICY_COUNT, THREAD_TIME_CONSTRAINT_POLICY, + THREAD_TIME_CONSTRAINT_POLICY_COUNT, thread_extended_policy_data_t, + thread_precedence_policy_data_t, thread_time_constraint_policy_data_t, + }, +}; use objc::{ class, msg_send, runtime::{BOOL, YES}, @@ -15,9 +26,11 @@ use objc::{ }; use std::{ ffi::c_void, + mem::MaybeUninit, ptr::{NonNull, addr_of}, time::{Duration, Instant}, }; +use util::ResultExt; /// All items in the generated file are marked as pub, so we're gonna wrap it in a separate mod to prevent /// these pub items from leaking into public API. @@ -56,7 +69,7 @@ impl PlatformDispatcher for MacDispatcher { is_main_thread == YES } - fn dispatch(&self, runnable: RunnableVariant, _: Option) { + fn dispatch(&self, runnable: RunnableVariant, _: Option, priority: Priority) { let (context, trampoline) = match runnable { RunnableVariant::Meta(runnable) => ( runnable.into_raw().as_ptr() as *mut c_void, @@ -67,16 +80,24 @@ impl PlatformDispatcher for MacDispatcher { Some(trampoline_compat as unsafe extern "C" fn(*mut c_void)), ), }; + + let queue_priority = match priority { + Priority::Realtime(_) => unreachable!(), + Priority::High => DISPATCH_QUEUE_PRIORITY_HIGH as isize, + Priority::Medium => DISPATCH_QUEUE_PRIORITY_DEFAULT as isize, + Priority::Low => DISPATCH_QUEUE_PRIORITY_LOW as isize, + }; + unsafe { dispatch_async_f( - dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH.try_into().unwrap(), 0), + dispatch_get_global_queue(queue_priority, 0), context, trampoline, ); } } - fn dispatch_on_main_thread(&self, runnable: RunnableVariant) { + fn dispatch_on_main_thread(&self, runnable: RunnableVariant, _priority: Priority) { let (context, trampoline) = match runnable { RunnableVariant::Meta(runnable) => ( runnable.into_raw().as_ptr() as *mut c_void, @@ -110,6 +131,120 @@ impl PlatformDispatcher for MacDispatcher { dispatch_after_f(when, queue, context, trampoline); } } + + fn spawn_realtime(&self, priority: RealtimePriority, f: Box) { + std::thread::spawn(move || { + match priority { + RealtimePriority::Audio => set_audio_thread_priority(), + RealtimePriority::Other => set_high_thread_priority(), + } + .context(format!("for priority {:?}", priority)) + .log_err(); + + f(); + }); + } +} + +fn set_high_thread_priority() -> anyhow::Result<()> { + // SAFETY: always safe to call + let thread_id = unsafe { libc::pthread_self() }; + + // SAFETY: all sched_param members are valid when initialized to zero. + let mut sched_param = unsafe { MaybeUninit::::zeroed().assume_init() }; + sched_param.sched_priority = 45; + + let result = unsafe { libc::pthread_setschedparam(thread_id, libc::SCHED_FIFO, &sched_param) }; + if result != 0 { + anyhow::bail!("failed to set realtime thread priority") + } + + Ok(()) +} + +fn set_audio_thread_priority() -> anyhow::Result<()> { + // https://chromium.googlesource.com/chromium/chromium/+/master/base/threading/platform_thread_mac.mm#93 + + // SAFETY: always safe to call + let thread_id = unsafe { libc::pthread_self() }; + + // SAFETY: thread_id is a valid thread id + let thread_id = unsafe { libc::pthread_mach_thread_np(thread_id) }; + + // Fixed priority thread + let mut policy = thread_extended_policy_data_t { timeshare: 0 }; + + // SAFETY: thread_id is a valid thread id + // SAFETY: thread_extended_policy_data_t is passed as THREAD_EXTENDED_POLICY + let result = unsafe { + mach2::thread_policy::thread_policy_set( + thread_id, + THREAD_EXTENDED_POLICY, + &mut policy as *mut _ as *mut _, + THREAD_EXTENDED_POLICY_COUNT, + ) + }; + + if result != KERN_SUCCESS { + anyhow::bail!("failed to set thread extended policy"); + } + + // relatively high priority + let mut precedence = thread_precedence_policy_data_t { importance: 63 }; + + // SAFETY: thread_id is a valid thread id + // SAFETY: thread_precedence_policy_data_t is passed as THREAD_PRECEDENCE_POLICY + let result = unsafe { + mach2::thread_policy::thread_policy_set( + thread_id, + THREAD_PRECEDENCE_POLICY, + &mut precedence as *mut _ as *mut _, + THREAD_PRECEDENCE_POLICY_COUNT, + ) + }; + + if result != KERN_SUCCESS { + anyhow::bail!("failed to set thread precedence policy"); + } + + const GUARANTEED_AUDIO_DUTY_CYCLE: f32 = 0.75; + const MAX_AUDIO_DUTY_CYCLE: f32 = 0.85; + + // ~128 frames @ 44.1KHz + const TIME_QUANTUM: f32 = 2.9; + + const AUDIO_TIME_NEEDED: f32 = GUARANTEED_AUDIO_DUTY_CYCLE * TIME_QUANTUM; + const MAX_TIME_ALLOWED: f32 = MAX_AUDIO_DUTY_CYCLE * TIME_QUANTUM; + + let mut timebase_info = mach_timebase_info_data_t { numer: 0, denom: 0 }; + // SAFETY: timebase_info is a valid pointer to a mach_timebase_info_data_t struct + unsafe { mach2::mach_time::mach_timebase_info(&mut timebase_info) }; + + let ms_to_abs_time = ((timebase_info.denom as f32) / (timebase_info.numer as f32)) * 1000000f32; + + let mut time_constraints = thread_time_constraint_policy_data_t { + period: (TIME_QUANTUM * ms_to_abs_time) as u32, + computation: (AUDIO_TIME_NEEDED * ms_to_abs_time) as u32, + constraint: (MAX_TIME_ALLOWED * ms_to_abs_time) as u32, + preemptible: 0, + }; + + // SAFETY: thread_id is a valid thread id + // SAFETY: thread_precedence_pthread_time_constraint_policy_data_t is passed as THREAD_TIME_CONSTRAINT_POLICY + let result = unsafe { + mach2::thread_policy::thread_policy_set( + thread_id, + THREAD_TIME_CONSTRAINT_POLICY, + &mut time_constraints as *mut _ as *mut _, + THREAD_TIME_CONSTRAINT_POLICY_COUNT, + ) + }; + + if result != KERN_SUCCESS { + anyhow::bail!("failed to set thread time constraint policy"); + } + + Ok(()) } extern "C" fn trampoline(runnable: *mut c_void) { diff --git a/crates/gpui/src/platform/mac/display.rs b/crates/gpui/src/platform/mac/display.rs index 4ee27027d5fbff973b9ef2c27b5d55739c8a711a..94791620e8a394f67a38c257c95c575398cee0b7 100644 --- a/crates/gpui/src/platform/mac/display.rs +++ b/crates/gpui/src/platform/mac/display.rs @@ -1,9 +1,10 @@ -use crate::{Bounds, DisplayId, Pixels, PlatformDisplay, px, size}; +use super::ns_string; +use crate::{Bounds, DisplayId, Pixels, PlatformDisplay, point, px, size}; use anyhow::Result; use cocoa::{ appkit::NSScreen, base::{id, nil}, - foundation::{NSDictionary, NSString}, + foundation::{NSArray, NSDictionary}, }; use core_foundation::uuid::{CFUUIDGetUUIDBytes, CFUUIDRef}; use core_graphics::display::{CGDirectDisplayID, CGDisplayBounds, CGGetActiveDisplayList}; @@ -35,7 +36,7 @@ impl MacDisplay { let screens = NSScreen::screens(nil); let screen = cocoa::foundation::NSArray::objectAtIndex(screens, 0); let device_description = NSScreen::deviceDescription(screen); - let screen_number_key: id = NSString::alloc(nil).init_str("NSScreenNumber"); + let screen_number_key: id = ns_string("NSScreenNumber"); let screen_number = device_description.objectForKey_(screen_number_key); let screen_number: CGDirectDisplayID = msg_send![screen_number, unsignedIntegerValue]; Self(screen_number) @@ -114,4 +115,53 @@ impl PlatformDisplay for MacDisplay { } } } + + fn visible_bounds(&self) -> Bounds { + unsafe { + let dominated_screen = self.get_nsscreen(); + + if dominated_screen == nil { + return self.bounds(); + } + + let screen_frame = NSScreen::frame(dominated_screen); + let visible_frame = NSScreen::visibleFrame(dominated_screen); + + // Convert from bottom-left origin (AppKit) to top-left origin + let origin_y = + screen_frame.size.height - visible_frame.origin.y - visible_frame.size.height + + screen_frame.origin.y; + + Bounds { + origin: point( + px(visible_frame.origin.x as f32 - screen_frame.origin.x as f32), + px(origin_y as f32), + ), + size: size( + px(visible_frame.size.width as f32), + px(visible_frame.size.height as f32), + ), + } + } + } +} + +impl MacDisplay { + /// Find the NSScreen corresponding to this display + unsafe fn get_nsscreen(&self) -> id { + let screens = unsafe { NSScreen::screens(nil) }; + let count = unsafe { NSArray::count(screens) }; + let screen_number_key: id = unsafe { ns_string("NSScreenNumber") }; + + for i in 0..count { + let screen = unsafe { NSArray::objectAtIndex(screens, i) }; + let device_description = unsafe { NSScreen::deviceDescription(screen) }; + let screen_number = unsafe { device_description.objectForKey_(screen_number_key) }; + let screen_id: CGDirectDisplayID = msg_send![screen_number, unsignedIntegerValue]; + if screen_id == self.0 { + return screen; + } + } + nil + } } diff --git a/crates/gpui/src/platform/mac/events.rs b/crates/gpui/src/platform/mac/events.rs index acc392a5f3429f20931455ea06733376ea0f587a..7a12e8d3d7ccb2e8a2f7b32b81c24a29f650e6e2 100644 --- a/crates/gpui/src/platform/mac/events.rs +++ b/crates/gpui/src/platform/mac/events.rs @@ -1,7 +1,8 @@ use crate::{ Capslock, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, - MouseDownEvent, MouseExitEvent, MouseMoveEvent, MouseUpEvent, NavigationDirection, Pixels, - PlatformInput, ScrollDelta, ScrollWheelEvent, TouchPhase, + MouseDownEvent, MouseExitEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent, + NavigationDirection, Pixels, PlatformInput, PressureStage, ScrollDelta, ScrollWheelEvent, + TouchPhase, platform::mac::{ LMGetKbdType, NSStringExt, TISCopyCurrentKeyboardLayoutInputSource, TISGetInputSourceProperty, UCKeyTranslate, kTISPropertyUnicodeKeyLayoutData, @@ -187,6 +188,26 @@ impl PlatformInput { }) }) } + NSEventType::NSEventTypePressure => { + let stage = native_event.stage(); + let pressure = native_event.pressure(); + + window_height.map(|window_height| { + Self::MousePressure(MousePressureEvent { + stage: match stage { + 1 => PressureStage::Normal, + 2 => PressureStage::Force, + _ => PressureStage::Zero, + }, + pressure, + modifiers: read_modifiers(native_event), + position: point( + px(native_event.locationInWindow().x as f32), + window_height - px(native_event.locationInWindow().y as f32), + ), + }) + }) + } // Some mice (like Logitech MX Master) send navigation buttons as swipe events NSEventType::NSEventTypeSwipe => { let navigation_direction = match native_event.phase() { diff --git a/crates/gpui/src/platform/mac/metal_renderer.rs b/crates/gpui/src/platform/mac/metal_renderer.rs index 550041a0ccb4cd39bc7a86317d9540e806af2a28..66f54e5ba0c66a508f9db73d5ad8f84cb52d0d69 100644 --- a/crates/gpui/src/platform/mac/metal_renderer.rs +++ b/crates/gpui/src/platform/mac/metal_renderer.rs @@ -46,9 +46,9 @@ pub unsafe fn new_renderer( _native_window: *mut c_void, _native_view: *mut c_void, _bounds: crate::Size, - _transparent: bool, + transparent: bool, ) -> Renderer { - MetalRenderer::new(context) + MetalRenderer::new(context, transparent) } pub(crate) struct InstanceBufferPool { @@ -128,7 +128,7 @@ pub struct PathRasterizationVertex { } impl MetalRenderer { - pub fn new(instance_buffer_pool: Arc>) -> Self { + pub fn new(instance_buffer_pool: Arc>, transparent: bool) -> Self { // Prefer low‐power integrated GPUs on Intel Mac. On Apple // Silicon, there is only ever one GPU, so this is equivalent to // `metal::Device::system_default()`. @@ -152,8 +152,13 @@ impl MetalRenderer { let layer = metal::MetalLayer::new(); layer.set_device(&device); layer.set_pixel_format(MTLPixelFormat::BGRA8Unorm); - layer.set_opaque(false); + // Support direct-to-display rendering if the window is not transparent + // https://developer.apple.com/documentation/metal/managing-your-game-window-for-metal-in-macos + layer.set_opaque(!transparent); layer.set_maximum_drawable_count(3); + // We already present at display sync with the display link + // This allows to use direct-to-display even in window mode + layer.set_display_sync_enabled(false); unsafe { let _: () = msg_send![&*layer, setAllowsNextDrawableTimeout: NO]; let _: () = msg_send![&*layer, setNeedsDisplayOnBoundsChange: YES]; @@ -352,8 +357,8 @@ impl MetalRenderer { } } - pub fn update_transparency(&self, _transparent: bool) { - // todo(mac)? + pub fn update_transparency(&self, transparent: bool) { + self.layer.set_opaque(!transparent); } pub fn destroy(&self) { 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/platform.rs b/crates/gpui/src/platform/mac/platform.rs index c2363afe270f973513c8ba696bf5d3f99fb92cad..ee67f465e34bd8109246f68b311e225aa8f9fd0a 100644 --- a/crates/gpui/src/platform/mac/platform.rs +++ b/crates/gpui/src/platform/mac/platform.rs @@ -2,7 +2,7 @@ use super::{ BoolExt, MacKeyboardLayout, MacKeyboardMapper, attributed_string::{NSAttributedString, NSMutableAttributedString}, events::key_to_native, - renderer, + ns_string, renderer, }; use crate::{ Action, AnyWindowHandle, BackgroundExecutor, ClipboardEntry, ClipboardItem, ClipboardString, @@ -1061,13 +1061,15 @@ impl Platform for MacPlatform { let attributed_string = { let mut buf = NSMutableAttributedString::alloc(nil) // TODO can we skip this? Or at least part of it? - .init_attributed_string(NSString::alloc(nil).init_str("")); + .init_attributed_string(ns_string("")) + .autorelease(); for entry in item.entries { if let ClipboardEntry::String(ClipboardString { text, metadata: _ }) = entry { let to_append = NSAttributedString::alloc(nil) - .init_attributed_string(NSString::alloc(nil).init_str(&text)); + .init_attributed_string(ns_string(&text)) + .autorelease(); buf.appendAttributedString_(to_append); } @@ -1543,10 +1545,6 @@ extern "C" fn handle_dock_menu(this: &mut Object, _: Sel, _: id) -> id { } } -unsafe fn ns_string(string: &str) -> id { - unsafe { NSString::alloc(nil).init_str(string).autorelease() } -} - unsafe fn ns_url_to_path(url: id) -> Result { let path: *mut c_char = msg_send![url, fileSystemRepresentation]; anyhow::ensure!(!path.is_null(), "url is not a file path: {}", unsafe { diff --git a/crates/gpui/src/platform/mac/screen_capture.rs b/crates/gpui/src/platform/mac/screen_capture.rs index 4d4ffa6896520e465dfeb7b1ccc06e1149f9e25d..4b80a87d32f45540c76790065514f29cc7f93b3f 100644 --- a/crates/gpui/src/platform/mac/screen_capture.rs +++ b/crates/gpui/src/platform/mac/screen_capture.rs @@ -1,3 +1,4 @@ +use super::ns_string; use crate::{ DevicePixels, ForegroundExecutor, SharedString, SourceMetadata, platform::{ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream}, @@ -7,7 +8,7 @@ use anyhow::{Result, anyhow}; use block::ConcreteBlock; use cocoa::{ base::{YES, id, nil}, - foundation::{NSArray, NSString}, + foundation::NSArray, }; use collections::HashMap; use core_foundation::base::TCFType; @@ -109,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; } @@ -131,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(); @@ -195,7 +206,7 @@ unsafe fn screen_id_to_human_label() -> HashMap { let screens: id = msg_send![class!(NSScreen), screens]; let count: usize = msg_send![screens, count]; let mut map = HashMap::default(); - let screen_number_key = unsafe { NSString::alloc(nil).init_str("NSScreenNumber") }; + let screen_number_key = unsafe { ns_string("NSScreenNumber") }; for i in 0..count { let screen: id = msg_send![screens, objectAtIndex: i]; let device_desc: id = msg_send![screen, deviceDescription]; 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 23752fc53edbc1062db19caf13c5c65fc282ca87..f843fcd943523dc9a1c228cea1c4dcdf63c76097 100644 --- a/crates/gpui/src/platform/mac/window.rs +++ b/crates/gpui/src/platform/mac/window.rs @@ -62,9 +62,12 @@ static mut BLURRED_VIEW_CLASS: *const Class = ptr::null(); #[allow(non_upper_case_globals)] const NSWindowStyleMaskNonactivatingPanel: NSWindowStyleMask = NSWindowStyleMask::from_bits_retain(1 << 7); +// WindowLevel const value ref: https://docs.rs/core-graphics2/0.4.1/src/core_graphics2/window_level.rs.html #[allow(non_upper_case_globals)] const NSNormalWindowLevel: NSInteger = 0; #[allow(non_upper_case_globals)] +const NSFloatingWindowLevel: NSInteger = 3; +#[allow(non_upper_case_globals)] const NSPopUpWindowLevel: NSInteger = 101; #[allow(non_upper_case_globals)] const NSTrackingMouseEnteredAndExited: NSUInteger = 0x01; @@ -153,6 +156,10 @@ unsafe fn build_classes() { sel!(mouseMoved:), handle_view_event as extern "C" fn(&Object, Sel, id), ); + decl.add_method( + sel!(pressureChangeWithEvent:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); decl.add_method( sel!(mouseExited:), handle_view_event as extern "C" fn(&Object, Sel, id), @@ -419,6 +426,8 @@ struct MacWindowState { select_previous_tab_callback: Option>, toggle_tab_bar_callback: Option>, activated_least_once: bool, + // The parent window if this window is a sheet (Dialog kind) + sheet_parent: Option, } impl MacWindowState { @@ -618,11 +627,16 @@ impl MacWindow { } let native_window: id = match kind { - WindowKind::Normal | WindowKind::Floating => msg_send![WINDOW_CLASS, alloc], + WindowKind::Normal => { + msg_send![WINDOW_CLASS, alloc] + } WindowKind::PopUp => { style_mask |= NSWindowStyleMaskNonactivatingPanel; msg_send![PANEL_CLASS, alloc] } + WindowKind::Floating | WindowKind::Dialog => { + msg_send![PANEL_CLASS, alloc] + } }; let display = display_id @@ -725,6 +739,7 @@ impl MacWindow { select_previous_tab_callback: None, toggle_tab_bar_callback: None, activated_least_once: false, + sheet_parent: None, }))); (*native_window).set_ivar( @@ -775,13 +790,22 @@ impl MacWindow { content_view.addSubview_(native_view.autorelease()); native_window.makeFirstResponder_(native_view); + let app: id = NSApplication::sharedApplication(nil); + let main_window: id = msg_send![app, mainWindow]; + let mut sheet_parent = None; + match kind { WindowKind::Normal | WindowKind::Floating => { - native_window.setLevel_(NSNormalWindowLevel); + if kind == WindowKind::Floating { + // Let the window float keep above normal windows. + native_window.setLevel_(NSFloatingWindowLevel); + } else { + native_window.setLevel_(NSNormalWindowLevel); + } native_window.setAcceptsMouseMovedEvents_(YES); if let Some(tabbing_identifier) = tabbing_identifier { - let tabbing_id = NSString::alloc(nil).init_str(tabbing_identifier.as_str()); + let tabbing_id = ns_string(tabbing_identifier.as_str()); let _: () = msg_send![native_window, setTabbingIdentifier: tabbing_id]; } else { let _: () = msg_send![native_window, setTabbingIdentifier:nil]; @@ -812,10 +836,23 @@ impl MacWindow { NSWindowCollectionBehavior::NSWindowCollectionBehaviorFullScreenAuxiliary ); } + WindowKind::Dialog => { + if !main_window.is_null() { + let parent = { + let active_sheet: id = msg_send![main_window, attachedSheet]; + if active_sheet.is_null() { + main_window + } else { + active_sheet + } + }; + let _: () = + msg_send![parent, beginSheet: native_window completionHandler: nil]; + sheet_parent = Some(parent); + } + } } - let app = NSApplication::sharedApplication(nil); - let main_window: id = msg_send![app, mainWindow]; if allows_automatic_window_tabbing && !main_window.is_null() && main_window != native_window @@ -857,7 +894,11 @@ impl MacWindow { // the window position might be incorrect if the main screen (the screen that contains the window that has focus) // is different from the primary screen. NSWindow::setFrameTopLeftPoint_(native_window, window_rect.origin); - window.0.lock().move_traffic_light(); + { + let mut window_state = window.0.lock(); + window_state.move_traffic_light(); + window_state.sheet_parent = sheet_parent; + } pool.drain(); @@ -904,8 +945,8 @@ impl MacWindow { pub fn get_user_tabbing_preference() -> Option { unsafe { let defaults: id = NSUserDefaults::standardUserDefaults(); - let domain = NSString::alloc(nil).init_str("NSGlobalDomain"); - let key = NSString::alloc(nil).init_str("AppleWindowTabbingMode"); + let domain = ns_string("NSGlobalDomain"); + let key = ns_string("AppleWindowTabbingMode"); let dict: id = msg_send![defaults, persistentDomainForName: domain]; let value: id = if !dict.is_null() { @@ -934,6 +975,7 @@ impl Drop for MacWindow { let mut this = self.0.lock(); this.renderer.destroy(); let window = this.native_window; + let sheet_parent = this.sheet_parent.take(); this.display_link.take(); unsafe { this.native_window.setDelegate_(nil); @@ -942,6 +984,9 @@ impl Drop for MacWindow { this.executor .spawn(async move { unsafe { + if let Some(parent) = sheet_parent { + let _: () = msg_send![parent, endSheet: window]; + } window.close(); window.autorelease(); } @@ -1033,7 +1078,7 @@ impl PlatformWindow for MacWindow { } if let Some(tabbing_identifier) = tabbing_identifier { - let tabbing_id = NSString::alloc(nil).init_str(tabbing_identifier.as_str()); + let tabbing_id = ns_string(tabbing_identifier.as_str()); let _: () = msg_send![native_window, setTabbingIdentifier: tabbing_id]; } else { let _: () = msg_send![native_window, setTabbingIdentifier:nil]; @@ -1059,10 +1104,8 @@ impl PlatformWindow for MacWindow { return None; } let device_description: id = msg_send![screen, deviceDescription]; - let screen_number: id = NSDictionary::valueForKey_( - device_description, - NSString::alloc(nil).init_str("NSScreenNumber"), - ); + let screen_number: id = + NSDictionary::valueForKey_(device_description, ns_string("NSScreenNumber")); let screen_number: u32 = msg_send![screen_number, unsignedIntValue]; @@ -1188,6 +1231,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()); } @@ -1505,8 +1549,8 @@ impl PlatformWindow for MacWindow { .spawn(async move { unsafe { let defaults: id = NSUserDefaults::standardUserDefaults(); - let domain = NSString::alloc(nil).init_str("NSGlobalDomain"); - let key = NSString::alloc(nil).init_str("AppleActionOnDoubleClick"); + let domain = ns_string("NSGlobalDomain"); + let key = ns_string("AppleActionOnDoubleClick"); let dict: id = msg_send![defaults, persistentDomainForName: domain]; let action: id = if !dict.is_null() { @@ -2508,7 +2552,7 @@ where unsafe fn display_id_for_screen(screen: id) -> CGDirectDisplayID { unsafe { let device_description = NSScreen::deviceDescription(screen); - let screen_number_key: id = NSString::alloc(nil).init_str("NSScreenNumber"); + let screen_number_key: id = ns_string("NSScreenNumber"); let screen_number = device_description.objectForKey_(screen_number_key); let screen_number: NSUInteger = msg_send![screen_number, unsignedIntegerValue]; screen_number as CGDirectDisplayID @@ -2554,7 +2598,7 @@ unsafe fn remove_layer_background(layer: id) { // `description` reflects its name and some parameters. Currently `NSVisualEffectView` // uses a `CAFilter` named "colorSaturate". If one day they switch to `CIFilter`, the // `description` will still contain "Saturat" ("... inputSaturation = ..."). - let test_string: id = NSString::alloc(nil).init_str("Saturat").autorelease(); + let test_string: id = ns_string("Saturat"); let count = NSArray::count(filters); for i in 0..count { let description: id = msg_send![filters.objectAtIndex(i), description]; diff --git a/crates/gpui/src/platform/test/dispatcher.rs b/crates/gpui/src/platform/test/dispatcher.rs index 538aacda83a095449193db6aab63f3a06189ef7a..c271430586106abc93e0bb3258c9e25a06b12383 100644 --- a/crates/gpui/src/platform/test/dispatcher.rs +++ b/crates/gpui/src/platform/test/dispatcher.rs @@ -1,4 +1,4 @@ -use crate::{PlatformDispatcher, RunnableVariant, TaskLabel}; +use crate::{PlatformDispatcher, Priority, RunnableVariant, TaskLabel}; use backtrace::Backtrace; use collections::{HashMap, HashSet, VecDeque}; use parking::Unparker; @@ -284,7 +284,7 @@ impl PlatformDispatcher for TestDispatcher { state.start_time + state.time } - fn dispatch(&self, runnable: RunnableVariant, label: Option) { + fn dispatch(&self, runnable: RunnableVariant, label: Option, _priority: Priority) { { let mut state = self.state.lock(); if label.is_some_and(|label| state.deprioritized_task_labels.contains(&label)) { @@ -296,7 +296,7 @@ impl PlatformDispatcher for TestDispatcher { self.unpark_all(); } - fn dispatch_on_main_thread(&self, runnable: RunnableVariant) { + fn dispatch_on_main_thread(&self, runnable: RunnableVariant, _priority: Priority) { self.state .lock() .foreground @@ -318,4 +318,10 @@ impl PlatformDispatcher for TestDispatcher { fn as_test(&self) -> Option<&TestDispatcher> { Some(self) } + + fn spawn_realtime(&self, _priority: crate::RealtimePriority, f: Box) { + std::thread::spawn(move || { + f(); + }); + } } diff --git a/crates/gpui/src/platform/windows/dispatcher.rs b/crates/gpui/src/platform/windows/dispatcher.rs index 6214e60e5b4b178c20b1fff655f4ac8b49be3f4c..14486ccee9843ef9c0792d62f22fa825f0db43ee 100644 --- a/crates/gpui/src/platform/windows/dispatcher.rs +++ b/crates/gpui/src/platform/windows/dispatcher.rs @@ -146,14 +146,19 @@ impl PlatformDispatcher for WindowsDispatcher { current().id() == self.main_thread_id } - fn dispatch(&self, runnable: RunnableVariant, label: Option) { + 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) { + 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) { @@ -185,4 +190,9 @@ impl PlatformDispatcher for WindowsDispatcher { fn dispatch_after(&self, duration: Duration, runnable: RunnableVariant) { self.dispatch_on_threadpool_after(runnable, duration); } + + fn spawn_realtime(&self, _priority: crate::RealtimePriority, _f: Box) { + // disabled on windows for now. + unimplemented!(); + } } diff --git a/crates/gpui/src/platform/windows/display.rs b/crates/gpui/src/platform/windows/display.rs index ea8960580dc7f45f0dc878247e8387b6a1032ea2..720d459c1ce3b0251d8009dc2b77864727ed5441 100644 --- a/crates/gpui/src/platform/windows/display.rs +++ b/crates/gpui/src/platform/windows/display.rs @@ -23,6 +23,7 @@ pub(crate) struct WindowsDisplay { pub display_id: DisplayId, scale_factor: f32, bounds: Bounds, + visible_bounds: Bounds, physical_bounds: Bounds, uuid: Uuid, } @@ -36,6 +37,7 @@ impl WindowsDisplay { let screen = available_monitors().into_iter().nth(display_id.0 as _)?; let info = get_monitor_info(screen).log_err()?; let monitor_size = info.monitorInfo.rcMonitor; + let work_area = info.monitorInfo.rcWork; let uuid = generate_uuid(&info.szDevice); let scale_factor = get_scale_factor_for_monitor(screen).log_err()?; let physical_size = size( @@ -55,6 +57,14 @@ impl WindowsDisplay { ), size: physical_size.to_pixels(scale_factor), }, + visible_bounds: Bounds { + origin: logical_point(work_area.left as f32, work_area.top as f32, scale_factor), + size: size( + (work_area.right - work_area.left) as f32 / scale_factor, + (work_area.bottom - work_area.top) as f32 / scale_factor, + ) + .map(crate::px), + }, physical_bounds: Bounds { origin: point(monitor_size.left.into(), monitor_size.top.into()), size: physical_size, @@ -66,6 +76,7 @@ impl WindowsDisplay { pub fn new_with_handle(monitor: HMONITOR) -> anyhow::Result { let info = get_monitor_info(monitor)?; let monitor_size = info.monitorInfo.rcMonitor; + let work_area = info.monitorInfo.rcWork; let uuid = generate_uuid(&info.szDevice); let display_id = available_monitors() .iter() @@ -89,6 +100,14 @@ impl WindowsDisplay { ), size: physical_size.to_pixels(scale_factor), }, + visible_bounds: Bounds { + origin: logical_point(work_area.left as f32, work_area.top as f32, scale_factor), + size: size( + (work_area.right - work_area.left) as f32 / scale_factor, + (work_area.bottom - work_area.top) as f32 / scale_factor, + ) + .map(crate::px), + }, physical_bounds: Bounds { origin: point(monitor_size.left.into(), monitor_size.top.into()), size: physical_size, @@ -100,6 +119,7 @@ impl WindowsDisplay { fn new_with_handle_and_id(handle: HMONITOR, display_id: DisplayId) -> anyhow::Result { let info = get_monitor_info(handle)?; let monitor_size = info.monitorInfo.rcMonitor; + let work_area = info.monitorInfo.rcWork; let uuid = generate_uuid(&info.szDevice); let scale_factor = get_scale_factor_for_monitor(handle)?; let physical_size = size( @@ -119,6 +139,14 @@ impl WindowsDisplay { ), size: physical_size.to_pixels(scale_factor), }, + visible_bounds: Bounds { + origin: logical_point(work_area.left as f32, work_area.top as f32, scale_factor), + size: size( + (work_area.right - work_area.left) as f32 / scale_factor, + (work_area.bottom - work_area.top) as f32 / scale_factor, + ) + .map(crate::px), + }, physical_bounds: Bounds { origin: point(monitor_size.left.into(), monitor_size.top.into()), size: physical_size, @@ -193,6 +221,10 @@ impl PlatformDisplay for WindowsDisplay { fn bounds(&self) -> Bounds { self.bounds } + + fn visible_bounds(&self) -> Bounds { + self.visible_bounds + } } fn available_monitors() -> SmallVec<[HMONITOR; 4]> { diff --git a/crates/gpui/src/platform/windows/events.rs b/crates/gpui/src/platform/windows/events.rs index e6fa6006eb95ec45f1634cb72ef63e2f622455a7..1f0a4a0d28c2b266fb8588e4ce54251be010a78d 100644 --- a/crates/gpui/src/platform/windows/events.rs +++ b/crates/gpui/src/platform/windows/events.rs @@ -40,6 +40,11 @@ impl WindowsWindowInner { lparam: LPARAM, ) -> LRESULT { let handled = match msg { + // eagerly activate the window, so calls to `active_window` will work correctly + WM_MOUSEACTIVATE => { + unsafe { SetActiveWindow(handle).log_err() }; + None + } WM_ACTIVATE => self.handle_activate_msg(wparam), WM_CREATE => self.handle_create_msg(handle), WM_MOVE => self.handle_move_msg(handle, lparam), @@ -265,6 +270,14 @@ impl WindowsWindowInner { fn handle_destroy_msg(&self, handle: HWND) -> Option { let callback = { self.state.callbacks.close.take() }; + // Re-enable parent window if this was a modal dialog + if let Some(parent_hwnd) = self.parent_hwnd { + unsafe { + let _ = EnableWindow(parent_hwnd, true); + let _ = SetForegroundWindow(parent_hwnd); + } + } + if let Some(callback) = callback { callback(); } diff --git a/crates/gpui/src/platform/windows/platform.rs b/crates/gpui/src/platform/windows/platform.rs index af0cb89ecc94da70cc42c8d4c397aeb2a811d6fb..0e0fdd56c54d56587c09bca14f16dd8e5aef389d 100644 --- a/crates/gpui/src/platform/windows/platform.rs +++ b/crates/gpui/src/platform/windows/platform.rs @@ -659,7 +659,7 @@ impl Platform for WindowsPlatform { if let Err(err) = result { // ERROR_NOT_FOUND means the credential doesn't exist. // Return Ok(None) to match macOS and Linux behavior. - if err.code().0 == ERROR_NOT_FOUND.0 as i32 { + if err.code() == ERROR_NOT_FOUND.to_hresult() { return Ok(None); } return Err(err.into()); diff --git a/crates/gpui/src/platform/windows/window.rs b/crates/gpui/src/platform/windows/window.rs index 7ef92b4150e69424b68e9417dda377aa7f2e9cc0..3fcc29ad7864f8e45d27638bef489ffbf03788b2 100644 --- a/crates/gpui/src/platform/windows/window.rs +++ b/crates/gpui/src/platform/windows/window.rs @@ -83,6 +83,7 @@ pub(crate) struct WindowsWindowInner { pub(crate) validation_number: usize, pub(crate) main_receiver: flume::Receiver, pub(crate) platform_window_handle: HWND, + pub(crate) parent_hwnd: Option, } impl WindowsWindowState { @@ -241,6 +242,7 @@ impl WindowsWindowInner { main_receiver: context.main_receiver.clone(), platform_window_handle: context.platform_window_handle, system_settings: WindowsSystemSettings::new(context.display), + parent_hwnd: context.parent_hwnd, })) } @@ -368,6 +370,7 @@ struct WindowCreateContext { disable_direct_composition: bool, directx_devices: DirectXDevices, invalidate_devices: Arc, + parent_hwnd: Option, } impl WindowsWindow { @@ -390,6 +393,20 @@ impl WindowsWindow { invalidate_devices, } = creation_info; register_window_class(icon); + let parent_hwnd = if params.kind == WindowKind::Dialog { + let parent_window = unsafe { GetActiveWindow() }; + if parent_window.is_invalid() { + None + } else { + // Disable the parent window to make this dialog modal + unsafe { + EnableWindow(parent_window, false).as_bool(); + }; + Some(parent_window) + } + } else { + None + }; let hide_title_bar = params .titlebar .as_ref() @@ -416,8 +433,14 @@ impl WindowsWindow { if params.is_minimizable { dwstyle |= WS_MINIMIZEBOX; } + let dwexstyle = if params.kind == WindowKind::Dialog { + dwstyle |= WS_POPUP | WS_CAPTION; + WS_EX_DLGMODALFRAME + } else { + WS_EX_APPWINDOW + }; - (WS_EX_APPWINDOW, dwstyle) + (dwexstyle, dwstyle) }; if !disable_direct_composition { dwexstyle |= WS_EX_NOREDIRECTIONBITMAP; @@ -449,6 +472,7 @@ impl WindowsWindow { disable_direct_composition, directx_devices, invalidate_devices, + parent_hwnd, }; let creation_result = unsafe { CreateWindowExW( @@ -460,7 +484,7 @@ impl WindowsWindow { CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, - None, + parent_hwnd, None, Some(hinstance.into()), Some(&context as *const _ as *const _), diff --git a/crates/gpui/src/profiler.rs b/crates/gpui/src/profiler.rs index 4e3f00c412cd19c8269497ff292ce9dbdd785fbe..73f435d7e798c78d6c7320a49da804ebe703c434 100644 --- a/crates/gpui/src/profiler.rs +++ b/crates/gpui/src/profiler.rs @@ -216,3 +216,19 @@ impl Drop for ThreadTimings { thread_timings.swap_remove(index); } } + +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); + }); +} diff --git a/crates/gpui/src/queue.rs b/crates/gpui/src/queue.rs new file mode 100644 index 0000000000000000000000000000000000000000..9e9da710977ee80df1853791918eebe5e7f01096 --- /dev/null +++ b/crates/gpui/src/queue.rs @@ -0,0 +1,328 @@ +use std::{ + fmt, + iter::FusedIterator, + sync::{Arc, atomic::AtomicUsize}, +}; + +use rand::{Rng, SeedableRng, rngs::SmallRng}; + +use crate::Priority; + +struct PriorityQueues { + high_priority: Vec, + medium_priority: Vec, + low_priority: Vec, +} + +impl PriorityQueues { + fn is_empty(&self) -> bool { + self.high_priority.is_empty() + && self.medium_priority.is_empty() + && self.low_priority.is_empty() + } +} + +struct PriorityQueueState { + queues: parking_lot::Mutex>, + condvar: parking_lot::Condvar, + receiver_count: AtomicUsize, + sender_count: AtomicUsize, +} + +impl PriorityQueueState { + fn send(&self, priority: Priority, item: T) -> Result<(), SendError> { + if self + .receiver_count + .load(std::sync::atomic::Ordering::Relaxed) + == 0 + { + return Err(SendError(item)); + } + + let mut queues = self.queues.lock(); + match priority { + Priority::Realtime(_) => unreachable!(), + Priority::High => queues.high_priority.push(item), + Priority::Medium => queues.medium_priority.push(item), + Priority::Low => queues.low_priority.push(item), + }; + self.condvar.notify_one(); + Ok(()) + } + + fn recv<'a>(&'a self) -> Result>, RecvError> { + let mut queues = self.queues.lock(); + + let sender_count = self.sender_count.load(std::sync::atomic::Ordering::Relaxed); + if queues.is_empty() && sender_count == 0 { + return Err(crate::queue::RecvError); + } + + while queues.is_empty() { + self.condvar.wait(&mut queues); + } + + Ok(queues) + } + + fn try_recv<'a>( + &'a self, + ) -> Result>>, RecvError> { + let mut queues = self.queues.lock(); + + let sender_count = self.sender_count.load(std::sync::atomic::Ordering::Relaxed); + if queues.is_empty() && sender_count == 0 { + return Err(crate::queue::RecvError); + } + + if queues.is_empty() { + Ok(None) + } else { + Ok(Some(queues)) + } + } +} + +pub(crate) struct PriorityQueueSender { + state: Arc>, +} + +impl PriorityQueueSender { + fn new(state: Arc>) -> Self { + Self { state } + } + + pub(crate) fn send(&self, priority: Priority, item: T) -> Result<(), SendError> { + self.state.send(priority, item)?; + Ok(()) + } +} + +impl Drop for PriorityQueueSender { + fn drop(&mut self) { + self.state + .sender_count + .fetch_sub(1, std::sync::atomic::Ordering::AcqRel); + } +} + +pub(crate) struct PriorityQueueReceiver { + state: Arc>, + rand: SmallRng, + disconnected: bool, +} + +impl Clone for PriorityQueueReceiver { + fn clone(&self) -> Self { + self.state + .receiver_count + .fetch_add(1, std::sync::atomic::Ordering::AcqRel); + Self { + state: Arc::clone(&self.state), + rand: SmallRng::seed_from_u64(0), + disconnected: self.disconnected, + } + } +} + +pub(crate) struct SendError(T); + +impl fmt::Debug for SendError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("SendError").field(&self.0).finish() + } +} + +#[derive(Debug)] +pub(crate) struct RecvError; + +#[allow(dead_code)] +impl PriorityQueueReceiver { + pub(crate) fn new() -> (PriorityQueueSender, Self) { + let state = PriorityQueueState { + queues: parking_lot::Mutex::new(PriorityQueues { + high_priority: Vec::new(), + medium_priority: Vec::new(), + low_priority: Vec::new(), + }), + condvar: parking_lot::Condvar::new(), + receiver_count: AtomicUsize::new(1), + sender_count: AtomicUsize::new(1), + }; + let state = Arc::new(state); + + let sender = PriorityQueueSender::new(Arc::clone(&state)); + + let receiver = PriorityQueueReceiver { + state, + rand: SmallRng::seed_from_u64(0), + disconnected: false, + }; + + (sender, receiver) + } + + /// Tries to pop one element from the priority queue without blocking. + /// + /// This will early return if there are no elements in the queue. + /// + /// This method is best suited if you only intend to pop one element, for better performance + /// on large queues see [`Self::try_iter`] + /// + /// # Errors + /// + /// If the sender was dropped + pub(crate) fn try_pop(&mut self) -> Result, RecvError> { + self.pop_inner(false) + } + + /// Pops an element from the priority queue blocking if necessary. + /// + /// This method is best suited if you only intend to pop one element, for better performance + /// on large queues see [`Self::iter``] + /// + /// # Errors + /// + /// If the sender was dropped + pub(crate) fn pop(&mut self) -> Result { + self.pop_inner(true).map(|e| e.unwrap()) + } + + /// Returns an iterator over the elements of the queue + /// this iterator will end when all elements have been consumed and will not wait for new ones. + pub(crate) fn try_iter(self) -> TryIter { + TryIter { + receiver: self, + ended: false, + } + } + + /// Returns an iterator over the elements of the queue + /// this iterator will wait for new elements if the queue is empty. + pub(crate) fn iter(self) -> Iter { + Iter(self) + } + + #[inline(always)] + // algorithm is the loaded die from biased coin from + // https://www.keithschwarz.com/darts-dice-coins/ + fn pop_inner(&mut self, block: bool) -> Result, RecvError> { + use Priority as P; + + let mut queues = if !block { + let Some(queues) = self.state.try_recv()? else { + return Ok(None); + }; + queues + } else { + self.state.recv()? + }; + + let high = P::High.probability() * !queues.high_priority.is_empty() as u32; + let medium = P::Medium.probability() * !queues.medium_priority.is_empty() as u32; + let low = P::Low.probability() * !queues.low_priority.is_empty() as u32; + let mut mass = high + medium + low; //% + + if !queues.high_priority.is_empty() { + let flip = self.rand.random_ratio(P::High.probability(), mass); + if flip { + return Ok(queues.high_priority.pop()); + } + mass -= P::High.probability(); + } + + if !queues.medium_priority.is_empty() { + let flip = self.rand.random_ratio(P::Medium.probability(), mass); + if flip { + return Ok(queues.medium_priority.pop()); + } + mass -= P::Medium.probability(); + } + + if !queues.low_priority.is_empty() { + let flip = self.rand.random_ratio(P::Low.probability(), mass); + if flip { + return Ok(queues.low_priority.pop()); + } + } + + Ok(None) + } +} + +impl Drop for PriorityQueueReceiver { + fn drop(&mut self) { + self.state + .receiver_count + .fetch_sub(1, std::sync::atomic::Ordering::AcqRel); + } +} + +/// If None is returned the sender disconnected +pub(crate) struct Iter(PriorityQueueReceiver); +impl Iterator for Iter { + type Item = T; + + fn next(&mut self) -> Option { + self.0.pop().ok() + } +} +impl FusedIterator for Iter {} + +/// If None is returned there are no more elements in the queue +pub(crate) struct TryIter { + receiver: PriorityQueueReceiver, + ended: bool, +} +impl Iterator for TryIter { + type Item = Result; + + fn next(&mut self) -> Option { + if self.ended { + return None; + } + + let res = self.receiver.try_pop(); + self.ended = res.is_err(); + + res.transpose() + } +} +impl FusedIterator for TryIter {} + +#[cfg(test)] +mod tests { + use collections::HashSet; + + use super::*; + + #[test] + fn all_tasks_get_yielded() { + let (tx, mut rx) = PriorityQueueReceiver::new(); + tx.send(Priority::Medium, 20).unwrap(); + tx.send(Priority::High, 30).unwrap(); + tx.send(Priority::Low, 10).unwrap(); + tx.send(Priority::Medium, 21).unwrap(); + tx.send(Priority::High, 31).unwrap(); + + drop(tx); + + assert_eq!( + rx.iter().collect::>(), + [30, 31, 20, 21, 10].into_iter().collect::>() + ) + } + + #[test] + fn new_high_prio_task_get_scheduled_quickly() { + let (tx, mut rx) = PriorityQueueReceiver::new(); + for _ in 0..100 { + tx.send(Priority::Low, 1).unwrap(); + } + + assert_eq!(rx.pop().unwrap(), 1); + tx.send(Priority::High, 3).unwrap(); + assert_eq!(rx.pop().unwrap(), 3); + assert_eq!(rx.pop().unwrap(), 1); + } +} diff --git a/crates/gpui/src/style.rs b/crates/gpui/src/style.rs index 42f8f25e47620fe673720055037b7f91f44165a2..4d6e6f490d81d967692a3e9d8316af75a7a4d306 100644 --- a/crates/gpui/src/style.rs +++ b/crates/gpui/src/style.rs @@ -252,6 +252,7 @@ pub struct Style { pub box_shadow: Vec, /// The text style of this element + #[refineable] pub text: TextStyleRefinement, /// The mouse cursor style shown when the mouse pointer is over an element. @@ -264,6 +265,10 @@ pub struct Style { /// Equivalent to the Tailwind `grid-cols-` pub grid_cols: Option, + /// The grid columns with min-content minimum sizing. + /// Unlike grid_cols, it won't shrink to width 0 in AvailableSpace::MinContent constraints. + pub grid_cols_min_content: Option, + /// The row span of this element /// Equivalent to the Tailwind `grid-rows-` pub grid_rows: Option, @@ -771,6 +776,7 @@ impl Default for Style { opacity: None, grid_rows: None, grid_cols: None, + grid_cols_min_content: None, grid_location: None, #[cfg(debug_assertions)] @@ -1469,4 +1475,21 @@ mod tests { ] ); } + + #[perf] + fn test_text_style_refinement() { + let mut style = Style::default(); + style.refine(&StyleRefinement::default().text_size(px(20.0))); + style.refine(&StyleRefinement::default().font_weight(FontWeight::SEMIBOLD)); + + assert_eq!( + Some(AbsoluteLength::from(px(20.0))), + style.text_style().unwrap().font_size + ); + + assert_eq!( + Some(FontWeight::SEMIBOLD), + style.text_style().unwrap().font_weight + ); + } } diff --git a/crates/gpui/src/styled.rs b/crates/gpui/src/styled.rs index b50432d332f7e26fdd4528c1644be3c9761b6ad0..e8088a84d7fc141d0a320988c6399afe2b93ce07 100644 --- a/crates/gpui/src/styled.rs +++ b/crates/gpui/src/styled.rs @@ -1,8 +1,9 @@ use crate::{ self as gpui, AbsoluteLength, AlignContent, AlignItems, BorderStyle, CursorStyle, - DefiniteLength, Display, Fill, FlexDirection, FlexWrap, Font, FontStyle, FontWeight, - GridPlacement, Hsla, JustifyContent, Length, SharedString, StrikethroughStyle, StyleRefinement, - TextAlign, TextOverflow, TextStyleRefinement, UnderlineStyle, WhiteSpace, px, relative, rems, + DefiniteLength, Display, Fill, FlexDirection, FlexWrap, Font, FontFeatures, FontStyle, + FontWeight, GridPlacement, Hsla, JustifyContent, Length, SharedString, StrikethroughStyle, + StyleRefinement, TextAlign, TextOverflow, TextStyleRefinement, UnderlineStyle, WhiteSpace, px, + relative, rems, }; pub use gpui_macros::{ border_style_methods, box_shadow_style_methods, cursor_style_methods, margin_style_methods, @@ -63,43 +64,33 @@ pub trait Styled: Sized { /// Sets the whitespace of the element to `normal`. /// [Docs](https://tailwindcss.com/docs/whitespace#normal) fn whitespace_normal(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .white_space = Some(WhiteSpace::Normal); + self.text_style().white_space = Some(WhiteSpace::Normal); self } /// Sets the whitespace of the element to `nowrap`. /// [Docs](https://tailwindcss.com/docs/whitespace#nowrap) fn whitespace_nowrap(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .white_space = Some(WhiteSpace::Nowrap); + self.text_style().white_space = Some(WhiteSpace::Nowrap); self } /// Sets the truncate overflowing text with an ellipsis (…) if needed. /// [Docs](https://tailwindcss.com/docs/text-overflow#ellipsis) fn text_ellipsis(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .text_overflow = Some(TextOverflow::Truncate(ELLIPSIS)); + self.text_style().text_overflow = Some(TextOverflow::Truncate(ELLIPSIS)); self } /// Sets the text overflow behavior of the element. fn text_overflow(mut self, overflow: TextOverflow) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .text_overflow = Some(overflow); + self.text_style().text_overflow = Some(overflow); self } /// Set the text alignment of the element. fn text_align(mut self, align: TextAlign) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .text_align = Some(align); + self.text_style().text_align = Some(align); self } @@ -127,7 +118,7 @@ pub trait Styled: Sized { /// Sets number of lines to show before truncating the text. /// [Docs](https://tailwindcss.com/docs/line-clamp) fn line_clamp(mut self, lines: usize) -> Self { - let mut text_style = self.text_style().get_or_insert_with(Default::default); + let mut text_style = self.text_style(); text_style.line_clamp = Some(lines); self.overflow_hidden() } @@ -395,7 +386,7 @@ pub trait Styled: Sized { } /// Returns a mutable reference to the text style that has been configured on this element. - fn text_style(&mut self) -> &mut Option { + fn text_style(&mut self) -> &mut TextStyleRefinement { let style: &mut StyleRefinement = self.style(); &mut style.text } @@ -404,7 +395,7 @@ pub trait Styled: Sized { /// /// This value cascades to its child elements. fn text_color(mut self, color: impl Into) -> Self { - self.text_style().get_or_insert_with(Default::default).color = Some(color.into()); + self.text_style().color = Some(color.into()); self } @@ -412,9 +403,7 @@ pub trait Styled: Sized { /// /// This value cascades to its child elements. fn font_weight(mut self, weight: FontWeight) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_weight = Some(weight); + self.text_style().font_weight = Some(weight); self } @@ -422,9 +411,7 @@ pub trait Styled: Sized { /// /// This value cascades to its child elements. fn text_bg(mut self, bg: impl Into) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .background_color = Some(bg.into()); + self.text_style().background_color = Some(bg.into()); self } @@ -432,97 +419,77 @@ pub trait Styled: Sized { /// /// This value cascades to its child elements. fn text_size(mut self, size: impl Into) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_size = Some(size.into()); + self.text_style().font_size = Some(size.into()); self } /// Sets the text size to 'extra small'. /// [Docs](https://tailwindcss.com/docs/font-size#setting-the-font-size) fn text_xs(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_size = Some(rems(0.75).into()); + self.text_style().font_size = Some(rems(0.75).into()); self } /// Sets the text size to 'small'. /// [Docs](https://tailwindcss.com/docs/font-size#setting-the-font-size) fn text_sm(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_size = Some(rems(0.875).into()); + self.text_style().font_size = Some(rems(0.875).into()); self } /// Sets the text size to 'base'. /// [Docs](https://tailwindcss.com/docs/font-size#setting-the-font-size) fn text_base(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_size = Some(rems(1.0).into()); + self.text_style().font_size = Some(rems(1.0).into()); self } /// Sets the text size to 'large'. /// [Docs](https://tailwindcss.com/docs/font-size#setting-the-font-size) fn text_lg(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_size = Some(rems(1.125).into()); + self.text_style().font_size = Some(rems(1.125).into()); self } /// Sets the text size to 'extra large'. /// [Docs](https://tailwindcss.com/docs/font-size#setting-the-font-size) fn text_xl(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_size = Some(rems(1.25).into()); + self.text_style().font_size = Some(rems(1.25).into()); self } /// Sets the text size to 'extra extra large'. /// [Docs](https://tailwindcss.com/docs/font-size#setting-the-font-size) fn text_2xl(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_size = Some(rems(1.5).into()); + self.text_style().font_size = Some(rems(1.5).into()); self } /// Sets the text size to 'extra extra extra large'. /// [Docs](https://tailwindcss.com/docs/font-size#setting-the-font-size) fn text_3xl(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_size = Some(rems(1.875).into()); + self.text_style().font_size = Some(rems(1.875).into()); self } /// Sets the font style of the element to italic. /// [Docs](https://tailwindcss.com/docs/font-style#italicizing-text) fn italic(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_style = Some(FontStyle::Italic); + self.text_style().font_style = Some(FontStyle::Italic); self } /// Sets the font style of the element to normal (not italic). /// [Docs](https://tailwindcss.com/docs/font-style#displaying-text-normally) fn not_italic(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_style = Some(FontStyle::Normal); + self.text_style().font_style = Some(FontStyle::Normal); self } /// Sets the text decoration to underline. /// [Docs](https://tailwindcss.com/docs/text-decoration-line#underling-text) fn underline(mut self) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); style.underline = Some(UnderlineStyle { thickness: px(1.), ..Default::default() @@ -533,7 +500,7 @@ pub trait Styled: Sized { /// Sets the decoration of the text to have a line through it. /// [Docs](https://tailwindcss.com/docs/text-decoration-line#adding-a-line-through-text) fn line_through(mut self) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); style.strikethrough = Some(StrikethroughStyle { thickness: px(1.), ..Default::default() @@ -545,15 +512,13 @@ pub trait Styled: Sized { /// /// This value cascades to its child elements. fn text_decoration_none(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .underline = None; + self.text_style().underline = None; self } /// Sets the color for the underline on this element fn text_decoration_color(mut self, color: impl Into) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); let underline = style.underline.get_or_insert_with(Default::default); underline.color = Some(color.into()); self @@ -562,7 +527,7 @@ pub trait Styled: Sized { /// Sets the text decoration style to a solid line. /// [Docs](https://tailwindcss.com/docs/text-decoration-style) fn text_decoration_solid(mut self) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); let underline = style.underline.get_or_insert_with(Default::default); underline.wavy = false; self @@ -571,7 +536,7 @@ pub trait Styled: Sized { /// Sets the text decoration style to a wavy line. /// [Docs](https://tailwindcss.com/docs/text-decoration-style) fn text_decoration_wavy(mut self) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); let underline = style.underline.get_or_insert_with(Default::default); underline.wavy = true; self @@ -580,7 +545,7 @@ pub trait Styled: Sized { /// Sets the text decoration to be 0px thick. /// [Docs](https://tailwindcss.com/docs/text-decoration-thickness) fn text_decoration_0(mut self) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); let underline = style.underline.get_or_insert_with(Default::default); underline.thickness = px(0.); self @@ -589,7 +554,7 @@ pub trait Styled: Sized { /// Sets the text decoration to be 1px thick. /// [Docs](https://tailwindcss.com/docs/text-decoration-thickness) fn text_decoration_1(mut self) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); let underline = style.underline.get_or_insert_with(Default::default); underline.thickness = px(1.); self @@ -598,7 +563,7 @@ pub trait Styled: Sized { /// Sets the text decoration to be 2px thick. /// [Docs](https://tailwindcss.com/docs/text-decoration-thickness) fn text_decoration_2(mut self) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); let underline = style.underline.get_or_insert_with(Default::default); underline.thickness = px(2.); self @@ -607,7 +572,7 @@ pub trait Styled: Sized { /// Sets the text decoration to be 4px thick. /// [Docs](https://tailwindcss.com/docs/text-decoration-thickness) fn text_decoration_4(mut self) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); let underline = style.underline.get_or_insert_with(Default::default); underline.thickness = px(4.); self @@ -616,7 +581,7 @@ pub trait Styled: Sized { /// Sets the text decoration to be 8px thick. /// [Docs](https://tailwindcss.com/docs/text-decoration-thickness) fn text_decoration_8(mut self) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); let underline = style.underline.get_or_insert_with(Default::default); underline.thickness = px(8.); self @@ -624,9 +589,13 @@ pub trait Styled: Sized { /// Sets the font family of this element and its children. fn font_family(mut self, family_name: impl Into) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_family = Some(family_name.into()); + self.text_style().font_family = Some(family_name.into()); + self + } + + /// Sets the font features of this element and its children. + fn font_features(mut self, features: FontFeatures) -> Self { + self.text_style().font_features = Some(features); self } @@ -640,7 +609,7 @@ pub trait Styled: Sized { style, } = font; - let text_style = self.text_style().get_or_insert_with(Default::default); + let text_style = self.text_style(); text_style.font_family = Some(family); text_style.font_features = Some(features); text_style.font_weight = Some(weight); @@ -652,9 +621,7 @@ pub trait Styled: Sized { /// Sets the line height of this element and its children. fn line_height(mut self, line_height: impl Into) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .line_height = Some(line_height.into()); + self.text_style().line_height = Some(line_height.into()); self } @@ -670,6 +637,13 @@ pub trait Styled: Sized { self } + /// Sets the grid columns with min-content minimum sizing. + /// Unlike grid_cols, it won't shrink to width 0 in AvailableSpace::MinContent constraints. + fn grid_cols_min_content(mut self, cols: u16) -> Self { + self.style().grid_cols_min_content = Some(cols); + self + } + /// Sets the grid rows of this element. fn grid_rows(mut self, rows: u16) -> Self { self.style().grid_rows = Some(rows); diff --git a/crates/gpui/src/taffy.rs b/crates/gpui/src/taffy.rs index 11cb0872861321c3c06c3f8a5bf79fdd30eb2275..99a50b87c8aa9f40a7694f1c2084b10f6d0a9315 100644 --- a/crates/gpui/src/taffy.rs +++ b/crates/gpui/src/taffy.rs @@ -8,6 +8,7 @@ use std::{fmt::Debug, ops::Range}; use taffy::{ TaffyTree, TraversePartialTree as _, geometry::{Point as TaffyPoint, Rect as TaffyRect, Size as TaffySize}, + prelude::min_content, style::AvailableSpace as TaffyAvailableSpace, tree::NodeId, }; @@ -314,6 +315,14 @@ impl ToTaffy for Style { .unwrap_or_default() } + fn to_grid_repeat_min_content( + unit: &Option, + ) -> Vec> { + // grid-template-columns: repeat(, minmax(min-content, 1fr)); + unit.map(|count| vec![repeat(count, vec![minmax(min_content(), fr(1.0))])]) + .unwrap_or_default() + } + taffy::style::Style { display: self.display.into(), overflow: self.overflow.into(), @@ -338,7 +347,11 @@ impl ToTaffy for Style { flex_grow: self.flex_grow, flex_shrink: self.flex_shrink, grid_template_rows: to_grid_repeat(&self.grid_rows), - grid_template_columns: to_grid_repeat(&self.grid_cols), + grid_template_columns: if self.grid_cols_min_content.is_some() { + to_grid_repeat_min_content(&self.grid_cols_min_content) + } else { + to_grid_repeat(&self.grid_cols) + }, grid_row: self .grid_location .as_ref() 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/text_system/line_wrapper.rs b/crates/gpui/src/text_system/line_wrapper.rs index 45159313b43c508029f2525234c80c6575d0f695..95cd55d04443c6b2c351bf8533ccb57d49e8dcd9 100644 --- a/crates/gpui/src/text_system/line_wrapper.rs +++ b/crates/gpui/src/text_system/line_wrapper.rs @@ -128,22 +128,21 @@ impl LineWrapper { }) } - /// Truncate a line of text to the given width with this wrapper's font and font size. - pub fn truncate_line<'a>( + /// Determines if a line should be truncated based on its width. + pub fn should_truncate_line( &mut self, - line: SharedString, + line: &str, truncate_width: Pixels, truncation_suffix: &str, - runs: &'a [TextRun], - ) -> (SharedString, Cow<'a, [TextRun]>) { + ) -> Option { let mut width = px(0.); - let mut suffix_width = truncation_suffix + let suffix_width = truncation_suffix .chars() .map(|c| self.width_for_char(c)) .fold(px(0.0), |a, x| a + x); - let mut char_indices = line.char_indices(); let mut truncate_ix = 0; - for (ix, c) in char_indices { + + for (ix, c) in line.char_indices() { if width + suffix_width < truncate_width { truncate_ix = ix; } @@ -152,16 +151,32 @@ impl LineWrapper { width += char_width; if width.floor() > truncate_width { - let result = - SharedString::from(format!("{}{}", &line[..truncate_ix], truncation_suffix)); - let mut runs = runs.to_vec(); - update_runs_after_truncation(&result, truncation_suffix, &mut runs); - - return (result, Cow::Owned(runs)); + return Some(truncate_ix); } } - (line, Cow::Borrowed(runs)) + None + } + + /// Truncate a line of text to the given width with this wrapper's font and font size. + pub fn truncate_line<'a>( + &mut self, + line: SharedString, + truncate_width: Pixels, + truncation_suffix: &str, + runs: &'a [TextRun], + ) -> (SharedString, Cow<'a, [TextRun]>) { + if let Some(truncate_ix) = + self.should_truncate_line(&line, truncate_width, truncation_suffix) + { + let result = + SharedString::from(format!("{}{}", &line[..truncate_ix], truncation_suffix)); + let mut runs = runs.to_vec(); + update_runs_after_truncation(&result, truncation_suffix, &mut runs); + (result, Cow::Owned(runs)) + } else { + (line, Cow::Borrowed(runs)) + } } /// Any character in this list should be treated as a word character, @@ -182,6 +197,11 @@ impl LineWrapper { // Cyrillic for Russian, Ukrainian, etc. // https://en.wikipedia.org/wiki/Cyrillic_script_in_Unicode matches!(c, '\u{0400}'..='\u{04FF}') || + + // Vietnamese (https://vietunicode.sourceforge.net/charset/) + matches!(c, '\u{1E00}'..='\u{1EFF}') || // Latin Extended Additional + matches!(c, '\u{0300}'..='\u{036F}') || // Combining Diacritical Marks + // Some other known special characters that should be treated as word characters, // e.g. `a-b`, `var_name`, `I'm`, '@mention`, `#hashtag`, `100%`, `3.1415`, // `2^3`, `a~b`, `a=1`, `Self::new`, etc. @@ -618,7 +638,12 @@ mod tests { #[track_caller] fn assert_word(word: &str) { for c in word.chars() { - assert!(LineWrapper::is_word_char(c), "assertion failed for '{}'", c); + assert!( + LineWrapper::is_word_char(c), + "assertion failed for '{}' (unicode 0x{:x})", + c, + c as u32 + ); } } @@ -661,6 +686,8 @@ mod tests { assert_word("ƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏ"); // Cyrillic assert_word("АБВГДЕЖЗИЙКЛМНОП"); + // Vietnamese (https://github.com/zed-industries/zed/issues/23245) + assert_word("ThậmchíđếnkhithuachạychúngcònnhẫntâmgiếtnốtsốđôngtùchínhtrịởYênBáivàCaoBằng"); // non-word characters assert_not_word("你好"); diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 69bf583057fdca4e0b3a71fc552c37c3319123ec..2ccd7edac86bced89048cbe5dbf196d8fbcf95f3 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -9,14 +9,15 @@ use crate::{ KeyBinding, KeyContext, KeyDownEvent, KeyEvent, Keystroke, KeystrokeEvent, LayoutId, LineLayoutIndex, Modifiers, ModifiersChangedEvent, MonochromeSprite, MouseButton, MouseEvent, MouseMoveEvent, MouseUpEvent, Path, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, - PlatformInputHandler, PlatformWindow, Point, PolychromeSprite, PromptButton, PromptLevel, Quad, - Render, RenderGlyphParams, RenderImage, RenderImageParams, RenderSvgParams, Replay, ResizeEdge, - SMOOTH_SVG_SCALE_FACTOR, SUBPIXEL_VARIANTS_X, SUBPIXEL_VARIANTS_Y, ScaledPixels, Scene, Shadow, - SharedString, Size, StrikethroughStyle, Style, SubscriberSet, Subscription, SystemWindowTab, - SystemWindowTabController, TabStopMap, TaffyLayoutEngine, Task, TextStyle, TextStyleRefinement, - TransformationMatrix, Underline, UnderlineStyle, WindowAppearance, WindowBackgroundAppearance, - WindowBounds, WindowControls, WindowDecorations, WindowOptions, WindowParams, WindowTextSystem, - point, prelude::*, px, rems, size, transparent_black, + PlatformInputHandler, PlatformWindow, Point, PolychromeSprite, Priority, PromptButton, + PromptLevel, Quad, Render, RenderGlyphParams, RenderImage, RenderImageParams, RenderSvgParams, + Replay, ResizeEdge, SMOOTH_SVG_SCALE_FACTOR, SUBPIXEL_VARIANTS_X, SUBPIXEL_VARIANTS_Y, + ScaledPixels, Scene, Shadow, SharedString, Size, StrikethroughStyle, Style, SubscriberSet, + Subscription, SystemWindowTab, SystemWindowTabController, TabStopMap, TaffyLayoutEngine, Task, + TextStyle, TextStyleRefinement, TransformationMatrix, Underline, UnderlineStyle, + WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControls, WindowDecorations, + WindowOptions, WindowParams, WindowTextSystem, point, prelude::*, px, rems, size, + transparent_black, }; use anyhow::{Context as _, Result, anyhow}; use collections::{FxHashMap, FxHashSet}; @@ -344,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. @@ -918,86 +919,69 @@ pub(crate) struct ElementStateBox { pub(crate) type_name: &'static str, } -fn default_bounds(display_id: Option, cx: &mut App) -> Bounds { - #[cfg(target_os = "macos")] - { - const CASCADE_OFFSET: f32 = 25.0; - - let display = display_id - .map(|id| cx.find_display(id)) - .unwrap_or_else(|| cx.primary_display()); - - let display_bounds = display - .as_ref() - .map(|d| d.bounds()) - .unwrap_or_else(|| Bounds::new(point(px(0.), px(0.)), DEFAULT_WINDOW_SIZE)); - - // TODO, BUG: if you open a window with the currently active window - // on the stack, this will erroneously select the 'unwrap_or_else' - // code path - let (base_origin, base_size) = cx - .active_window() - .and_then(|w| { - w.update(cx, |_, window, _| { - let bounds = window.bounds(); - (bounds.origin, bounds.size) - }) - .ok() - }) - .unwrap_or_else(|| { - let default_bounds = display - .as_ref() - .map(|d| d.default_bounds()) - .unwrap_or_else(|| Bounds::new(point(px(0.), px(0.)), DEFAULT_WINDOW_SIZE)); - (default_bounds.origin, default_bounds.size) - }); - - let cascade_offset = point(px(CASCADE_OFFSET), px(CASCADE_OFFSET)); - let proposed_origin = base_origin + cascade_offset; - let proposed_bounds = Bounds::new(proposed_origin, base_size); - - let display_right = display_bounds.origin.x + display_bounds.size.width; - let display_bottom = display_bounds.origin.y + display_bounds.size.height; - let window_right = proposed_bounds.origin.x + proposed_bounds.size.width; - let window_bottom = proposed_bounds.origin.y + proposed_bounds.size.height; - - let fits_horizontally = window_right <= display_right; - let fits_vertically = window_bottom <= display_bottom; - - let final_origin = match (fits_horizontally, fits_vertically) { - (true, true) => proposed_origin, - (false, true) => point(display_bounds.origin.x, base_origin.y), - (true, false) => point(base_origin.x, display_bounds.origin.y), - (false, false) => display_bounds.origin, - }; - - Bounds::new(final_origin, base_size) - } - - #[cfg(not(target_os = "macos"))] - { - const DEFAULT_WINDOW_OFFSET: Point = point(px(0.), px(35.)); - - // TODO, BUG: if you open a window with the currently active window - // on the stack, this will erroneously select the 'unwrap_or_else' - // code path - cx.active_window() - .and_then(|w| w.update(cx, |_, window, _| window.bounds()).ok()) - .map(|mut bounds| { - bounds.origin += DEFAULT_WINDOW_OFFSET; - bounds - }) - .unwrap_or_else(|| { - let display = display_id - .map(|id| cx.find_display(id)) - .unwrap_or_else(|| cx.primary_display()); - - display - .as_ref() - .map(|display| display.default_bounds()) - .unwrap_or_else(|| Bounds::new(point(px(0.), px(0.)), DEFAULT_WINDOW_SIZE)) - }) - } +fn default_bounds(display_id: Option, cx: &mut App) -> WindowBounds { + // TODO, BUG: if you open a window with the currently active window + // on the stack, this will erroneously fallback to `None` + // + // TODO these should be the initial window bounds not considering maximized/fullscreen + let active_window_bounds = cx + .active_window() + .and_then(|w| w.update(cx, |_, window, _| window.window_bounds()).ok()); + + const CASCADE_OFFSET: f32 = 25.0; + + let display = display_id + .map(|id| cx.find_display(id)) + .unwrap_or_else(|| cx.primary_display()); + + let default_placement = || Bounds::new(point(px(0.), px(0.)), DEFAULT_WINDOW_SIZE); + + // Use visible_bounds to exclude taskbar/dock areas + let display_bounds = display + .as_ref() + .map(|d| d.visible_bounds()) + .unwrap_or_else(default_placement); + + let ( + Bounds { + origin: base_origin, + size: base_size, + }, + window_bounds_ctor, + ): (_, fn(Bounds) -> WindowBounds) = match active_window_bounds { + Some(bounds) => match bounds { + WindowBounds::Windowed(bounds) => (bounds, WindowBounds::Windowed), + WindowBounds::Maximized(bounds) => (bounds, WindowBounds::Maximized), + WindowBounds::Fullscreen(bounds) => (bounds, WindowBounds::Fullscreen), + }, + None => ( + display + .as_ref() + .map(|d| d.default_bounds()) + .unwrap_or_else(default_placement), + WindowBounds::Windowed, + ), + }; + + let cascade_offset = point(px(CASCADE_OFFSET), px(CASCADE_OFFSET)); + let proposed_origin = base_origin + cascade_offset; + let proposed_bounds = Bounds::new(proposed_origin, base_size); + + let display_right = display_bounds.origin.x + display_bounds.size.width; + let display_bottom = display_bounds.origin.y + display_bounds.size.height; + let window_right = proposed_bounds.origin.x + proposed_bounds.size.width; + let window_bottom = proposed_bounds.origin.y + proposed_bounds.size.height; + + let fits_horizontally = window_right <= display_right; + let fits_vertically = window_bottom <= display_bottom; + + let final_origin = match (fits_horizontally, fits_vertically) { + (true, true) => proposed_origin, + (false, true) => point(display_bounds.origin.x, base_origin.y), + (true, false) => point(base_origin.x, display_bounds.origin.y), + (false, false) => display_bounds.origin, + }; + window_bounds_ctor(Bounds::new(final_origin, base_size)) } impl Window { @@ -1024,13 +1008,11 @@ impl Window { tabbing_identifier, } = options; - let bounds = window_bounds - .map(|bounds| bounds.get_bounds()) - .unwrap_or_else(|| default_bounds(display_id, cx)); + let window_bounds = window_bounds.unwrap_or_else(|| default_bounds(display_id, cx)); let mut platform_window = cx.platform.open_window( handle, WindowParams { - bounds, + bounds: window_bounds.get_bounds(), titlebar, kind, is_movable, @@ -1071,12 +1053,10 @@ impl Window { .request_decorations(window_decorations.unwrap_or(WindowDecorations::Server)); platform_window.set_background_appearance(window_background); - if let Some(ref window_open_state) = window_bounds { - match window_open_state { - WindowBounds::Fullscreen(_) => platform_window.toggle_fullscreen(), - WindowBounds::Maximized(_) => platform_window.zoom(), - WindowBounds::Windowed(_) => {} - } + match window_bounds { + WindowBounds::Fullscreen(_) => platform_window.toggle_fullscreen(), + WindowBounds::Maximized(_) => platform_window.zoom(), + WindowBounds::Windowed(_) => {} } platform_window.on_close(Box::new({ @@ -1456,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(); } @@ -1483,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) } } @@ -1518,7 +1510,8 @@ impl Window { style } - /// Check if the platform window is maximized + /// Check if the platform window is maximized. + /// /// On some platforms (namely Windows) this is different than the bounds being the size of the display pub fn is_maximized(&self) -> bool { self.platform_window.is_maximized() @@ -1745,6 +1738,27 @@ impl Window { }) } + /// Spawn the future returned by the given closure on the application thread + /// pool, with the given priority. The closure is provided a handle to the + /// current window and an `AsyncWindowContext` for use within your future. + #[track_caller] + pub fn spawn_with_priority( + &self, + priority: Priority, + cx: &App, + f: AsyncFn, + ) -> Task + where + R: 'static, + AsyncFn: AsyncFnOnce(&mut AsyncWindowContext) -> R + 'static, + { + let handle = self.handle; + cx.spawn_with_priority(priority, async move |app| { + let mut async_window_cx = AsyncWindowContext::new_context(app.clone(), handle); + f(&mut async_window_cx).await + }) + } + fn bounds_changed(&mut self, cx: &mut App) { self.scale_factor = self.platform_window.scale_factor(); self.viewport_size = self.platform_window.content_size(); @@ -1959,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 @@ -1967,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 @@ -3703,6 +3725,9 @@ impl Window { self.modifiers = mouse_up.modifiers; PlatformInput::MouseUp(mouse_up) } + PlatformInput::MousePressure(mouse_pressure) => { + PlatformInput::MousePressure(mouse_pressure) + } PlatformInput::MouseExited(mouse_exited) => { self.modifiers = mouse_exited.modifiers; PlatformInput::MouseExited(mouse_exited) @@ -4007,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)); @@ -4425,6 +4450,13 @@ impl Window { dispatch_tree.highest_precedence_binding_for_action(action, &context_stack) } + /// Find the bindings that can follow the current input sequence for the current context stack. + pub fn possible_bindings_for_input(&self, input: &[Keystroke]) -> Vec { + self.rendered_frame + .dispatch_tree + .possible_next_bindings_for_input(input, &self.context_stack()) + } + fn context_stack_for_focus_handle( &self, focus_handle: &FocusHandle, @@ -4934,7 +4966,7 @@ impl From> for AnyWindowHandle { } /// A handle to a window with any root view type, which can be downcast to a window with a specific root view type. -#[derive(Copy, Clone, PartialEq, Eq, Hash)] +#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)] pub struct AnyWindowHandle { pub(crate) id: WindowId, state_type: TypeId, 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/gpui_tokio/src/gpui_tokio.rs b/crates/gpui_tokio/src/gpui_tokio.rs index 61dcfc48efb1dfecc04c4a131ddc32691e01e255..9cfa1493af49ee95210edb9669a6ca89095f42cd 100644 --- a/crates/gpui_tokio/src/gpui_tokio.rs +++ b/crates/gpui_tokio/src/gpui_tokio.rs @@ -5,25 +5,48 @@ use util::defer; pub use tokio::task::JoinError; +/// Initializes the Tokio wrapper using a new Tokio runtime with 2 worker threads. +/// +/// If you need more threads (or access to the runtime outside of GPUI), you can create the runtime +/// yourself and pass a Handle to `init_from_handle`. pub fn init(cx: &mut App) { - cx.set_global(GlobalTokio::new()); + let runtime = tokio::runtime::Builder::new_multi_thread() + // Since we now have two executors, let's try to keep our footprint small + .worker_threads(2) + .enable_all() + .build() + .expect("Failed to initialize Tokio"); + + cx.set_global(GlobalTokio::new(RuntimeHolder::Owned(runtime))); +} + +/// Initializes the Tokio wrapper using a Tokio runtime handle. +pub fn init_from_handle(cx: &mut App, handle: tokio::runtime::Handle) { + cx.set_global(GlobalTokio::new(RuntimeHolder::Shared(handle))); +} + +enum RuntimeHolder { + Owned(tokio::runtime::Runtime), + Shared(tokio::runtime::Handle), +} + +impl RuntimeHolder { + pub fn handle(&self) -> &tokio::runtime::Handle { + match self { + RuntimeHolder::Owned(runtime) => runtime.handle(), + RuntimeHolder::Shared(handle) => handle, + } + } } struct GlobalTokio { - runtime: tokio::runtime::Runtime, + runtime: RuntimeHolder, } impl Global for GlobalTokio {} impl GlobalTokio { - fn new() -> Self { - let runtime = tokio::runtime::Builder::new_multi_thread() - // Since we now have two executors, let's try to keep our footprint small - .worker_threads(2) - .enable_all() - .build() - .expect("Failed to initialize Tokio"); - + fn new(runtime: RuntimeHolder) -> Self { Self { runtime } } } @@ -40,7 +63,7 @@ impl Tokio { R: Send + 'static, { cx.read_global(|tokio: &GlobalTokio, cx| { - let join_handle = tokio.runtime.spawn(f); + let join_handle = tokio.runtime.handle().spawn(f); let abort_handle = join_handle.abort_handle(); let cancel = defer(move || { abort_handle.abort(); @@ -62,7 +85,7 @@ impl Tokio { R: Send + 'static, { cx.read_global(|tokio: &GlobalTokio, cx| { - let join_handle = tokio.runtime.spawn(f); + let join_handle = tokio.runtime.handle().spawn(f); let abort_handle = join_handle.abort_handle(); let cancel = defer(move || { abort_handle.abort(); diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index cc84129250cfdbe968aa3d86f1d00d0789d01480..23ae7a6d928d98aafe48d28cfe5626bbf76d29b8 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -49,6 +49,7 @@ pub enum IconName { BoltOutlined, Book, BookCopy, + Box, CaseSensitive, Chat, Check, @@ -259,6 +260,7 @@ pub enum IconName { XCircle, XCircleFilled, ZedAgent, + ZedAgentTwo, ZedAssistant, ZedBurnMode, ZedBurnModeOn, diff --git a/crates/image_viewer/src/image_info.rs b/crates/image_viewer/src/image_info.rs index 6e8956abc67868457f071e04f3c2a1957ff6c19c..6eedb13ed1a150094ae4882718f2384b06cfe6a7 100644 --- a/crates/image_viewer/src/image_info.rs +++ b/crates/image_viewer/src/image_info.rs @@ -77,9 +77,7 @@ impl Render for ImageInfo { .to_string(), ); - div().child( - Button::new("image-metadata", components.join(" • ")).label_size(LabelSize::Small), - ) + div().child(Label::new(components.join(" • ")).size(LabelSize::Small)) } } 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 113d5026eb89587714172ff4c76698bcadb5fd6a..be20feaf5f8c1feea5b08fa3a6a3b542b26ef5ce 100644 --- a/crates/keymap_editor/src/keymap_editor.rs +++ b/crates/keymap_editor/src/keymap_editor.rs @@ -81,50 +81,61 @@ pub fn init(cx: &mut App) { let keymap_event_channel = KeymapEventChannel::new(); cx.set_global(keymap_event_channel); - fn common(filter: Option, cx: &mut App) { - workspace::with_active_or_new_workspace(cx, move |workspace, window, cx| { - workspace - .with_local_workspace(window, cx, move |workspace, window, cx| { - let existing = workspace - .active_pane() - .read(cx) - .items() - .find_map(|item| item.downcast::()); - - let keymap_editor = if let Some(existing) = existing { - workspace.activate_item(&existing, true, true, window, cx); - existing - } else { - let keymap_editor = - cx.new(|cx| KeymapEditor::new(workspace.weak_handle(), window, cx)); - workspace.add_item_to_active_pane( - Box::new(keymap_editor.clone()), - None, - true, - window, - cx, - ); - keymap_editor - }; - - if let Some(filter) = filter { - keymap_editor.update(cx, |editor, cx| { - editor.filter_editor.update(cx, |editor, cx| { - editor.clear(window, cx); - editor.insert(&filter, window, cx); - }); - if !editor.has_binding_for(&filter) { - open_binding_modal_after_loading(cx) - } - }) - } - }) - .detach(); - }) + fn open_keymap_editor( + filter: Option, + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, + ) { + workspace + .with_local_workspace(window, cx, |workspace, window, cx| { + let existing = workspace + .active_pane() + .read(cx) + .items() + .find_map(|item| item.downcast::()); + + let keymap_editor = if let Some(existing) = existing { + workspace.activate_item(&existing, true, true, window, cx); + existing + } else { + let keymap_editor = + cx.new(|cx| KeymapEditor::new(workspace.weak_handle(), window, cx)); + workspace.add_item_to_active_pane( + Box::new(keymap_editor.clone()), + None, + true, + window, + cx, + ); + keymap_editor + }; + + if let Some(filter) = filter { + keymap_editor.update(cx, |editor, cx| { + editor.filter_editor.update(cx, |editor, cx| { + editor.clear(window, cx); + editor.insert(&filter, window, cx); + }); + if !editor.has_binding_for(&filter) { + open_binding_modal_after_loading(cx) + } + }) + } + }) + .detach_and_log_err(cx); } - cx.on_action(|_: &OpenKeymap, cx| common(None, cx)); - cx.on_action(|action: &ChangeKeybinding, cx| common(Some(action.action.clone()), cx)); + cx.observe_new(|workspace: &mut Workspace, _window, _cx| { + workspace + .register_action(|workspace, _: &OpenKeymap, window, cx| { + open_keymap_editor(None, workspace, window, cx); + }) + .register_action(|workspace, action: &ChangeKeybinding, window, cx| { + open_keymap_editor(Some(action.action.clone()), workspace, window, cx); + }); + }) + .detach(); register_serializable_item::(cx); } @@ -900,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); @@ -937,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(); } } @@ -987,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, @@ -1003,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(); } @@ -1219,7 +1230,7 @@ impl KeymapEditor { window, cx, ); - window.focus(&modal.focus_handle(cx)); + window.focus(&modal.focus_handle(cx), cx); modal }); }) @@ -1327,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); } } } @@ -2687,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); } } @@ -2746,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| { @@ -2799,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..06d41e729bfabbf4f7e050409d2675dd909941d6 100644 --- a/crates/language/Cargo.toml +++ b/crates/language/Cargo.toml @@ -32,6 +32,7 @@ async-trait.workspace = true clock.workspace = true collections.workspace = true ec4rs.workspace = true +encoding_rs.workspace = true fs.workspace = true futures.workspace = true fuzzy.workspace = true @@ -48,6 +49,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 7bf62b5aa43c60a7ecee756dd66066682ac09077..99e0c8d4ebdad709eea0e9ab6dbdf9d889d54ec5 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, @@ -22,9 +22,10 @@ pub use crate::{ proto, }; use anyhow::{Context as _, Result}; +use clock::Lamport; pub use clock::ReplicaId; -use clock::{Global, Lamport}; use collections::{HashMap, HashSet}; +use encoding_rs::Encoding; use fs::MTime; use futures::channel::oneshot; use gpui::{ @@ -33,7 +34,7 @@ use gpui::{ }; use lsp::{LanguageServerId, NumberOrString}; -use parking_lot::{Mutex, RawMutex, lock_api::MutexGuard}; +use parking_lot::Mutex; use serde::{Deserialize, Serialize}; use serde_json::Value; use settings::WorktreeId; @@ -130,29 +131,39 @@ pub struct Buffer { has_unsaved_edits: Cell<(clock::Global, bool)>, change_bits: Vec>>, _subscriptions: Vec, - tree_sitter_data: Arc>, + tree_sitter_data: Arc, + encoding: &'static Encoding, + has_bom: bool, } -#[derive(Debug, Clone)] +#[derive(Debug)] pub struct TreeSitterData { chunks: RowChunks, - brackets_by_chunks: Vec>>>, + brackets_by_chunks: Mutex>>>>, } const MAX_ROWS_IN_A_CHUNK: u32 = 50; impl TreeSitterData { - fn clear(&mut self) { - self.brackets_by_chunks = vec![None; self.chunks.len()]; + fn clear(&mut self, snapshot: text::BufferSnapshot) { + self.chunks = RowChunks::new(snapshot, MAX_ROWS_IN_A_CHUNK); + self.brackets_by_chunks.get_mut().clear(); + self.brackets_by_chunks + .get_mut() + .resize(self.chunks.len(), None); } fn new(snapshot: text::BufferSnapshot) -> Self { let chunks = RowChunks::new(snapshot, MAX_ROWS_IN_A_CHUNK); Self { - brackets_by_chunks: vec![None; chunks.len()], + brackets_by_chunks: Mutex::new(vec![None; chunks.len()]), chunks, } } + + fn version(&self) -> &clock::Global { + self.chunks.version() + } } #[derive(Copy, Clone, Debug, PartialEq, Eq)] @@ -176,7 +187,7 @@ pub struct BufferSnapshot { remote_selections: TreeMap, language: Option>, non_text_state_update_count: usize, - tree_sitter_data: Arc>, + tree_sitter_data: Arc, } /// The kind and amount of indentation in a particular line. For now, @@ -1062,7 +1073,7 @@ impl Buffer { let tree_sitter_data = TreeSitterData::new(snapshot); Self { saved_mtime, - tree_sitter_data: Arc::new(Mutex::new(tree_sitter_data)), + tree_sitter_data: Arc::new(tree_sitter_data), saved_version: buffer.version(), preview_version: buffer.version(), reload_task: None, @@ -1092,6 +1103,8 @@ impl Buffer { has_conflict: false, change_bits: Default::default(), _subscriptions: Vec::new(), + encoding: encoding_rs::UTF_8, + has_bom: false, } } @@ -1119,7 +1132,7 @@ impl Buffer { file: None, diagnostics: Default::default(), remote_selections: Default::default(), - tree_sitter_data: Arc::new(Mutex::new(tree_sitter_data)), + tree_sitter_data: Arc::new(tree_sitter_data), language, non_text_state_update_count: 0, } @@ -1141,7 +1154,7 @@ impl Buffer { BufferSnapshot { text, syntax, - tree_sitter_data: Arc::new(Mutex::new(tree_sitter_data)), + tree_sitter_data: Arc::new(tree_sitter_data), file: None, diagnostics: Default::default(), remote_selections: Default::default(), @@ -1170,7 +1183,7 @@ impl Buffer { BufferSnapshot { text, syntax, - tree_sitter_data: Arc::new(Mutex::new(tree_sitter_data)), + tree_sitter_data: Arc::new(tree_sitter_data), file: None, diagnostics: Default::default(), remote_selections: Default::default(), @@ -1187,10 +1200,16 @@ impl Buffer { syntax_map.interpolate(&text); let syntax = syntax_map.snapshot(); + let tree_sitter_data = if self.text.version() != *self.tree_sitter_data.version() { + Arc::new(TreeSitterData::new(text.clone())) + } else { + self.tree_sitter_data.clone() + }; + BufferSnapshot { text, syntax, - tree_sitter_data: self.tree_sitter_data.clone(), + tree_sitter_data, file: self.file.clone(), remote_selections: self.remote_selections.clone(), diagnostics: self.diagnostics.clone(), @@ -1369,6 +1388,26 @@ impl Buffer { self.saved_mtime } + /// Returns the character encoding of the buffer's file. + pub fn encoding(&self) -> &'static Encoding { + self.encoding + } + + /// Sets the character encoding of the buffer. + pub fn set_encoding(&mut self, encoding: &'static Encoding) { + self.encoding = encoding; + } + + /// Returns whether the buffer has a Byte Order Mark. + pub fn has_bom(&self) -> bool { + self.has_bom + } + + /// Sets whether the buffer has a Byte Order Mark. + pub fn set_has_bom(&mut self, has_bom: bool) { + self.has_bom = has_bom; + } + /// Assign a language to the buffer. pub fn set_language_async(&mut self, language: Option>, cx: &mut Context) { self.set_language_(language, cfg!(any(test, feature = "test-support")), cx); @@ -1624,6 +1663,16 @@ impl Buffer { self.sync_parse_timeout = timeout; } + fn invalidate_tree_sitter_data(&mut self, snapshot: text::BufferSnapshot) { + match Arc::get_mut(&mut self.tree_sitter_data) { + Some(tree_sitter_data) => tree_sitter_data.clear(snapshot), + None => { + let tree_sitter_data = TreeSitterData::new(snapshot); + self.tree_sitter_data = Arc::new(tree_sitter_data) + } + } + } + /// Called after an edit to synchronize the buffer's main parse tree with /// the buffer's new underlying state. /// @@ -1648,6 +1697,9 @@ impl Buffer { /// for the same buffer, we only initiate a new parse if we are not already /// parsing in the background. pub fn reparse(&mut self, cx: &mut Context, may_block: bool) { + if self.text.version() != *self.tree_sitter_data.version() { + self.invalidate_tree_sitter_data(self.text.snapshot()); + } if self.reparse.is_some() { return; } @@ -1749,7 +1801,7 @@ impl Buffer { self.syntax_map.lock().did_parse(syntax_snapshot); self.request_autoindent(cx); self.parse_status.0.send(ParseStatus::Idle).unwrap(); - self.tree_sitter_data.lock().clear(); + self.invalidate_tree_sitter_data(self.text.snapshot()); cx.emit(BufferEvent::Reparsed); cx.notify(); } @@ -3187,15 +3239,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() @@ -3224,6 +3283,7 @@ impl BufferSnapshot { start_positions.push(StartPosition { start: Point::from_ts_point(capture.node.start_position()), suffix: suffix.clone(), + language: mat.language.clone(), }); } } @@ -3274,8 +3334,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; } @@ -3285,7 +3344,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(); @@ -3293,14 +3352,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)) @@ -3309,16 +3375,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 @@ -3326,10 +3392,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; } @@ -4281,155 +4353,125 @@ impl BufferSnapshot { pub fn fetch_bracket_ranges( &self, range: Range, - known_chunks: Option<(&Global, &HashSet>)>, + known_chunks: Option<&HashSet>>, ) -> HashMap, Vec>> { - let mut tree_sitter_data = self.latest_tree_sitter_data().clone(); - - let known_chunks = match known_chunks { - Some((known_version, known_chunks)) => { - if !tree_sitter_data - .chunks - .version() - .changed_since(known_version) - { - known_chunks.clone() - } else { - HashSet::default() - } - } - None => HashSet::default(), - }; - - let mut new_bracket_matches = HashMap::default(); let mut all_bracket_matches = HashMap::default(); - for chunk in tree_sitter_data + for chunk in self + .tree_sitter_data .chunks - .applicable_chunks(&[self.anchor_before(range.start)..self.anchor_after(range.end)]) + .applicable_chunks(&[range.to_point(self)]) { - if known_chunks.contains(&chunk.row_range()) { + if known_chunks.is_some_and(|chunks| chunks.contains(&chunk.row_range())) { continue; } - let Some(chunk_range) = tree_sitter_data.chunks.chunk_range(chunk) else { + let chunk_range = chunk.anchor_range(); + let chunk_range = chunk_range.to_offset(&self); + + if let Some(cached_brackets) = + &self.tree_sitter_data.brackets_by_chunks.lock()[chunk.id] + { + all_bracket_matches.insert(chunk.row_range(), cached_brackets.clone()); continue; - }; - let chunk_range = chunk_range.to_offset(&tree_sitter_data.chunks.snapshot); - - let bracket_matches = match tree_sitter_data.brackets_by_chunks[chunk.id].take() { - Some(cached_brackets) => cached_brackets, - None => { - let mut all_brackets = Vec::new(); - 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 configs = matches - .grammars() - .iter() - .map(|grammar| grammar.brackets_config.as_ref().unwrap()) - .collect::>(); - - while let Some(mat) = matches.peek() { - let mut open = None; - let mut close = None; - let syntax_layer_depth = mat.depth; - let config = configs[mat.grammar_index]; - let pattern = &config.patterns[mat.pattern_index]; - for capture in mat.captures { - if capture.index == config.open_capture_ix { - open = Some(capture.node.byte_range()); - } else if capture.index == config.close_capture_ix { - close = Some(capture.node.byte_range()); - } - } + } - matches.advance(); + let mut all_brackets = Vec::new(); + let mut opens = Vec::new(); + let mut color_pairs = Vec::new(); - let Some((open_range, close_range)) = open.zip(close) else { - continue; - }; + 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() + .map(|grammar| grammar.brackets_config.as_ref().unwrap()) + .collect::>(); + + while let Some(mat) = matches.peek() { + let mut open = None; + let mut close = None; + let syntax_layer_depth = mat.depth; + let config = configs[mat.grammar_index]; + let pattern = &config.patterns[mat.pattern_index]; + for capture in mat.captures { + if capture.index == config.open_capture_ix { + open = Some(capture.node.byte_range()); + } else if capture.index == config.close_capture_ix { + close = Some(capture.node.byte_range()); + } + } - let bracket_range = open_range.start..=close_range.end; - if !bracket_range.overlaps(&chunk_range) { - continue; - } + matches.advance(); - let index = all_brackets.len(); - all_brackets.push(BracketMatch { - open_range: open_range.clone(), - close_range: close_range.clone(), - newline_only: pattern.newline_only, - syntax_layer_depth, - color_index: None, - }); + let Some((open_range, close_range)) = open.zip(close) else { + continue; + }; - // Certain languages have "brackets" that are not brackets, e.g. tags. and such - // bracket will match the entire tag with all text inside. - // For now, avoid highlighting any pair that has more than single char in each bracket. - // We need to colorize `` bracket pairs, so cannot make this check stricter. - let should_color = !pattern.rainbow_exclude - && (open_range.len() == 1 || close_range.len() == 1); - if should_color { - opens.push(open_range.clone()); - color_pairs.push((open_range, close_range, index)); - } - } + let bracket_range = open_range.start..=close_range.end; + if !bracket_range.overlaps(&chunk_range) { + continue; + } - opens.sort_by_key(|r| (r.start, r.end)); - opens.dedup_by(|a, b| a.start == b.start && a.end == b.end); - color_pairs.sort_by_key(|(_, close, _)| close.end); + let index = all_brackets.len(); + all_brackets.push(BracketMatch { + open_range: open_range.clone(), + close_range: close_range.clone(), + newline_only: pattern.newline_only, + syntax_layer_depth, + color_index: None, + }); - let mut open_stack = Vec::new(); - let mut open_index = 0; - for (open, close, index) in color_pairs { - while open_index < opens.len() && opens[open_index].start < close.start { - open_stack.push(opens[open_index].clone()); - open_index += 1; - } + // Certain languages have "brackets" that are not brackets, e.g. tags. and such + // bracket will match the entire tag with all text inside. + // For now, avoid highlighting any pair that has more than single char in each bracket. + // We need to colorize `` bracket pairs, so cannot make this check stricter. + let should_color = + !pattern.rainbow_exclude && (open_range.len() == 1 || close_range.len() == 1); + if should_color { + opens.push(open_range.clone()); + color_pairs.push((open_range, close_range, index)); + } + } - if open_stack.last() == Some(&open) { - let depth_index = open_stack.len() - 1; - all_brackets[index].color_index = Some(depth_index); - open_stack.pop(); - } - } + opens.sort_by_key(|r| (r.start, r.end)); + opens.dedup_by(|a, b| a.start == b.start && a.end == b.end); + color_pairs.sort_by_key(|(_, close, _)| close.end); - all_brackets.sort_by_key(|bracket_match| { - (bracket_match.open_range.start, bracket_match.open_range.end) - }); - new_bracket_matches.insert(chunk.id, all_brackets.clone()); - all_brackets + let mut open_stack = Vec::new(); + let mut open_index = 0; + for (open, close, index) in color_pairs { + while open_index < opens.len() && opens[open_index].start < close.start { + open_stack.push(opens[open_index].clone()); + open_index += 1; } - }; - all_bracket_matches.insert(chunk.row_range(), bracket_matches); - } - let mut latest_tree_sitter_data = self.latest_tree_sitter_data(); - if latest_tree_sitter_data.chunks.version() == &self.version { - for (chunk_id, new_matches) in new_bracket_matches { - let old_chunks = &mut latest_tree_sitter_data.brackets_by_chunks[chunk_id]; - if old_chunks.is_none() { - *old_chunks = Some(new_matches); + if open_stack.last() == Some(&open) { + let depth_index = open_stack.len() - 1; + all_brackets[index].color_index = Some(depth_index); + open_stack.pop(); } } - } - all_bracket_matches - } + all_brackets.sort_by_key(|bracket_match| { + (bracket_match.open_range.start, bracket_match.open_range.end) + }); - fn latest_tree_sitter_data(&self) -> MutexGuard<'_, RawMutex, TreeSitterData> { - let mut tree_sitter_data = self.tree_sitter_data.lock(); - if self - .version - .changed_since(tree_sitter_data.chunks.version()) - { - *tree_sitter_data = TreeSitterData::new(self.text.clone()); + if let empty_slot @ None = + &mut self.tree_sitter_data.brackets_by_chunks.lock()[chunk.id] + { + *empty_slot = Some(all_brackets.clone()); + } + all_bracket_matches.insert(chunk.row_range(), all_brackets); } - tree_sitter_data + + all_bracket_matches } pub fn all_bracket_ranges( diff --git a/crates/language/src/buffer/row_chunk.rs b/crates/language/src/buffer/row_chunk.rs index 7589c5ac078b9443c3dfd501abb0e6d79cb74581..0f3c0b5afb1cc1a2d60a2a568fe00403733ef5c6 100644 --- a/crates/language/src/buffer/row_chunk.rs +++ b/crates/language/src/buffer/row_chunk.rs @@ -3,7 +3,6 @@ use std::{ops::Range, sync::Arc}; -use clock::Global; use text::{Anchor, OffsetRangeExt as _, Point}; use util::RangeExt; @@ -19,14 +18,13 @@ use crate::BufferRow; /// #[derive(Clone)] pub struct RowChunks { - pub(crate) snapshot: text::BufferSnapshot, chunks: Arc<[RowChunk]>, + version: clock::Global, } impl std::fmt::Debug for RowChunks { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("RowChunks") - .field("version", self.snapshot.version()) .field("chunks", &self.chunks) .finish() } @@ -38,34 +36,45 @@ impl RowChunks { let last_row = buffer_point_range.end.row; let chunks = (buffer_point_range.start.row..=last_row) .step_by(max_rows_per_chunk as usize) + .collect::>(); + let last_chunk_id = chunks.len() - 1; + let chunks = chunks + .into_iter() .enumerate() - .map(|(id, chunk_start)| RowChunk { - id, - start: chunk_start, - end_exclusive: (chunk_start + max_rows_per_chunk).min(last_row), + .map(|(id, chunk_start)| { + let start = Point::new(chunk_start, 0); + let end_exclusive = (chunk_start + max_rows_per_chunk).min(last_row); + let end = if id == last_chunk_id { + Point::new(end_exclusive, snapshot.line_len(end_exclusive)) + } else { + Point::new(end_exclusive, 0) + }; + RowChunk { + id, + start: chunk_start, + end_exclusive, + start_anchor: snapshot.anchor_before(start), + end_anchor: snapshot.anchor_after(end), + } }) .collect::>(); Self { - snapshot, chunks: Arc::from(chunks), + version: snapshot.version().clone(), } } - pub fn version(&self) -> &Global { - self.snapshot.version() + pub fn version(&self) -> &clock::Global { + &self.version } pub fn len(&self) -> usize { self.chunks.len() } - pub fn applicable_chunks( - &self, - ranges: &[Range], - ) -> impl Iterator { + pub fn applicable_chunks(&self, ranges: &[Range]) -> impl Iterator { let row_ranges = ranges .iter() - .map(|range| range.to_point(&self.snapshot)) // Be lenient and yield multiple chunks if they "touch" the exclusive part of the range. // This will result in LSP hints [re-]queried for more ranges, but also more hints already visible when scrolling around. .map(|point_range| point_range.start.row..point_range.end.row + 1) @@ -81,23 +90,6 @@ impl RowChunks { .copied() } - pub fn chunk_range(&self, chunk: RowChunk) -> Option> { - if !self.chunks.contains(&chunk) { - return None; - } - - let start = Point::new(chunk.start, 0); - let end = if self.chunks.last() == Some(&chunk) { - Point::new( - chunk.end_exclusive, - self.snapshot.line_len(chunk.end_exclusive), - ) - } else { - Point::new(chunk.end_exclusive, 0) - }; - Some(self.snapshot.anchor_before(start)..self.snapshot.anchor_after(end)) - } - pub fn previous_chunk(&self, chunk: RowChunk) -> Option { if chunk.id == 0 { None @@ -112,10 +104,16 @@ pub struct RowChunk { pub id: usize, pub start: BufferRow, pub end_exclusive: BufferRow, + pub start_anchor: Anchor, + pub end_anchor: Anchor, } impl RowChunk { pub fn row_range(&self) -> Range { self.start..self.end_exclusive } + + pub fn anchor_range(&self) -> Range { + self.start_anchor..self.end_anchor + } } diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index a6a76dc70269777eb3acda79bd3fb32865c4b7ee..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 { @@ -535,7 +556,7 @@ pub trait LspInstaller { _version: &Self::BinaryVersion, _container_dir: &PathBuf, _delegate: &dyn LspAdapterDelegate, - ) -> impl Future> { + ) -> impl Send + Future> { async { None } } @@ -544,7 +565,7 @@ pub trait LspInstaller { latest_version: Self::BinaryVersion, container_dir: PathBuf, delegate: &dyn LspAdapterDelegate, - ) -> impl Future>; + ) -> impl Send + Future>; fn cached_server_binary( &self, @@ -575,6 +596,7 @@ pub trait DynLspInstaller { #[async_trait(?Send)] impl DynLspInstaller for LI where + BinaryVersion: Send + Sync, LI: LspInstaller + LspAdapter, { async fn try_fetch_server_binary( @@ -593,8 +615,13 @@ where .fetch_latest_server_version(delegate.as_ref(), pre_release, cx) .await?; - if let Some(binary) = self - .check_if_version_installed(&latest_version, &container_dir, delegate.as_ref()) + if let Some(binary) = cx + .background_executor() + .await_on_background(self.check_if_version_installed( + &latest_version, + &container_dir, + delegate.as_ref(), + )) .await { log::debug!("language server {:?} is already installed", name.0); @@ -603,8 +630,13 @@ where } else { log::debug!("downloading language server {:?}", name.0); delegate.update_status(name.clone(), BinaryStatus::Downloading); - let binary = self - .fetch_server_binary(latest_version, container_dir, delegate.as_ref()) + let binary = cx + .background_executor() + .await_on_background(self.fetch_server_binary( + latest_version, + container_dir, + delegate.as_ref(), + )) .await; delegate.update_status(name.clone(), BinaryStatus::None); @@ -2414,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_model/Cargo.toml b/crates/language_model/Cargo.toml index 7c6470f4fa0c1eac847c1194e967b451093a76ad..e472521074109216bd243f5875dcc325cc9b3fed 100644 --- a/crates/language_model/Cargo.toml +++ b/crates/language_model/Cargo.toml @@ -18,6 +18,7 @@ test-support = [] [dependencies] anthropic = { workspace = true, features = ["schemars"] } anyhow.workspace = true +credentials_provider.workspace = true base64.workspace = true client.workspace = true cloud_api_types.workspace = true @@ -38,9 +39,9 @@ serde.workspace = true serde_json.workspace = true settings.workspace = true smol.workspace = true -telemetry_events.workspace = true thiserror.workspace = true util.workspace = true +zed_env_vars.workspace = true [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/language_models/src/api_key.rs b/crates/language_model/src/api_key.rs similarity index 95% rename from crates/language_models/src/api_key.rs rename to crates/language_model/src/api_key.rs index 122234b6ced6d0bf1b7a0d684683c841824ccd2d..754fde069295d8799820020bef286b1a1a3c590c 100644 --- a/crates/language_models/src/api_key.rs +++ b/crates/language_model/src/api_key.rs @@ -2,7 +2,6 @@ use anyhow::{Result, anyhow}; use credentials_provider::CredentialsProvider; use futures::{FutureExt, future}; use gpui::{AsyncApp, Context, SharedString, Task}; -use language_model::AuthenticateError; use std::{ fmt::{Display, Formatter}, sync::Arc, @@ -10,13 +9,16 @@ use std::{ use util::ResultExt as _; use zed_env_vars::EnvVar; +use crate::AuthenticateError; + /// Manages a single API key for a language model provider. API keys either come from environment /// variables or the system keychain. /// /// Keys from the system keychain are associated with a provider URL, and this ensures that they are /// only used with that URL. pub struct ApiKeyState { - url: SharedString, + pub url: SharedString, + env_var: EnvVar, load_status: LoadStatus, load_task: Option>>, } @@ -35,9 +37,10 @@ pub struct ApiKey { } impl ApiKeyState { - pub fn new(url: SharedString) -> Self { + pub fn new(url: SharedString, env_var: EnvVar) -> Self { Self { url, + env_var, load_status: LoadStatus::NotPresent, load_task: None, } @@ -47,6 +50,10 @@ impl ApiKeyState { matches!(self.load_status, LoadStatus::Loaded { .. }) } + pub fn env_var_name(&self) -> &SharedString { + &self.env_var.name + } + pub fn is_from_env_var(&self) -> bool { match &self.load_status { LoadStatus::Loaded(ApiKey { @@ -136,14 +143,13 @@ impl ApiKeyState { pub fn handle_url_change( &mut self, url: SharedString, - env_var: &EnvVar, get_this: impl Fn(&mut Ent) -> &mut Self + Clone + 'static, cx: &mut Context, ) { if url != self.url { if !self.is_from_env_var() { // loading will continue even though this result task is dropped - let _task = self.load_if_needed(url, env_var, get_this, cx); + let _task = self.load_if_needed(url, get_this, cx); } } } @@ -156,7 +162,6 @@ impl ApiKeyState { pub fn load_if_needed( &mut self, url: SharedString, - env_var: &EnvVar, get_this: impl Fn(&mut Ent) -> &mut Self + Clone + 'static, cx: &mut Context, ) -> Task> { @@ -166,10 +171,10 @@ impl ApiKeyState { return Task::ready(Ok(())); } - if let Some(key) = &env_var.value + if let Some(key) = &self.env_var.value && !key.is_empty() { - let api_key = ApiKey::from_env(env_var.name.clone(), key); + let api_key = ApiKey::from_env(self.env_var.name.clone(), key); self.url = url; self.load_status = LoadStatus::Loaded(api_key); self.load_task = None; diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs index cb03b84cbf34d3003e53befa518ecd91626a13e9..09d44b5b408324936af00a2a5e4f1deb4f351434 100644 --- a/crates/language_model/src/language_model.rs +++ b/crates/language_model/src/language_model.rs @@ -1,3 +1,4 @@ +mod api_key; mod model; mod rate_limiter; mod registry; @@ -30,6 +31,7 @@ use std::{fmt, io}; use thiserror::Error; use util::serde::is_default; +pub use crate::api_key::{ApiKey, ApiKeyState}; pub use crate::model::*; pub use crate::rate_limiter::*; pub use crate::registry::*; @@ -37,6 +39,7 @@ pub use crate::request::*; pub use crate::role::*; pub use crate::telemetry::*; pub use crate::tool_schema::LanguageModelToolSchemaFormat; +pub use zed_env_vars::{EnvVar, env_var}; pub const ANTHROPIC_PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("anthropic"); @@ -609,6 +612,11 @@ pub trait LanguageModel: Send + Sync { false } + /// Returns whether this model or provider supports streaming tool calls; + fn supports_streaming_tools(&self) -> bool { + false + } + fn tool_input_format(&self) -> LanguageModelToolSchemaFormat { LanguageModelToolSchemaFormat::JsonSchema } @@ -763,6 +771,21 @@ pub trait LanguageModelExt: LanguageModel { } impl LanguageModelExt for dyn LanguageModel {} +impl std::fmt::Debug for dyn LanguageModel { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("") + .field("id", &self.id()) + .field("name", &self.name()) + .field("provider_id", &self.provider_id()) + .field("provider_name", &self.provider_name()) + .field("upstream_provider_name", &self.upstream_provider_name()) + .field("upstream_provider_id", &self.upstream_provider_id()) + .field("upstream_provider_id", &self.upstream_provider_id()) + .field("supports_streaming_tools", &self.supports_streaming_tools()) + .finish() + } +} + /// An error that occurred when trying to authenticate the language model provider. #[derive(Debug, Error)] pub enum AuthenticateError { diff --git a/crates/language_model/src/request.rs b/crates/language_model/src/request.rs index d97d87bdc95c443aeaf3f2b5578bf7f0c1ef322a..5e99cca4f9d6e61672c541cb90a3a1ca7da91203 100644 --- a/crates/language_model/src/request.rs +++ b/crates/language_model/src/request.rs @@ -19,7 +19,8 @@ use crate::{LanguageModelToolUse, LanguageModelToolUseId}; pub struct LanguageModelImage { /// A base64-encoded PNG image. pub source: SharedString, - pub size: Size, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub size: Option>, } impl LanguageModelImage { @@ -61,7 +62,7 @@ impl LanguageModelImage { } Some(Self { - size: size(DevicePixels(width?), DevicePixels(height?)), + size: Some(size(DevicePixels(width?), DevicePixels(height?))), source: SharedString::from(source.to_string()), }) } @@ -83,7 +84,7 @@ impl LanguageModelImage { pub fn empty() -> Self { Self { source: "".into(), - size: size(DevicePixels(0), DevicePixels(0)), + size: None, } } @@ -139,15 +140,18 @@ impl LanguageModelImage { let source = unsafe { String::from_utf8_unchecked(base64_image) }; Some(LanguageModelImage { - size: image_size, + size: Some(image_size), source: source.into(), }) }) } pub fn estimate_tokens(&self) -> usize { - let width = self.size.width.0.unsigned_abs() as usize; - let height = self.size.height.0.unsigned_abs() as usize; + let Some(size) = self.size.as_ref() else { + return 0; + }; + let width = size.width.0.unsigned_abs() as usize; + let height = size.height.0.unsigned_abs() as usize; // From: https://docs.anthropic.com/en/docs/build-with-claude/vision#calculate-image-costs // Note that are a lot of conditions on Anthropic's API, and OpenAI doesn't use this, @@ -463,8 +467,9 @@ mod tests { match result { LanguageModelToolResultContent::Image(image) => { assert_eq!(image.source.as_ref(), "base64encodedimagedata"); - assert_eq!(image.size.width.0, 100); - assert_eq!(image.size.height.0, 200); + let size = image.size.expect("size"); + assert_eq!(size.width.0, 100); + assert_eq!(size.height.0, 200); } _ => panic!("Expected Image variant"), } @@ -483,8 +488,9 @@ mod tests { match result { LanguageModelToolResultContent::Image(image) => { assert_eq!(image.source.as_ref(), "wrappedimagedata"); - assert_eq!(image.size.width.0, 50); - assert_eq!(image.size.height.0, 75); + let size = image.size.expect("size"); + assert_eq!(size.width.0, 50); + assert_eq!(size.height.0, 75); } _ => panic!("Expected Image variant"), } @@ -503,8 +509,9 @@ mod tests { match result { LanguageModelToolResultContent::Image(image) => { assert_eq!(image.source.as_ref(), "caseinsensitive"); - assert_eq!(image.size.width.0, 30); - assert_eq!(image.size.height.0, 40); + let size = image.size.expect("size"); + assert_eq!(size.width.0, 30); + assert_eq!(size.height.0, 40); } _ => panic!("Expected Image variant"), } @@ -541,8 +548,9 @@ mod tests { match result { LanguageModelToolResultContent::Image(image) => { assert_eq!(image.source.as_ref(), "directimage"); - assert_eq!(image.size.width.0, 200); - assert_eq!(image.size.height.0, 300); + let size = image.size.expect("size"); + assert_eq!(size.width.0, 200); + assert_eq!(size.height.0, 300); } _ => panic!("Expected Image variant"), } diff --git a/crates/language_model/src/telemetry.rs b/crates/language_model/src/telemetry.rs index ccdcb0ad0cdf0d830d0163f39afad478377fe01d..6d7f4df7f644115cae7b2148f4d78fde19674344 100644 --- a/crates/language_model/src/telemetry.rs +++ b/crates/language_model/src/telemetry.rs @@ -1,41 +1,101 @@ use crate::ANTHROPIC_PROVIDER_ID; use anthropic::ANTHROPIC_API_URL; use anyhow::{Context as _, anyhow}; -use client::telemetry::Telemetry; use gpui::BackgroundExecutor; use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest}; use std::env; use std::sync::Arc; -use telemetry_events::{AssistantEventData, AssistantKind, AssistantPhase}; use util::ResultExt; -pub fn report_assistant_event( - event: AssistantEventData, - telemetry: Option>, - client: Arc, - model_api_key: Option, - executor: &BackgroundExecutor, +#[derive(Clone, Debug)] +pub struct AnthropicEventData { + pub completion_type: AnthropicCompletionType, + pub event: AnthropicEventType, + pub language_name: Option, + pub message_id: Option, +} + +#[derive(Clone, Debug)] +pub enum AnthropicCompletionType { + Editor, + Terminal, + Panel, +} + +#[derive(Clone, Debug)] +pub enum AnthropicEventType { + Invoked, + Response, + Accept, + Reject, +} + +impl AnthropicCompletionType { + fn as_str(&self) -> &'static str { + match self { + Self::Editor => "natural_language_completion_in_editor", + Self::Terminal => "natural_language_completion_in_terminal", + Self::Panel => "conversation_message", + } + } +} + +impl AnthropicEventType { + fn as_str(&self) -> &'static str { + match self { + Self::Invoked => "invoke", + Self::Response => "response", + Self::Accept => "accept", + Self::Reject => "reject", + } + } +} + +pub fn report_anthropic_event( + model: &Arc, + event: AnthropicEventData, + cx: &gpui::App, ) { - if let Some(telemetry) = telemetry.as_ref() { - telemetry.report_assistant_event(event.clone()); - if telemetry.metrics_enabled() && event.model_provider == ANTHROPIC_PROVIDER_ID.0 { - if let Some(api_key) = model_api_key { - executor - .spawn(async move { - report_anthropic_event(event, client, api_key) - .await - .log_err(); - }) - .detach(); - } else { - log::error!("Cannot send Anthropic telemetry because API key is missing"); - } + let reporter = AnthropicEventReporter::new(model, cx); + reporter.report(event); +} + +#[derive(Clone)] +pub struct AnthropicEventReporter { + http_client: Arc, + executor: BackgroundExecutor, + api_key: Option, + is_anthropic: bool, +} + +impl AnthropicEventReporter { + pub fn new(model: &Arc, cx: &gpui::App) -> Self { + Self { + http_client: cx.http_client(), + executor: cx.background_executor().clone(), + api_key: model.api_key(cx), + is_anthropic: model.provider_id() == ANTHROPIC_PROVIDER_ID, } } + + pub fn report(&self, event: AnthropicEventData) { + if !self.is_anthropic { + return; + } + let Some(api_key) = self.api_key.clone() else { + return; + }; + let client = self.http_client.clone(); + self.executor + .spawn(async move { + send_anthropic_event(event, client, api_key).await.log_err(); + }) + .detach(); + } } -async fn report_anthropic_event( - event: AssistantEventData, +async fn send_anthropic_event( + event: AnthropicEventData, client: Arc, api_key: String, ) -> anyhow::Result<()> { @@ -45,18 +105,10 @@ async fn report_anthropic_event( .uri(uri) .header("X-Api-Key", api_key) .header("Content-Type", "application/json"); - let serialized_event: serde_json::Value = serde_json::json!({ - "completion_type": match event.kind { - AssistantKind::Inline => "natural_language_completion_in_editor", - AssistantKind::InlineTerminal => "natural_language_completion_in_terminal", - AssistantKind::Panel => "conversation_message", - }, - "event": match event.phase { - AssistantPhase::Response => "response", - AssistantPhase::Invoked => "invoke", - AssistantPhase::Accepted => "accept", - AssistantPhase::Rejected => "reject", - }, + + let serialized_event = serde_json::json!({ + "completion_type": event.completion_type.as_str(), + "event": event.event.as_str(), "metadata": { "language_name": event.language_name, "message_id": event.message_id, diff --git a/crates/language_models/Cargo.toml b/crates/language_models/Cargo.toml index 6c5704312d94e2c98ff62c49d3d5b57c1b274057..5531e698ab7fccae736e800f38b16e35bcd35ac4 100644 --- a/crates/language_models/Cargo.toml +++ b/crates/language_models/Cargo.toml @@ -60,7 +60,6 @@ ui_input.workspace = true util.workspace = true vercel = { workspace = true, features = ["schemars"] } x_ai = { workspace = true, features = ["schemars"] } -zed_env_vars.workspace = true [dev-dependencies] editor = { workspace = true, features = ["test-support"] } diff --git a/crates/language_models/src/language_models.rs b/crates/language_models/src/language_models.rs index d771dba3733540cdb720416c21d5d0cb76b9d3be..1038f5e233e0a5970b0e8bd969a65f6f0e2a7550 100644 --- a/crates/language_models/src/language_models.rs +++ b/crates/language_models/src/language_models.rs @@ -7,10 +7,8 @@ use gpui::{App, Context, Entity}; use language_model::{LanguageModelProviderId, LanguageModelRegistry}; use provider::deepseek::DeepSeekLanguageModelProvider; -mod api_key; pub mod provider; mod settings; -pub mod ui; use crate::provider::anthropic::AnthropicLanguageModelProvider; use crate::provider::bedrock::BedrockLanguageModelProvider; diff --git a/crates/language_models/src/provider/anthropic.rs b/crates/language_models/src/provider/anthropic.rs index 1affe38a08d22e2aaed8c1207513ce41a13b8e59..d8c972399c33922386bfba4236e1369d03d338dc 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}; @@ -8,25 +8,21 @@ use futures::{FutureExt, Stream, StreamExt, future, future::BoxFuture, stream::B use gpui::{AnyView, App, AsyncApp, Context, Entity, Task}; use http_client::HttpClient; use language_model::{ - AuthenticateError, ConfigurationViewTargetAgent, LanguageModel, - LanguageModelCacheConfiguration, LanguageModelCompletionError, LanguageModelId, - LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, - LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, - LanguageModelToolResultContent, MessageContent, RateLimiter, Role, + ApiKeyState, AuthenticateError, ConfigurationViewTargetAgent, EnvVar, LanguageModel, + LanguageModelCacheConfiguration, LanguageModelCompletionError, LanguageModelCompletionEvent, + LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, + LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, + LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, + RateLimiter, Role, StopReason, env_var, }; -use language_model::{LanguageModelCompletionEvent, LanguageModelToolUse, StopReason}; use settings::{Settings, SettingsStore}; use std::pin::Pin; use std::str::FromStr; use std::sync::{Arc, LazyLock}; use strum::IntoEnumIterator; -use ui::{List, prelude::*}; +use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; use ui_input::InputField; use util::ResultExt; -use zed_env_vars::{EnvVar, env_var}; - -use crate::api_key::ApiKeyState; -use crate::ui::{ConfiguredApiCard, InstructionListItem}; pub use settings::AnthropicAvailableModel as AvailableModel; @@ -65,12 +61,8 @@ impl State { fn authenticate(&mut self, cx: &mut Context) -> Task> { let api_url = AnthropicLanguageModelProvider::api_url(cx); - self.api_key_state.load_if_needed( - api_url, - &API_KEY_ENV_VAR, - |this| &mut this.api_key_state, - cx, - ) + self.api_key_state + .load_if_needed(api_url, |this| &mut this.api_key_state, cx) } } @@ -79,17 +71,13 @@ impl AnthropicLanguageModelProvider { let state = cx.new(|cx| { cx.observe_global::(|this: &mut State, cx| { let api_url = Self::api_url(cx); - this.api_key_state.handle_url_change( - api_url, - &API_KEY_ENV_VAR, - |this| &mut this.api_key_state, - cx, - ); + this.api_key_state + .handle_url_change(api_url, |this| &mut this.api_key_state, cx); cx.notify(); }) .detach(); State { - api_key_state: ApiKeyState::new(Self::api_url(cx)), + api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()), } }); @@ -231,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 + } } - LanguageModelToolResultContent::Image(image) => { - tokens_from_images += image.estimate_tokens(); + MessageContent::Thinking { + text: thinking, + signature, + } => { + if !thinking.is_empty() { + Some(anthropic::RequestContent::Thinking { + thinking, + signature: signature.unwrap_or_default(), + cache_control: None, + }) + } else { + None + } + } + MessageContent::RedactedThinking(data) => { + if !data.is_empty() { + Some(anthropic::RequestContent::RedactedThinking { data }) + } else { + None + } + } + MessageContent::Image(image) => Some(anthropic::RequestContent::Image { + source: anthropic::ImageSource { + source_type: "base64".to_string(), + media_type: "image/png".to_string(), + data: image.source.to_string(), + }, + cache_control: None, + }), + MessageContent::ToolUse(tool_use) => { + Some(anthropic::RequestContent::ToolUse { + id: tool_use.id.to_string(), + name: tool_use.name.to_string(), + input: tool_use.input, + cache_control: None, + }) } - }, + MessageContent::ToolResult(tool_result) => { + Some(anthropic::RequestContent::ToolResult { + tool_use_id: tool_result.tool_use_id.to_string(), + is_error: tool_result.is_error, + content: match tool_result.content { + LanguageModelToolResultContent::Text(text) => { + ToolResultContent::Plain(text.to_string()) + } + LanguageModelToolResultContent::Image(image) => { + ToolResultContent::Multipart(vec![ToolResultPart::Image { + source: anthropic::ImageSource { + source_type: "base64".to_string(), + media_type: "image/png".to_string(), + data: image.source.to_string(), + }, + }]) + } + }, + cache_control: None, + }) + } + }) + .collect(); + let anthropic_role = match message.role { + Role::User => anthropic::Role::User, + Role::Assistant => anthropic::Role::Assistant, + Role::System => unreachable!("System role should never occur here"), + }; + if let Some(last_message) = new_messages.last_mut() + && last_message.role == anthropic_role + { + last_message.content.extend(anthropic_message_content); + continue; } - } - 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(); + } + }, + } } - // Tiktoken doesn't yet support these models, so we manually use the - // same tokenizer as GPT-4. - tiktoken_rs::num_tokens_from_messages("gpt-4", &string_messages) - .map(|tokens| (tokens + tokens_from_images) as u64) - }) - .boxed() + if !string_contents.is_empty() { + string_messages.push(tiktoken_rs::ChatCompletionRequestMessage { + role: match message.role { + Role::User => "user".into(), + Role::Assistant => "assistant".into(), + Role::System => "system".into(), + }, + content: Some(string_contents), + name: None, + function_call: None, + }); + } + } + + // Tiktoken doesn't yet support these models, so we manually use the + // same tokenizer as GPT-4. + tiktoken_rs::num_tokens_from_messages("gpt-4", &string_messages) + .map(|tokens| (tokens + tokens_from_images) as u64) } impl AnthropicModel { @@ -362,6 +497,10 @@ impl LanguageModel for AnthropicModel { true } + fn supports_streaming_tools(&self) -> bool { + true + } + fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool { match choice { LanguageModelToolChoice::Auto @@ -394,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( @@ -937,14 +1109,12 @@ impl Render for ConfigurationView { .child( List::new() .child( - InstructionListItem::new( - "Create one by visiting", - Some("Anthropic's settings"), - Some("https://console.anthropic.com/settings/keys") - ) + ListBulletItem::new("") + .child(Label::new("Create one by visiting")) + .child(ButtonLink::new("Anthropic's settings", "https://console.anthropic.com/settings/keys")) ) .child( - InstructionListItem::text_only("Paste your API key below and hit enter to start using the agent") + ListBulletItem::new("Paste your API key below and hit enter to start using the agent") ) ) .child(self.api_key_editor.clone()) @@ -953,7 +1123,8 @@ impl Render for ConfigurationView { format!("You can also assign the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed."), ) .size(LabelSize::Small) - .color(Color::Muted), + .color(Color::Muted) + .mt_0p5(), ) .into_any_element() } else { diff --git a/crates/language_models/src/provider/bedrock.rs b/crates/language_models/src/provider/bedrock.rs index e478c193a27a9e30301ae9233ea666c8160b25f5..286f9ec1a4bf67c22868cf83e00e7b46e0737ba8 100644 --- a/crates/language_models/src/provider/bedrock.rs +++ b/crates/language_models/src/provider/bedrock.rs @@ -2,11 +2,10 @@ use std::pin::Pin; use std::str::FromStr; use std::sync::Arc; -use crate::ui::{ConfiguredApiCard, InstructionListItem}; 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; @@ -31,20 +30,21 @@ 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::{List, prelude::*}; +use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; use ui_input::InputField; use util::ResultExt; @@ -55,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)] @@ -80,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, @@ -91,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, } } } @@ -131,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 @@ -155,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 @@ -179,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(); })?; @@ -230,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 { @@ -258,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| { @@ -267,7 +398,7 @@ impl BedrockLanguageModelProvider { }); Self { - http_client: AwsHttpClient::new(http_client.clone()), + http_client: AwsHttpClient::new(http_client), handle: Tokio::handle(cx), state, } @@ -313,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); } } @@ -367,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)) } } @@ -394,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()) @@ -426,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")?; @@ -1025,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, @@ -1036,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(); @@ -1067,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) }); @@ -1096,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, @@ -1132,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 }) @@ -1164,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()); @@ -1206,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(); @@ -1250,24 +1398,20 @@ impl Render for ConfigurationView { .child( List::new() .child( - InstructionListItem::new( - "Grant permissions to the strategy you'll use according to the:", - Some("Prerequisites"), - Some("https://docs.aws.amazon.com/bedrock/latest/userguide/inference-prereq.html"), - ) + ListBulletItem::new("") + .child(Label::new("Grant permissions to the strategy you'll use according to the:")) + .child(ButtonLink::new("Prerequisites", "https://docs.aws.amazon.com/bedrock/latest/userguide/inference-prereq.html")) ) .child( - InstructionListItem::new( - "Select the models you would like access to:", - Some("Bedrock Model Catalog"), - Some("https://us-east-1.console.aws.amazon.com/bedrock/home?region=us-east-1#/modelaccess"), - ) + ListBulletItem::new("") + .child(Label::new("Select the models you would like access to:")) + .child(ButtonLink::new("Bedrock Model Catalog", "https://us-east-1.console.aws.amazon.com/bedrock/home?region=us-east-1#/modelaccess")) ) ) .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) @@ -1275,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), @@ -1297,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(InstructionListItem::new( - "Create an IAM user in the AWS console with programmatic access", - Some("IAM Console"), - Some("https://us-east-1.console.aws.amazon.com/iam/home?region=us-east-1#/users"), - )) - .child(InstructionListItem::new( - "Attach the necessary Bedrock permissions to this ", - Some("user"), - Some("https://docs.aws.amazon.com/bedrock/latest/userguide/inference-prereq.html"), - )) - .child(InstructionListItem::text_only( - "Copy the access key ID and secret access key when provided", - )) - .child(InstructionListItem::text_only( - "Enter these credentials below", - )), + .child( + ListBulletItem::new("") + .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("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("") + .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 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 a19a427dbacb32883b1877888ec04899a2b8d427..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; @@ -602,6 +604,10 @@ impl LanguageModel for CloudLanguageModel { self.model.supports_images } + fn supports_streaming_tools(&self) -> bool { + self.model.supports_streaming_tools + } + fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool { match choice { LanguageModelToolChoice::Auto @@ -663,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/copilot_chat.rs b/crates/language_models/src/provider/copilot_chat.rs index 92ac342a39ff04ae42f5b01b5777a5d16563c37f..70198b337e467e1618192e781d3e3be305fea9c5 100644 --- a/crates/language_models/src/provider/copilot_chat.rs +++ b/crates/language_models/src/provider/copilot_chat.rs @@ -14,7 +14,7 @@ use copilot::{Copilot, Status}; use futures::future::BoxFuture; use futures::stream::BoxStream; use futures::{FutureExt, Stream, StreamExt}; -use gpui::{Action, AnyView, App, AsyncApp, Entity, Render, Subscription, Task, svg}; +use gpui::{AnyView, App, AsyncApp, Entity, Subscription, Task}; use http_client::StatusCode; use language::language_settings::all_language_settings; use language_model::{ @@ -26,11 +26,9 @@ use language_model::{ StopReason, TokenUsage, }; use settings::SettingsStore; -use ui::{CommonAnimationExt, prelude::*}; +use ui::prelude::*; use util::debug_panic; -use crate::ui::ConfiguredApiCard; - const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("copilot_chat"); const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("GitHub Copilot Chat"); @@ -179,8 +177,18 @@ impl LanguageModelProvider for CopilotChatLanguageModelProvider { _: &mut Window, cx: &mut App, ) -> AnyView { - let state = self.state.clone(); - cx.new(|cx| ConfigurationView::new(state, cx)).into() + cx.new(|cx| { + copilot::ConfigurationView::new( + |cx| { + CopilotChat::global(cx) + .map(|m| m.read(cx).is_authenticated()) + .unwrap_or(false) + }, + copilot::ConfigurationMode::Chat, + cx, + ) + }) + .into() } fn reset_credentials(&self, _cx: &mut App) -> Task> { @@ -1474,92 +1482,3 @@ mod tests { ); } } -struct ConfigurationView { - copilot_status: Option, - state: Entity, - _subscription: Option, -} - -impl ConfigurationView { - pub fn new(state: Entity, cx: &mut Context) -> Self { - let copilot = Copilot::global(cx); - - Self { - copilot_status: copilot.as_ref().map(|copilot| copilot.read(cx).status()), - state, - _subscription: copilot.as_ref().map(|copilot| { - cx.observe(copilot, |this, model, cx| { - this.copilot_status = Some(model.read(cx).status()); - cx.notify(); - }) - }), - } - } -} - -impl Render for ConfigurationView { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - if self.state.read(cx).is_authenticated(cx) { - ConfiguredApiCard::new("Authorized") - .button_label("Sign Out") - .on_click(|_, window, cx| { - window.dispatch_action(copilot::SignOut.boxed_clone(), cx); - }) - .into_any_element() - } else { - let loading_icon = Icon::new(IconName::ArrowCircle).with_rotate_animation(4); - - const ERROR_LABEL: &str = "Copilot Chat requires an active GitHub Copilot subscription. Please ensure Copilot is configured and try again, or use a different Assistant provider."; - - match &self.copilot_status { - Some(status) => match status { - Status::Starting { task: _ } => h_flex() - .gap_2() - .child(loading_icon) - .child(Label::new("Starting Copilot…")) - .into_any_element(), - Status::SigningIn { prompt: _ } - | Status::SignedOut { - awaiting_signing_in: true, - } => h_flex() - .gap_2() - .child(loading_icon) - .child(Label::new("Signing into Copilot…")) - .into_any_element(), - Status::Error(_) => { - const LABEL: &str = "Copilot had issues starting. Please try restarting it. If the issue persists, try reinstalling Copilot."; - v_flex() - .gap_6() - .child(Label::new(LABEL)) - .child(svg().size_8().path(IconName::CopilotError.path())) - .into_any_element() - } - _ => { - const LABEL: &str = "To use Zed's agent with GitHub Copilot, you need to be logged in to GitHub. Note that your GitHub account must have an active Copilot Chat subscription."; - - v_flex() - .gap_2() - .child(Label::new(LABEL)) - .child( - Button::new("sign_in", "Sign in to use GitHub Copilot") - .full_width() - .style(ButtonStyle::Outlined) - .icon_color(Color::Muted) - .icon(IconName::Github) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) - .on_click(|_, window, cx| { - copilot::initiate_sign_in(window, cx) - }), - ) - .into_any_element() - } - }, - None => v_flex() - .gap_6() - .child(Label::new(ERROR_LABEL)) - .into_any_element(), - } - } - } -} diff --git a/crates/language_models/src/provider/deepseek.rs b/crates/language_models/src/provider/deepseek.rs index 91b83bb9f1d0f08fe70f5e750ff8ce993a7afd7f..b00a5d82f5665a5c87c662d1af84fbeb9ac07ebb 100644 --- a/crates/language_models/src/provider/deepseek.rs +++ b/crates/language_models/src/provider/deepseek.rs @@ -7,11 +7,11 @@ use futures::{FutureExt, StreamExt, future, future::BoxFuture, stream::BoxStream use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window}; use http_client::HttpClient; use language_model::{ - AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, - LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, - LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, - LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, - RateLimiter, Role, StopReason, TokenUsage, + ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError, + LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, + LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, + LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent, + LanguageModelToolUse, MessageContent, RateLimiter, Role, StopReason, TokenUsage, env_var, }; pub use settings::DeepseekAvailableModel as AvailableModel; use settings::{Settings, SettingsStore}; @@ -19,13 +19,9 @@ use std::pin::Pin; use std::str::FromStr; use std::sync::{Arc, LazyLock}; -use ui::{List, prelude::*}; +use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; use ui_input::InputField; use util::ResultExt; -use zed_env_vars::{EnvVar, env_var}; - -use crate::ui::ConfiguredApiCard; -use crate::{api_key::ApiKeyState, ui::InstructionListItem}; const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("deepseek"); const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("DeepSeek"); @@ -67,12 +63,8 @@ impl State { fn authenticate(&mut self, cx: &mut Context) -> Task> { let api_url = DeepSeekLanguageModelProvider::api_url(cx); - self.api_key_state.load_if_needed( - api_url, - &API_KEY_ENV_VAR, - |this| &mut this.api_key_state, - cx, - ) + self.api_key_state + .load_if_needed(api_url, |this| &mut this.api_key_state, cx) } } @@ -81,17 +73,13 @@ impl DeepSeekLanguageModelProvider { let state = cx.new(|cx| { cx.observe_global::(|this: &mut State, cx| { let api_url = Self::api_url(cx); - this.api_key_state.handle_url_change( - api_url, - &API_KEY_ENV_VAR, - |this| &mut this.api_key_state, - cx, - ); + this.api_key_state + .handle_url_change(api_url, |this| &mut this.api_key_state, cx); cx.notify(); }) .detach(); State { - api_key_state: ApiKeyState::new(Self::api_url(cx)), + api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()), } }); @@ -632,12 +620,15 @@ impl Render for ConfigurationView { .child(Label::new("To use DeepSeek in Zed, you need an API key:")) .child( List::new() - .child(InstructionListItem::new( - "Get your API key from the", - Some("DeepSeek console"), - Some("https://platform.deepseek.com/api_keys"), - )) - .child(InstructionListItem::text_only( + .child( + ListBulletItem::new("") + .child(Label::new("Get your API key from the")) + .child(ButtonLink::new( + "DeepSeek console", + "https://platform.deepseek.com/api_keys", + )), + ) + .child(ListBulletItem::new( "Paste your API key below and hit enter to start using the assistant", )), ) diff --git a/crates/language_models/src/provider/google.rs b/crates/language_models/src/provider/google.rs index c5a5affcd3d9e8c34f6306f86cb5348f86397892..989b99061b6d0f4c6680f08616c55946138ae0fe 100644 --- a/crates/language_models/src/provider/google.rs +++ b/crates/language_models/src/provider/google.rs @@ -9,7 +9,7 @@ use google_ai::{ use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window}; use http_client::HttpClient; use language_model::{ - AuthenticateError, ConfigurationViewTargetAgent, LanguageModelCompletionError, + AuthenticateError, ConfigurationViewTargetAgent, EnvVar, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelToolChoice, LanguageModelToolSchemaFormat, LanguageModelToolUse, LanguageModelToolUseId, MessageContent, StopReason, }; @@ -28,14 +28,11 @@ use std::sync::{ atomic::{self, AtomicU64}, }; use strum::IntoEnumIterator; -use ui::{List, prelude::*}; +use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; use ui_input::InputField; use util::ResultExt; -use zed_env_vars::EnvVar; -use crate::api_key::ApiKey; -use crate::api_key::ApiKeyState; -use crate::ui::{ConfiguredApiCard, InstructionListItem}; +use language_model::{ApiKey, ApiKeyState}; const PROVIDER_ID: LanguageModelProviderId = language_model::GOOGLE_PROVIDER_ID; const PROVIDER_NAME: LanguageModelProviderName = language_model::GOOGLE_PROVIDER_NAME; @@ -87,12 +84,8 @@ impl State { fn authenticate(&mut self, cx: &mut Context) -> Task> { let api_url = GoogleLanguageModelProvider::api_url(cx); - self.api_key_state.load_if_needed( - api_url, - &API_KEY_ENV_VAR, - |this| &mut this.api_key_state, - cx, - ) + self.api_key_state + .load_if_needed(api_url, |this| &mut this.api_key_state, cx) } } @@ -101,17 +94,13 @@ impl GoogleLanguageModelProvider { let state = cx.new(|cx| { cx.observe_global::(|this: &mut State, cx| { let api_url = Self::api_url(cx); - this.api_key_state.handle_url_change( - api_url, - &API_KEY_ENV_VAR, - |this| &mut this.api_key_state, - cx, - ); + this.api_key_state + .handle_url_change(api_url, |this| &mut this.api_key_state, cx); cx.notify(); }) .detach(); State { - api_key_state: ApiKeyState::new(Self::api_url(cx)), + api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()), } }); @@ -873,14 +862,14 @@ impl Render for ConfigurationView { }))) .child( List::new() - .child(InstructionListItem::new( - "Create one by visiting", - Some("Google AI's console"), - Some("https://aistudio.google.com/app/apikey"), - )) - .child(InstructionListItem::text_only( - "Paste your API key below and hit enter to start using the assistant", - )), + .child( + ListBulletItem::new("") + .child(Label::new("Create one by visiting")) + .child(ButtonLink::new("Google AI's console", "https://aistudio.google.com/app/apikey")) + ) + .child( + ListBulletItem::new("Paste your API key below and hit enter to start using the agent") + ) ) .child(self.api_key_editor.clone()) .child( diff --git a/crates/language_models/src/provider/lmstudio.rs b/crates/language_models/src/provider/lmstudio.rs index a16bd351a9d779bcba5b2a4111fc62e0dc9dc639..94f99f10afc8928fb7fbc8526ab46e7dca37a5ce 100644 --- a/crates/language_models/src/provider/lmstudio.rs +++ b/crates/language_models/src/provider/lmstudio.rs @@ -20,11 +20,10 @@ use settings::{Settings, SettingsStore}; use std::pin::Pin; use std::str::FromStr; use std::{collections::BTreeMap, sync::Arc}; -use ui::{ButtonLike, Indicator, List, prelude::*}; +use ui::{ButtonLike, Indicator, List, ListBulletItem, prelude::*}; use util::ResultExt; use crate::AllLanguageModelSettings; -use crate::ui::InstructionListItem; const LMSTUDIO_DOWNLOAD_URL: &str = "https://lmstudio.ai/download"; const LMSTUDIO_CATALOG_URL: &str = "https://lmstudio.ai/models"; @@ -686,12 +685,14 @@ impl Render for ConfigurationView { .child( v_flex().gap_1().child(Label::new(lmstudio_intro)).child( List::new() - .child(InstructionListItem::text_only( + .child(ListBulletItem::new( "LM Studio needs to be running with at least one model downloaded.", )) - .child(InstructionListItem::text_only( - "To get your first model, try running `lms get qwen2.5-coder-7b`", - )), + .child( + ListBulletItem::new("") + .child(Label::new("To get your first model, try running")) + .child(Label::new("lms get qwen2.5-coder-7b").inline_code(cx)), + ), ), ) .child( diff --git a/crates/language_models/src/provider/mistral.rs b/crates/language_models/src/provider/mistral.rs index 8372a8c95e579f1d860fd9bb25656731ee2c7e50..64f3999e3aa96b2611e265a6eaf5df8063332c2a 100644 --- a/crates/language_models/src/provider/mistral.rs +++ b/crates/language_models/src/provider/mistral.rs @@ -1,31 +1,27 @@ use anyhow::{Result, anyhow}; use collections::BTreeMap; -use fs::Fs; + use futures::{FutureExt, Stream, StreamExt, future, future::BoxFuture, stream::BoxStream}; use gpui::{AnyView, App, AsyncApp, Context, Entity, Global, SharedString, Task, Window}; use http_client::HttpClient; use language_model::{ - AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, - LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, - LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, - LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, - RateLimiter, Role, StopReason, TokenUsage, + ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError, + LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, + LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, + LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent, + LanguageModelToolUse, MessageContent, RateLimiter, Role, StopReason, TokenUsage, env_var, }; -use mistral::{CODESTRAL_API_URL, MISTRAL_API_URL, StreamResponse}; +pub use mistral::{CODESTRAL_API_URL, MISTRAL_API_URL, StreamResponse}; pub use settings::MistralAvailableModel as AvailableModel; -use settings::{EditPredictionProvider, Settings, SettingsStore, update_settings_file}; +use settings::{Settings, SettingsStore}; use std::collections::HashMap; use std::pin::Pin; use std::str::FromStr; use std::sync::{Arc, LazyLock}; use strum::IntoEnumIterator; -use ui::{List, prelude::*}; +use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; use ui_input::InputField; use util::ResultExt; -use zed_env_vars::{EnvVar, env_var}; - -use crate::ui::ConfiguredApiCard; -use crate::{api_key::ApiKeyState, ui::InstructionListItem}; const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("mistral"); const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("Mistral"); @@ -44,12 +40,26 @@ pub struct MistralSettings { pub struct MistralLanguageModelProvider { http_client: Arc, - state: Entity, + pub state: Entity, } pub struct State { api_key_state: ApiKeyState, - codestral_api_key_state: ApiKeyState, + codestral_api_key_state: Entity, +} + +struct CodestralApiKey(Entity); +impl Global for CodestralApiKey {} + +pub fn codestral_api_key(cx: &mut App) -> Entity { + if cx.has_global::() { + cx.global::().0.clone() + } else { + let api_key_state = cx + .new(|_| ApiKeyState::new(CODESTRAL_API_URL.into(), CODESTRAL_API_KEY_ENV_VAR.clone())); + cx.set_global(CodestralApiKey(api_key_state.clone())); + api_key_state + } } impl State { @@ -63,39 +73,19 @@ impl State { .store(api_url, api_key, |this| &mut this.api_key_state, cx) } - fn set_codestral_api_key( - &mut self, - api_key: Option, - cx: &mut Context, - ) -> Task> { - self.codestral_api_key_state.store( - CODESTRAL_API_URL.into(), - api_key, - |this| &mut this.codestral_api_key_state, - cx, - ) - } - fn authenticate(&mut self, cx: &mut Context) -> Task> { let api_url = MistralLanguageModelProvider::api_url(cx); - self.api_key_state.load_if_needed( - api_url, - &API_KEY_ENV_VAR, - |this| &mut this.api_key_state, - cx, - ) + self.api_key_state + .load_if_needed(api_url, |this| &mut this.api_key_state, cx) } fn authenticate_codestral( &mut self, cx: &mut Context, ) -> Task> { - self.codestral_api_key_state.load_if_needed( - CODESTRAL_API_URL.into(), - &CODESTRAL_API_KEY_ENV_VAR, - |this| &mut this.codestral_api_key_state, - cx, - ) + self.codestral_api_key_state.update(cx, |state, cx| { + state.load_if_needed(CODESTRAL_API_URL.into(), |state| state, cx) + }) } } @@ -116,18 +106,14 @@ impl MistralLanguageModelProvider { let state = cx.new(|cx| { cx.observe_global::(|this: &mut State, cx| { let api_url = Self::api_url(cx); - this.api_key_state.handle_url_change( - api_url, - &API_KEY_ENV_VAR, - |this| &mut this.api_key_state, - cx, - ); + this.api_key_state + .handle_url_change(api_url, |this| &mut this.api_key_state, cx); cx.notify(); }) .detach(); State { - api_key_state: ApiKeyState::new(Self::api_url(cx)), - codestral_api_key_state: ApiKeyState::new(CODESTRAL_API_URL.into()), + api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()), + codestral_api_key_state: codestral_api_key(cx), } }); @@ -142,7 +128,11 @@ impl MistralLanguageModelProvider { } pub fn codestral_api_key(&self, url: &str, cx: &App) -> Option> { - self.state.read(cx).codestral_api_key_state.key(url) + self.state + .read(cx) + .codestral_api_key_state + .read(cx) + .key(url) } fn create_language_model(&self, model: mistral::Model) -> Arc { @@ -159,7 +149,7 @@ impl MistralLanguageModelProvider { &crate::AllLanguageModelSettings::get_global(cx).mistral } - fn api_url(cx: &App) -> SharedString { + pub fn api_url(cx: &App) -> SharedString { let api_url = &Self::settings(cx).api_url; if api_url.is_empty() { mistral::MISTRAL_API_URL.into() @@ -747,7 +737,6 @@ struct RawToolCall { struct ConfigurationView { api_key_editor: Entity, - codestral_api_key_editor: Entity, state: Entity, load_credentials_task: Option>, } @@ -756,8 +745,6 @@ impl ConfigurationView { fn new(state: Entity, window: &mut Window, cx: &mut Context) -> Self { let api_key_editor = cx.new(|cx| InputField::new(window, cx, "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")); - let codestral_api_key_editor = - cx.new(|cx| InputField::new(window, cx, "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")); cx.observe(&state, |_, _, cx| { cx.notify(); @@ -774,12 +761,6 @@ impl ConfigurationView { // We don't log an error, because "not signed in" is also an error. let _ = task.await; } - if let Some(task) = state - .update(cx, |state, cx| state.authenticate_codestral(cx)) - .log_err() - { - let _ = task.await; - } this.update(cx, |this, cx| { this.load_credentials_task = None; @@ -791,7 +772,6 @@ impl ConfigurationView { Self { api_key_editor, - codestral_api_key_editor, state, load_credentials_task, } @@ -829,110 +809,9 @@ impl ConfigurationView { .detach_and_log_err(cx); } - fn save_codestral_api_key( - &mut self, - _: &menu::Confirm, - window: &mut Window, - cx: &mut Context, - ) { - let api_key = self - .codestral_api_key_editor - .read(cx) - .text(cx) - .trim() - .to_string(); - if api_key.is_empty() { - return; - } - - // url changes can cause the editor to be displayed again - self.codestral_api_key_editor - .update(cx, |editor, cx| editor.set_text("", window, cx)); - - let state = self.state.clone(); - cx.spawn_in(window, async move |_, cx| { - state - .update(cx, |state, cx| { - state.set_codestral_api_key(Some(api_key), cx) - })? - .await?; - cx.update(|_window, cx| { - set_edit_prediction_provider(EditPredictionProvider::Codestral, cx) - }) - }) - .detach_and_log_err(cx); - } - - fn reset_codestral_api_key(&mut self, window: &mut Window, cx: &mut Context) { - self.codestral_api_key_editor - .update(cx, |editor, cx| editor.set_text("", window, cx)); - - let state = self.state.clone(); - cx.spawn_in(window, async move |_, cx| { - state - .update(cx, |state, cx| state.set_codestral_api_key(None, cx))? - .await?; - cx.update(|_window, cx| set_edit_prediction_provider(EditPredictionProvider::Zed, cx)) - }) - .detach_and_log_err(cx); - } - fn should_render_api_key_editor(&self, cx: &mut Context) -> bool { !self.state.read(cx).is_authenticated() } - - fn render_codestral_api_key_editor(&mut self, cx: &mut Context) -> AnyElement { - let key_state = &self.state.read(cx).codestral_api_key_state; - let should_show_editor = !key_state.has_key(); - let env_var_set = key_state.is_from_env_var(); - let configured_card_label = if env_var_set { - format!("API key set in {CODESTRAL_API_KEY_ENV_VAR_NAME} environment variable") - } else { - "Codestral API key configured".to_string() - }; - - if should_show_editor { - v_flex() - .id("codestral") - .size_full() - .mt_2() - .on_action(cx.listener(Self::save_codestral_api_key)) - .child(Label::new( - "To use Codestral as an edit prediction provider, \ - you need to add a Codestral-specific API key. Follow these steps:", - )) - .child( - List::new() - .child(InstructionListItem::new( - "Create one by visiting", - Some("the Codestral section of Mistral's console"), - Some("https://console.mistral.ai/codestral"), - )) - .child(InstructionListItem::text_only("Paste your API key below and hit enter")), - ) - .child(self.codestral_api_key_editor.clone()) - .child( - Label::new( - format!("You can also assign the {CODESTRAL_API_KEY_ENV_VAR_NAME} environment variable and restart Zed."), - ) - .size(LabelSize::Small).color(Color::Muted), - ).into_any() - } else { - ConfiguredApiCard::new(configured_card_label) - .disabled(env_var_set) - .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))) - .when(env_var_set, |this| { - this.tooltip_label(format!( - "To reset your API key, \ - unset the {CODESTRAL_API_KEY_ENV_VAR_NAME} environment variable." - )) - }) - .on_click( - cx.listener(|this, _, window, cx| this.reset_codestral_api_key(window, cx)), - ) - .into_any_element() - } - } } impl Render for ConfigurationView { @@ -958,17 +837,17 @@ impl Render for ConfigurationView { .child(Label::new("To use Zed's agent with Mistral, you need to add an API key. Follow these steps:")) .child( List::new() - .child(InstructionListItem::new( - "Create one by visiting", - Some("Mistral's console"), - Some("https://console.mistral.ai/api-keys"), - )) - .child(InstructionListItem::text_only( - "Ensure your Mistral account has credits", - )) - .child(InstructionListItem::text_only( - "Paste your API key below and hit enter to start using the assistant", - )), + .child( + ListBulletItem::new("") + .child(Label::new("Create one by visiting")) + .child(ButtonLink::new("Mistral's console", "https://console.mistral.ai/api-keys")) + ) + .child( + ListBulletItem::new("Ensure your Mistral account has credits") + ) + .child( + ListBulletItem::new("Paste your API key below and hit enter to start using the assistant") + ), ) .child(self.api_key_editor.clone()) .child( @@ -977,7 +856,6 @@ impl Render for ConfigurationView { ) .size(LabelSize::Small).color(Color::Muted), ) - .child(self.render_codestral_api_key_editor(cx)) .into_any() } else { v_flex() @@ -994,24 +872,11 @@ impl Render for ConfigurationView { )) }), ) - .child(self.render_codestral_api_key_editor(cx)) .into_any() } } } -fn set_edit_prediction_provider(provider: EditPredictionProvider, cx: &mut App) { - let fs = ::global(cx); - update_settings_file(fs, cx, move |settings, _| { - settings - .project - .all_languages - .features - .get_or_insert_default() - .edit_prediction_provider = Some(provider); - }); -} - #[cfg(test)] mod tests { use super::*; @@ -1062,7 +927,7 @@ mod tests { MessageContent::Text("What's in this image?".into()), MessageContent::Image(LanguageModelImage { source: "base64data".into(), - size: Default::default(), + size: None, }), ], cache: false, diff --git a/crates/language_models/src/provider/ollama.rs b/crates/language_models/src/provider/ollama.rs index 8345db3cce9fc51c487ec039c4257bfb39b162c3..c5a8bf41711563110cbcb5d81698b7029b04a713 100644 --- a/crates/language_models/src/provider/ollama.rs +++ b/crates/language_models/src/provider/ollama.rs @@ -5,11 +5,11 @@ use futures::{Stream, TryFutureExt, stream}; use gpui::{AnyView, App, AsyncApp, Context, CursorStyle, Entity, Task}; use http_client::HttpClient; use language_model::{ - AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, - LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, - LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, - LanguageModelRequestTool, LanguageModelToolChoice, LanguageModelToolUse, - LanguageModelToolUseId, MessageContent, RateLimiter, Role, StopReason, TokenUsage, + ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError, + LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, + LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, + LanguageModelRequest, LanguageModelRequestTool, LanguageModelToolChoice, LanguageModelToolUse, + LanguageModelToolUseId, MessageContent, RateLimiter, Role, StopReason, TokenUsage, env_var, }; use menu; use ollama::{ @@ -22,13 +22,13 @@ use std::pin::Pin; use std::sync::LazyLock; use std::sync::atomic::{AtomicU64, Ordering}; use std::{collections::HashMap, sync::Arc}; -use ui::{ButtonLike, ElevationIndex, List, Tooltip, prelude::*}; +use ui::{ + ButtonLike, ButtonLink, ConfiguredApiCard, ElevationIndex, List, ListBulletItem, Tooltip, + prelude::*, +}; use ui_input::InputField; -use zed_env_vars::{EnvVar, env_var}; use crate::AllLanguageModelSettings; -use crate::api_key::ApiKeyState; -use crate::ui::{ConfiguredApiCard, InstructionListItem}; const OLLAMA_DOWNLOAD_URL: &str = "https://ollama.com/download"; const OLLAMA_LIBRARY_URL: &str = "https://ollama.com/library"; @@ -43,6 +43,7 @@ static API_KEY_ENV_VAR: LazyLock = env_var!(API_KEY_ENV_VAR_NAME); #[derive(Default, Debug, Clone, PartialEq)] pub struct OllamaSettings { pub api_url: String, + pub auto_discover: bool, pub available_models: Vec, } @@ -80,12 +81,9 @@ impl State { fn authenticate(&mut self, cx: &mut Context) -> Task> { let api_url = OllamaLanguageModelProvider::api_url(cx); - let task = self.api_key_state.load_if_needed( - api_url, - &API_KEY_ENV_VAR, - |this| &mut this.api_key_state, - cx, - ); + let task = self + .api_key_state + .load_if_needed(api_url, |this| &mut this.api_key_state, cx); // Always try to fetch models - if no API key is needed (local Ollama), it will work // If API key is needed and provided, it will work @@ -185,7 +183,7 @@ impl OllamaLanguageModelProvider { http_client, fetched_models: Default::default(), fetch_model_task: None, - api_key_state: ApiKeyState::new(Self::api_url(cx)), + api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()), } }), }; @@ -241,10 +239,13 @@ impl LanguageModelProvider for OllamaLanguageModelProvider { fn provided_models(&self, cx: &App) -> Vec> { let mut models: HashMap = HashMap::new(); + let settings = OllamaLanguageModelProvider::settings(cx); // Add models from the Ollama API - for model in self.state.read(cx).fetched_models.iter() { - models.insert(model.name.clone(), model.clone()); + if settings.auto_discover { + for model in self.state.read(cx).fetched_models.iter() { + models.insert(model.name.clone(), model.clone()); + } } // Override with available models from settings @@ -723,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( @@ -733,15 +734,17 @@ impl ConfigurationView { .child(Label::new("To use local Ollama:")) .child( List::new() - .child(InstructionListItem::new( - "Download and install Ollama from", - Some("ollama.com"), - Some("https://ollama.com/download"), - )) - .child(InstructionListItem::text_only( - "Start Ollama and download a model: `ollama run gpt-oss:20b`", - )) - .child(InstructionListItem::text_only( + .child( + ListBulletItem::new("") + .child(Label::new("Download and install Ollama from")) + .child(ButtonLink::new("ollama.com", "https://ollama.com/download")), + ) + .child( + ListBulletItem::new("") + .child(Label::new("Start Ollama and download a model:")) + .child(Label::new("ollama run gpt-oss:20b").inline_code(cx)), + ) + .child(ListBulletItem::new( "Click 'Connect' below to start using Ollama in Zed", )), ) @@ -830,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_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs index 32ee95ce9bd423bf7f66efc1bc7440455380ab5c..afaffba3e53eb2496f9fae795d69b9e9c9f57249 100644 --- a/crates/language_models/src/provider/open_ai.rs +++ b/crates/language_models/src/provider/open_ai.rs @@ -5,11 +5,11 @@ use futures::{FutureExt, StreamExt, future, future::BoxFuture}; use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window}; use http_client::HttpClient; use language_model::{ - AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, - LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, - LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, - LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, - RateLimiter, Role, StopReason, TokenUsage, + ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError, + LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, + LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, + LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent, + LanguageModelToolUse, MessageContent, RateLimiter, Role, StopReason, TokenUsage, env_var, }; use menu; use open_ai::{ @@ -20,13 +20,9 @@ use std::pin::Pin; use std::str::FromStr as _; use std::sync::{Arc, LazyLock}; use strum::IntoEnumIterator; -use ui::{List, prelude::*}; +use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; use ui_input::InputField; use util::ResultExt; -use zed_env_vars::{EnvVar, env_var}; - -use crate::ui::ConfiguredApiCard; -use crate::{api_key::ApiKeyState, ui::InstructionListItem}; const PROVIDER_ID: LanguageModelProviderId = language_model::OPEN_AI_PROVIDER_ID; const PROVIDER_NAME: LanguageModelProviderName = language_model::OPEN_AI_PROVIDER_NAME; @@ -62,12 +58,8 @@ impl State { fn authenticate(&mut self, cx: &mut Context) -> Task> { let api_url = OpenAiLanguageModelProvider::api_url(cx); - self.api_key_state.load_if_needed( - api_url, - &API_KEY_ENV_VAR, - |this| &mut this.api_key_state, - cx, - ) + self.api_key_state + .load_if_needed(api_url, |this| &mut this.api_key_state, cx) } } @@ -76,17 +68,13 @@ impl OpenAiLanguageModelProvider { let state = cx.new(|cx| { cx.observe_global::(|this: &mut State, cx| { let api_url = Self::api_url(cx); - this.api_key_state.handle_url_change( - api_url, - &API_KEY_ENV_VAR, - |this| &mut this.api_key_state, - cx, - ); + this.api_key_state + .handle_url_change(api_url, |this| &mut this.api_key_state, cx); cx.notify(); }) .detach(); State { - api_key_state: ApiKeyState::new(Self::api_url(cx)), + api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()), } }); @@ -278,6 +266,7 @@ impl LanguageModel for OpenAiLanguageModel { | Model::FiveMini | Model::FiveNano | Model::FivePointOne + | Model::FivePointTwo | Model::O1 | Model::O3 | Model::O4Mini => true, @@ -675,8 +664,11 @@ pub fn count_open_ai_tokens( | Model::O4Mini | Model::Five | Model::FiveMini - | Model::FiveNano => tiktoken_rs::num_tokens_from_messages(model.id(), &messages), // GPT-5.1 doesn't have tiktoken support yet; fall back on gpt-4o tokenizer - Model::FivePointOne => tiktoken_rs::num_tokens_from_messages("gpt-5", &messages), + | Model::FiveNano => tiktoken_rs::num_tokens_from_messages(model.id(), &messages), + // GPT-5.1 and 5.2 don't have dedicated tiktoken support; use gpt-5 tokenizer + Model::FivePointOne | Model::FivePointTwo => { + tiktoken_rs::num_tokens_from_messages("gpt-5", &messages) + } } .map(|tokens| tokens as u64) }) @@ -786,17 +778,17 @@ impl Render for ConfigurationView { .child(Label::new("To use Zed's agent with OpenAI, you need to add an API key. Follow these steps:")) .child( List::new() - .child(InstructionListItem::new( - "Create one by visiting", - Some("OpenAI's console"), - Some("https://platform.openai.com/api-keys"), - )) - .child(InstructionListItem::text_only( - "Ensure your OpenAI account has credits", - )) - .child(InstructionListItem::text_only( - "Paste your API key below and hit enter to start using the assistant", - )), + .child( + ListBulletItem::new("") + .child(Label::new("Create one by visiting")) + .child(ButtonLink::new("OpenAI's console", "https://platform.openai.com/api-keys")) + ) + .child( + ListBulletItem::new("Ensure your OpenAI account has credits") + ) + .child( + ListBulletItem::new("Paste your API key below and hit enter to start using the agent") + ), ) .child(self.api_key_editor.clone()) .child( diff --git a/crates/language_models/src/provider/open_ai_compatible.rs b/crates/language_models/src/provider/open_ai_compatible.rs index a30c8bfa5d3a728d6dd388f8e768cd470ee9736d..e6e7a9984da3d48b9e3c0f9571b8e916359fba03 100644 --- a/crates/language_models/src/provider/open_ai_compatible.rs +++ b/crates/language_models/src/provider/open_ai_compatible.rs @@ -4,10 +4,10 @@ use futures::{FutureExt, StreamExt, future, future::BoxFuture}; use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window}; use http_client::HttpClient; use language_model::{ - AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, - LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, - LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, - LanguageModelToolChoice, LanguageModelToolSchemaFormat, RateLimiter, + ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError, + LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, + LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, + LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolSchemaFormat, RateLimiter, }; use menu; use open_ai::{ResponseStreamEvent, stream_completion}; @@ -16,9 +16,7 @@ use std::sync::Arc; use ui::{ElevationIndex, Tooltip, prelude::*}; use ui_input::InputField; use util::ResultExt; -use zed_env_vars::EnvVar; -use crate::api_key::ApiKeyState; use crate::provider::open_ai::{OpenAiEventMapper, into_open_ai}; pub use settings::OpenAiCompatibleAvailableModel as AvailableModel; pub use settings::OpenAiCompatibleModelCapabilities as ModelCapabilities; @@ -38,7 +36,6 @@ pub struct OpenAiCompatibleLanguageModelProvider { pub struct State { id: Arc, - api_key_env_var: EnvVar, api_key_state: ApiKeyState, settings: OpenAiCompatibleSettings, } @@ -56,12 +53,8 @@ impl State { fn authenticate(&mut self, cx: &mut Context) -> Task> { let api_url = SharedString::new(self.settings.api_url.clone()); - self.api_key_state.load_if_needed( - api_url, - &self.api_key_env_var, - |this| &mut this.api_key_state, - cx, - ) + self.api_key_state + .load_if_needed(api_url, |this| &mut this.api_key_state, cx) } } @@ -83,7 +76,6 @@ impl OpenAiCompatibleLanguageModelProvider { let api_url = SharedString::new(settings.api_url.as_str()); this.api_key_state.handle_url_change( api_url, - &this.api_key_env_var, |this| &mut this.api_key_state, cx, ); @@ -95,8 +87,10 @@ impl OpenAiCompatibleLanguageModelProvider { let settings = resolve_settings(&id, cx).cloned().unwrap_or_default(); State { id: id.clone(), - api_key_env_var: EnvVar::new(api_key_env_var_name), - api_key_state: ApiKeyState::new(SharedString::new(settings.api_url.as_str())), + api_key_state: ApiKeyState::new( + SharedString::new(settings.api_url.as_str()), + EnvVar::new(api_key_env_var_name), + ), settings, } }); @@ -437,7 +431,7 @@ impl Render for ConfigurationView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let state = self.state.read(cx); let env_var_set = state.api_key_state.is_from_env_var(); - let env_var_name = &state.api_key_env_var.name; + let env_var_name = state.api_key_state.env_var_name(); let api_key_section = if self.should_render_editor(cx) { v_flex() diff --git a/crates/language_models/src/provider/open_router.rs b/crates/language_models/src/provider/open_router.rs index 7b10ebf963033603ede691fa72d2fa523bcdbab9..ad2e90d9dd5f4ece7e2582a867da50f6962c981c 100644 --- a/crates/language_models/src/provider/open_router.rs +++ b/crates/language_models/src/provider/open_router.rs @@ -4,11 +4,12 @@ use futures::{FutureExt, Stream, StreamExt, future, future::BoxFuture}; use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task}; use http_client::HttpClient; use language_model::{ - AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, - LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, - LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, - LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolSchemaFormat, - LanguageModelToolUse, MessageContent, RateLimiter, Role, StopReason, TokenUsage, + ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError, + LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, + LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, + LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent, + LanguageModelToolSchemaFormat, LanguageModelToolUse, MessageContent, RateLimiter, Role, + StopReason, TokenUsage, env_var, }; use open_router::{ Model, ModelMode as OpenRouterModelMode, OPEN_ROUTER_API_URL, ResponseStreamEvent, list_models, @@ -17,13 +18,9 @@ use settings::{OpenRouterAvailableModel as AvailableModel, Settings, SettingsSto use std::pin::Pin; use std::str::FromStr as _; use std::sync::{Arc, LazyLock}; -use ui::{List, prelude::*}; +use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; use ui_input::InputField; use util::ResultExt; -use zed_env_vars::{EnvVar, env_var}; - -use crate::ui::ConfiguredApiCard; -use crate::{api_key::ApiKeyState, ui::InstructionListItem}; const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("openrouter"); const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("OpenRouter"); @@ -62,12 +59,9 @@ impl State { fn authenticate(&mut self, cx: &mut Context) -> Task> { let api_url = OpenRouterLanguageModelProvider::api_url(cx); - let task = self.api_key_state.load_if_needed( - api_url, - &API_KEY_ENV_VAR, - |this| &mut this.api_key_state, - cx, - ); + let task = self + .api_key_state + .load_if_needed(api_url, |this| &mut this.api_key_state, cx); cx.spawn(async move |this, cx| { let result = task.await; @@ -135,7 +129,7 @@ impl OpenRouterLanguageModelProvider { }) .detach(); State { - api_key_state: ApiKeyState::new(Self::api_url(cx)), + api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()), http_client: http_client.clone(), available_models: Vec::new(), fetch_models_task: None, @@ -830,17 +824,15 @@ impl Render for ConfigurationView { .child(Label::new("To use Zed's agent with OpenRouter, you need to add an API key. Follow these steps:")) .child( List::new() - .child(InstructionListItem::new( - "Create an API key by visiting", - Some("OpenRouter's console"), - Some("https://openrouter.ai/keys"), - )) - .child(InstructionListItem::text_only( - "Ensure your OpenRouter account has credits", - )) - .child(InstructionListItem::text_only( - "Paste your API key below and hit enter to start using the assistant", - )), + .child( + ListBulletItem::new("") + .child(Label::new("Create an API key by visiting")) + .child(ButtonLink::new("OpenRouter's console", "https://openrouter.ai/keys")) + ) + .child(ListBulletItem::new("Ensure your OpenRouter account has credits") + ) + .child(ListBulletItem::new("Paste your API key below and hit enter to start using the assistant") + ), ) .child(self.api_key_editor.clone()) .child( diff --git a/crates/language_models/src/provider/vercel.rs b/crates/language_models/src/provider/vercel.rs index 061dc1799922c03952b1a96e2785425f61bcf00b..4dfe848df80123dc4c37d27b81f76db359e076f9 100644 --- a/crates/language_models/src/provider/vercel.rs +++ b/crates/language_models/src/provider/vercel.rs @@ -4,26 +4,20 @@ use futures::{FutureExt, StreamExt, future, future::BoxFuture}; use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window}; use http_client::HttpClient; use language_model::{ - AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, - LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, - LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, - LanguageModelToolChoice, RateLimiter, Role, + ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError, + LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, + LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, + LanguageModelRequest, LanguageModelToolChoice, RateLimiter, Role, env_var, }; use open_ai::ResponseStreamEvent; pub use settings::VercelAvailableModel as AvailableModel; use settings::{Settings, SettingsStore}; use std::sync::{Arc, LazyLock}; use strum::IntoEnumIterator; -use ui::{List, prelude::*}; +use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; use ui_input::InputField; use util::ResultExt; use vercel::{Model, VERCEL_API_URL}; -use zed_env_vars::{EnvVar, env_var}; - -use crate::{ - api_key::ApiKeyState, - ui::{ConfiguredApiCard, InstructionListItem}, -}; const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("vercel"); const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("Vercel"); @@ -59,12 +53,8 @@ impl State { fn authenticate(&mut self, cx: &mut Context) -> Task> { let api_url = VercelLanguageModelProvider::api_url(cx); - self.api_key_state.load_if_needed( - api_url, - &API_KEY_ENV_VAR, - |this| &mut this.api_key_state, - cx, - ) + self.api_key_state + .load_if_needed(api_url, |this| &mut this.api_key_state, cx) } } @@ -73,17 +63,13 @@ impl VercelLanguageModelProvider { let state = cx.new(|cx| { cx.observe_global::(|this: &mut State, cx| { let api_url = Self::api_url(cx); - this.api_key_state.handle_url_change( - api_url, - &API_KEY_ENV_VAR, - |this| &mut this.api_key_state, - cx, - ); + this.api_key_state + .handle_url_change(api_url, |this| &mut this.api_key_state, cx); cx.notify(); }) .detach(); State { - api_key_state: ApiKeyState::new(Self::api_url(cx)), + api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()), } }); @@ -472,14 +458,14 @@ impl Render for ConfigurationView { .child(Label::new("To use Zed's agent with Vercel v0, you need to add an API key. Follow these steps:")) .child( List::new() - .child(InstructionListItem::new( - "Create one by visiting", - Some("Vercel v0's console"), - Some("https://v0.dev/chat/settings/keys"), - )) - .child(InstructionListItem::text_only( - "Paste your API key below and hit enter to start using the agent", - )), + .child( + ListBulletItem::new("") + .child(Label::new("Create one by visiting")) + .child(ButtonLink::new("Vercel v0's console", "https://v0.dev/chat/settings/keys")) + ) + .child( + ListBulletItem::new("Paste your API key below and hit enter to start using the agent") + ), ) .child(self.api_key_editor.clone()) .child( diff --git a/crates/language_models/src/provider/x_ai.rs b/crates/language_models/src/provider/x_ai.rs index cc54dfa0dd8a3f2ca6ab2b769a779afa8e73988b..19c50d71cf4e483b68d48c8b982a975f3091ff46 100644 --- a/crates/language_models/src/provider/x_ai.rs +++ b/crates/language_models/src/provider/x_ai.rs @@ -4,26 +4,21 @@ use futures::{FutureExt, StreamExt, future, future::BoxFuture}; use gpui::{AnyView, App, AsyncApp, Context, Entity, Task, Window}; use http_client::HttpClient; use language_model::{ - AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, - LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, - LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, - LanguageModelToolChoice, LanguageModelToolSchemaFormat, RateLimiter, Role, + ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError, + LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, + LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, + LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolSchemaFormat, RateLimiter, + Role, env_var, }; use open_ai::ResponseStreamEvent; pub use settings::XaiAvailableModel as AvailableModel; use settings::{Settings, SettingsStore}; use std::sync::{Arc, LazyLock}; use strum::IntoEnumIterator; -use ui::{List, prelude::*}; +use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; use ui_input::InputField; use util::ResultExt; use x_ai::{Model, XAI_API_URL}; -use zed_env_vars::{EnvVar, env_var}; - -use crate::{ - api_key::ApiKeyState, - ui::{ConfiguredApiCard, InstructionListItem}, -}; const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("x_ai"); const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("xAI"); @@ -59,12 +54,8 @@ impl State { fn authenticate(&mut self, cx: &mut Context) -> Task> { let api_url = XAiLanguageModelProvider::api_url(cx); - self.api_key_state.load_if_needed( - api_url, - &API_KEY_ENV_VAR, - |this| &mut this.api_key_state, - cx, - ) + self.api_key_state + .load_if_needed(api_url, |this| &mut this.api_key_state, cx) } } @@ -73,17 +64,13 @@ impl XAiLanguageModelProvider { let state = cx.new(|cx| { cx.observe_global::(|this: &mut State, cx| { let api_url = Self::api_url(cx); - this.api_key_state.handle_url_change( - api_url, - &API_KEY_ENV_VAR, - |this| &mut this.api_key_state, - cx, - ); + this.api_key_state + .handle_url_change(api_url, |this| &mut this.api_key_state, cx); cx.notify(); }) .detach(); State { - api_key_state: ApiKeyState::new(Self::api_url(cx)), + api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()), } }); @@ -474,14 +461,14 @@ impl Render for ConfigurationView { .child(Label::new("To use Zed's agent with xAI, you need to add an API key. Follow these steps:")) .child( List::new() - .child(InstructionListItem::new( - "Create one by visiting", - Some("xAI console"), - Some("https://console.x.ai/team/default/api-keys"), - )) - .child(InstructionListItem::text_only( - "Paste your API key below and hit enter to start using the agent", - )), + .child( + ListBulletItem::new("") + .child(Label::new("Create one by visiting")) + .child(ButtonLink::new("xAI console", "https://console.x.ai/team/default/api-keys")) + ) + .child( + ListBulletItem::new("Paste your API key below and hit enter to start using the agent") + ), ) .child(self.api_key_editor.clone()) .child( diff --git a/crates/language_models/src/settings.rs b/crates/language_models/src/settings.rs index 43a8e7334a744c84d6edfae3ffc97115eb8f51b2..62f0025c755e10ea1bdae605d9dcc752298bb5f1 100644 --- a/crates/language_models/src/settings.rs +++ b/crates/language_models/src/settings.rs @@ -78,6 +78,7 @@ impl settings::Settings for AllLanguageModelSettings { }, ollama: OllamaSettings { api_url: ollama.api_url.unwrap(), + auto_discover: ollama.auto_discover.unwrap_or(true), available_models: ollama.available_models.unwrap_or_default(), }, open_router: OpenRouterSettings { diff --git a/crates/language_models/src/ui.rs b/crates/language_models/src/ui.rs deleted file mode 100644 index 1d7796ecc2b6c2a78b3ebc02dc9cd29bd8cfa2c6..0000000000000000000000000000000000000000 --- a/crates/language_models/src/ui.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod configured_api_card; -pub mod instruction_list_item; -pub use configured_api_card::ConfiguredApiCard; -pub use instruction_list_item::InstructionListItem; diff --git a/crates/language_models/src/ui/instruction_list_item.rs b/crates/language_models/src/ui/instruction_list_item.rs deleted file mode 100644 index bdb5fbe242ee902dc98a37addfaa0f103ef9ad20..0000000000000000000000000000000000000000 --- a/crates/language_models/src/ui/instruction_list_item.rs +++ /dev/null @@ -1,69 +0,0 @@ -use gpui::{AnyElement, IntoElement, ParentElement, SharedString}; -use ui::{ListItem, prelude::*}; - -/// A reusable list item component for adding LLM provider configuration instructions -pub struct InstructionListItem { - label: SharedString, - button_label: Option, - button_link: Option, -} - -impl InstructionListItem { - pub fn new( - label: impl Into, - button_label: Option>, - button_link: Option>, - ) -> Self { - Self { - label: label.into(), - button_label: button_label.map(|l| l.into()), - button_link: button_link.map(|l| l.into()), - } - } - - pub fn text_only(label: impl Into) -> Self { - Self { - label: label.into(), - button_label: None, - button_link: None, - } - } -} - -impl IntoElement for InstructionListItem { - type Element = AnyElement; - - fn into_element(self) -> Self::Element { - let item_content = if let (Some(button_label), Some(button_link)) = - (self.button_label, self.button_link) - { - let link = button_link; - let unique_id = SharedString::from(format!("{}-button", self.label)); - - h_flex() - .flex_wrap() - .child(Label::new(self.label)) - .child( - Button::new(unique_id, button_label) - .style(ButtonStyle::Subtle) - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .on_click(move |_, _window, cx| cx.open_url(&link)), - ) - .into_any_element() - } else { - Label::new(self.label).into_any_element() - }; - - ListItem::new("list-item") - .selectable(false) - .start_slot( - Icon::new(IconName::Dash) - .size(IconSize::XSmall) - .color(Color::Hidden), - ) - .child(div().w_full().child(item_content)) - .into_any_element() - } -} diff --git a/crates/language_tools/src/lsp_log_view.rs b/crates/language_tools/src/lsp_log_view.rs index 314dcc0b9bde998a0fec65b2847ae13641f0d011..e34bbb46d35d5a524c08369fcc991dfe81865127 100644 --- a/crates/language_tools/src/lsp_log_view.rs +++ b/crates/language_tools/src/lsp_log_view.rs @@ -125,7 +125,7 @@ pub fn init(on_headless_host: bool, cx: &mut App) { let server_id = server.server_id(); let weak_lsp_store = cx.weak_entity(); log_store.copilot_log_subscription = - Some(server.on_notification::( + Some(server.on_notification::( move |params, cx| { weak_lsp_store .update(cx, |lsp_store, cx| { @@ -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/c/injections.scm b/crates/languages/src/c/injections.scm index d7df76b118672e77e3e2a6eacb320aade84c05fa..447897340cc735ed77099b20fd6fc8c52ac19ec8 100644 --- a/crates/languages/src/c/injections.scm +++ b/crates/languages/src/c/injections.scm @@ -1,7 +1,6 @@ ((comment) @injection.content - (#match? @injection.content "^(///|//!|/\\*\\*|/\\*!)(.*)") - (#set! injection.language "doxygen") - (#set! injection.include-children)) + (#set! injection.language "comment") +) (preproc_def value: (preproc_arg) @injection.content diff --git a/crates/languages/src/cpp/brackets.scm b/crates/languages/src/cpp/brackets.scm index 2149bddc6c9a7ec04667d03da75580b676e12a28..9eaebba332861ef716902b3827d4940b71f37221 100644 --- a/crates/languages/src/cpp/brackets.scm +++ b/crates/languages/src/cpp/brackets.scm @@ -1,5 +1,6 @@ ("(" @open ")" @close) ("[" @open "]" @close) ("{" @open "}" @close) +("<" @open ">" @close) (("\"" @open "\"" @close) (#set! rainbow.exclude)) (("'" @open "'" @close) (#set! rainbow.exclude)) diff --git a/crates/languages/src/cpp/injections.scm b/crates/languages/src/cpp/injections.scm index a115a3bffdbe4c522b611f3786ffc95dcecc5cff..160770f3cc1d69f5cb3d1679c8a48726d8d437ed 100644 --- a/crates/languages/src/cpp/injections.scm +++ b/crates/languages/src/cpp/injections.scm @@ -1,7 +1,6 @@ ((comment) @injection.content - (#match? @injection.content "^(///|//!|/\\*\\*|/\\*!)(.*)") - (#set! injection.language "doxygen") - (#set! injection.include-children)) + (#set! injection.language "comment") +) (preproc_def value: (preproc_arg) @injection.content 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/eslint.rs b/crates/languages/src/eslint.rs index 4f18149265ceac23aadd93b02e7b7309291849fa..fd4133d7ebcafc2553e25c876eb9fb1c6257ebc1 100644 --- a/crates/languages/src/eslint.rs +++ b/crates/languages/src/eslint.rs @@ -126,11 +126,11 @@ impl LspInstaller for EsLintLspAdapter { } self.node - .run_npm_subcommand(&repo_root, "install", &[]) + .run_npm_subcommand(Some(&repo_root), "install", &[]) .await?; self.node - .run_npm_subcommand(&repo_root, "run-script", &["compile"]) + .run_npm_subcommand(Some(&repo_root), "run-script", &["compile"]) .await?; } diff --git a/crates/languages/src/go.rs b/crates/languages/src/go.rs index a8699fe9c2dc8cf99ca46a16fe75b1de6eea7ffa..130e142076b8c6ec0393e4f0d617c3a522b2ef22 100644 --- a/crates/languages/src/go.rs +++ b/crates/languages/src/go.rs @@ -73,7 +73,9 @@ impl LspInstaller for GoLspAdapter { delegate.show_notification(NOTIFICATION_MESSAGE, cx); })? } - anyhow::bail!("cannot install gopls"); + anyhow::bail!( + "Could not install the Go language server `gopls`, because `go` was not found." + ); } let release = diff --git a/crates/languages/src/javascript/highlights.scm b/crates/languages/src/javascript/highlights.scm index e5b84ab68df2b32061691f469046569a6597750e..d13db50e2ef85e25bdc5643672eb128265c58d91 100644 --- a/crates/languages/src/javascript/highlights.scm +++ b/crates/languages/src/javascript/highlights.scm @@ -2,6 +2,40 @@ (identifier) @variable +(call_expression + function: (member_expression + object: (identifier) @type.builtin + (#any-of? + @type.builtin + "Promise" + "Array" + "Object" + "Map" + "Set" + "WeakMap" + "WeakSet" + "Date" + "Error" + "TypeError" + "RangeError" + "SyntaxError" + "ReferenceError" + "EvalError" + "URIError" + "RegExp" + "Function" + "Number" + "String" + "Boolean" + "Symbol" + "BigInt" + "Proxy" + "ArrayBuffer" + "DataView" + ) + ) +) + ; Properties (property_identifier) @property @@ -18,6 +52,12 @@ function: (member_expression property: [(property_identifier) (private_property_identifier)] @function.method)) +(new_expression + constructor: (identifier) @type) + +(nested_type_identifier + module: (identifier) @type) + ; Function and method definitions (function_expression @@ -47,10 +87,45 @@ left: (identifier) @function right: [(function_expression) (arrow_function)]) +; Parameters + +(required_parameter + (identifier) @variable.parameter) + +(required_parameter + (_ + ([ + (identifier) + (shorthand_property_identifier_pattern) + ]) @variable.parameter)) + +(optional_parameter + (identifier) @variable.parameter) + +(optional_parameter + (_ + ([ + (identifier) + (shorthand_property_identifier_pattern) + ]) @variable.parameter)) + +(catch_clause + parameter: (identifier) @variable.parameter) + +(index_signature + name: (identifier) @variable.parameter) + +(arrow_function + parameter: (identifier) @variable.parameter) + ; Special identifiers +; +(class_declaration + (type_identifier) @type.class) + +(extends_clause + value: (identifier) @type.class) -((identifier) @type - (#match? @type "^[A-Z]")) (type_identifier) @type (predefined_type) @type.builtin @@ -251,6 +326,34 @@ (jsx_closing_element (identifier) @tag.jsx (#match? @tag.jsx "^[a-z][^.]*$")) (jsx_self_closing_element (identifier) @tag.jsx (#match? @tag.jsx "^[a-z][^.]*$")) +(jsx_opening_element + [ + (identifier) @type + (member_expression + object: (identifier) @type + property: (property_identifier) @type + ) + ] +) +(jsx_closing_element + [ + (identifier) @type + (member_expression + object: (identifier) @type + property: (property_identifier) @type + ) + ] +) +(jsx_self_closing_element + [ + (identifier) @type + (member_expression + object: (identifier) @type + property: (property_identifier) @type + ) + ] +) + (jsx_attribute (property_identifier) @attribute.jsx) (jsx_opening_element (["<" ">"]) @punctuation.bracket.jsx) (jsx_closing_element ([""]) @punctuation.bracket.jsx) diff --git a/crates/languages/src/javascript/injections.scm b/crates/languages/src/javascript/injections.scm index f79cd788d78964f61f611023d0645c95c88aaf17..244e025a6f5d62f1d3500fc35fc480b1baa2471e 100644 --- a/crates/languages/src/javascript/injections.scm +++ b/crates/languages/src/javascript/injections.scm @@ -83,3 +83,46 @@ arguments: (arguments (template_string (string_fragment) @injection.content (#set! injection.language "isograph"))) ) + +; Parse the contents of strings and tagged template +; literals with leading ECMAScript comments: +; '/* html */' or '/*html*/' +( + ((comment) @_ecma_comment [ + (string (string_fragment) @injection.content) + (template_string (string_fragment) @injection.content) + ]) + (#match? @_ecma_comment "^\\/\\*\\s*html\\s*\\*\\/") + (#set! injection.language "html") +) + +; '/* sql */' or '/*sql*/' +( + ((comment) @_ecma_comment [ + (string (string_fragment) @injection.content) + (template_string (string_fragment) @injection.content) + ]) + (#match? @_ecma_comment "^\\/\\*\\s*sql\\s*\\*\\/") + (#set! injection.language "sql") +) + +; '/* gql */' or '/*gql*/' +; '/* graphql */' or '/*graphql*/' +( + ((comment) @_ecma_comment [ + (string (string_fragment) @injection.content) + (template_string (string_fragment) @injection.content) + ]) + (#match? @_ecma_comment "^\\/\\*\\s*(gql|graphql)\\s*\\*\\/") + (#set! injection.language "graphql") +) + +; '/* css */' or '/*css*/' +( + ((comment) @_ecma_comment [ + (string (string_fragment) @injection.content) + (template_string (string_fragment) @injection.content) + ]) + (#match? @_ecma_comment "^\\/\\*\\s*(css)\\s*\\*\\/") + (#set! injection.language "css") +) diff --git a/crates/languages/src/jsdoc/highlights.scm b/crates/languages/src/jsdoc/highlights.scm index 103d32d0bd29dae56bd456893288e86a8cf87148..581b5d8111fe25443de9951cfdddc8c277ad83ff 100644 --- a/crates/languages/src/jsdoc/highlights.scm +++ b/crates/languages/src/jsdoc/highlights.scm @@ -1,2 +1,3 @@ (tag_name) @keyword.jsdoc (type) @type.jsdoc +(identifier) @variable.jsdoc 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/markdown/config.toml b/crates/languages/src/markdown/config.toml index f786c61b8155b0fa4c93b43e2126cc66d86e22e4..84c79d2538a0af470ec16d55fe9cf2d1ae05805b 100644 --- a/crates/languages/src/markdown/config.toml +++ b/crates/languages/src/markdown/config.toml @@ -24,5 +24,9 @@ rewrap_prefixes = [ auto_indent_on_paste = false auto_indent_using_last_non_empty_line = false tab_size = 2 -decrease_indent_pattern = "^.*$" +decrease_indent_patterns = [ + { pattern = "^\\s*-", valid_after = ["list_item"] }, + { pattern = "^\\s*\\d", valid_after = ["list_item"] }, + { pattern = "^\\s*", valid_after = ["list_item"] }, +] prettier_parser_name = "markdown" diff --git a/crates/languages/src/markdown/indents.scm b/crates/languages/src/markdown/indents.scm index 2840e8b4611c05ef4c88e775c970eb79b9b99f4a..dc6dfa6118309c264e146a5af167327947fc6946 100644 --- a/crates/languages/src/markdown/indents.scm +++ b/crates/languages/src/markdown/indents.scm @@ -1 +1,3 @@ (list (list_item) @indent) + +(list_item) @start.list_item diff --git a/crates/languages/src/python.rs b/crates/languages/src/python.rs index db914ad2d062dcce77bb12c9b777da3db9a7750f..a06b1efe649b93ef56a35c40bd0d35cd1bc7ca9c 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 @@ -294,6 +295,23 @@ impl LspInstaller for TyLspAdapter { }) } + async fn check_if_user_installed( + &self, + delegate: &dyn LspAdapterDelegate, + _: Option, + _: &AsyncApp, + ) -> Option { + let Some(ty_bin) = delegate.which(Self::SERVER_NAME.as_ref()).await else { + return None; + }; + let env = delegate.shell_env().await; + Some(LanguageServerBinary { + path: ty_bin, + env: Some(env), + arguments: vec!["server".into()], + }) + } + async fn fetch_server_binary( &self, latest_version: Self::BinaryVersion, @@ -621,14 +639,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 +690,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( @@ -1131,6 +1150,18 @@ fn wr_distance( } } +fn micromamba_shell_name(kind: ShellKind) -> &'static str { + match kind { + ShellKind::Csh => "csh", + ShellKind::Fish => "fish", + ShellKind::Nushell => "nu", + ShellKind::PowerShell => "powershell", + ShellKind::Cmd => "cmd.exe", + // default / catch-all: + _ => "posix", + } +} + #[async_trait] impl ToolchainLister for PythonToolchainProvider { async fn list( @@ -1297,24 +1328,28 @@ impl ToolchainLister for PythonToolchainProvider { .as_option() .map(|venv| venv.conda_manager) .unwrap_or(settings::CondaManager::Auto); - let manager = match conda_manager { settings::CondaManager::Conda => "conda", settings::CondaManager::Mamba => "mamba", settings::CondaManager::Micromamba => "micromamba", - settings::CondaManager::Auto => { - // When auto, prefer the detected manager or fall back to conda - toolchain - .environment - .manager - .as_ref() - .and_then(|m| m.executable.file_name()) - .and_then(|name| name.to_str()) - .filter(|name| matches!(*name, "conda" | "mamba" | "micromamba")) - .unwrap_or("conda") - } + settings::CondaManager::Auto => toolchain + .environment + .manager + .as_ref() + .and_then(|m| m.executable.file_name()) + .and_then(|name| name.to_str()) + .filter(|name| matches!(*name, "conda" | "mamba" | "micromamba")) + .unwrap_or("conda"), }; + // Activate micromamba shell in the child shell + // [required for micromamba] + if manager == "micromamba" { + let shell = micromamba_shell_name(shell); + activation_script + .push(format!(r#"eval "$({manager} shell hook --shell {shell})""#)); + } + if let Some(name) = &toolchain.environment.name { activation_script.push(format!("{manager} activate {name}")); } else { @@ -1344,7 +1379,7 @@ impl ToolchainLister for PythonToolchainProvider { ShellKind::Fish => Some(format!("\"{pyenv}\" shell - fish {version}")), ShellKind::Posix => Some(format!("\"{pyenv}\" shell - sh {version}")), ShellKind::Nushell => Some(format!("^\"{pyenv}\" shell - nu {version}")), - ShellKind::PowerShell => None, + ShellKind::PowerShell | ShellKind::Pwsh => None, ShellKind::Csh => None, ShellKind::Tcsh => None, ShellKind::Cmd => None, @@ -2024,14 +2059,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 @@ -2076,6 +2111,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/python/injections.scm b/crates/languages/src/python/injections.scm index 9117c713b98fdd2896b13e4949a77c6489b9ee36..d8470140e999f3dc649c0a498987cfae7df6bf59 100644 --- a/crates/languages/src/python/injections.scm +++ b/crates/languages/src/python/injections.scm @@ -1,3 +1,34 @@ ((comment) @injection.content (#set! injection.language "comment") ) + +; SQL ----------------------------------------------------------------------------- +( + [ + ; function calls + (call + [ + (attribute attribute: (identifier) @function_name) + (identifier) @function_name + ] + arguments: (argument_list + (comment) @comment + (string + (string_content) @injection.content + ) + )) + + ; string variables + ((comment) @comment + . + (expression_statement + (assignment + right: (string + (string_content) @injection.content + ) + ) + )) + ] + (#match? @comment "^(#|#\\s+)(?i:sql)\\s*$") + (#set! injection.language "sql") +) diff --git a/crates/languages/src/rust.rs b/crates/languages/src/rust.rs index 31d7448285969fbce005b9b7134f56c7d8362f73..80bc48908b0894f251d6631b67cb4a19658454bd 100644 --- a/crates/languages/src/rust.rs +++ b/crates/languages/src/rust.rs @@ -355,7 +355,7 @@ impl LspAdapter for RustLspAdapter { | lsp::CompletionTextEdit::Edit(lsp::TextEdit { new_text, .. }), ) = completion.text_edit.as_ref() && let Ok(mut snippet) = snippet::Snippet::parse(new_text) - && !snippet.tabstops.is_empty() + && snippet.tabstops.len() > 1 { label = String::new(); @@ -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..]); @@ -417,7 +421,9 @@ impl LspAdapter for RustLspAdapter { 0..label.rfind('(').unwrap_or(completion.label.len()), highlight_id, )); - } else if detail_left.is_none() { + } else if detail_left.is_none() + && kind != Some(lsp::CompletionItemKind::SNIPPET) + { return None; } } @@ -882,7 +888,7 @@ impl ContextProvider for RustContextProvider { RUST_BIN_REQUIRED_FEATURES_FLAG_TASK_VARIABLE.template_value(), RUST_BIN_REQUIRED_FEATURES_TASK_VARIABLE.template_value(), ], - cwd: Some("$ZED_DIRNAME".to_owned()), + cwd: Some(RUST_MANIFEST_DIRNAME_TASK_VARIABLE.template_value()), tags: vec!["rust-main".to_owned()], ..TaskTemplate::default() }, @@ -904,14 +910,14 @@ impl ContextProvider for RustContextProvider { label: "Run".into(), command: "cargo".into(), args: run_task_args, - cwd: Some("$ZED_DIRNAME".to_owned()), + cwd: Some(RUST_MANIFEST_DIRNAME_TASK_VARIABLE.template_value()), ..TaskTemplate::default() }, TaskTemplate { label: "Clean".into(), command: "cargo".into(), args: vec!["clean".into()], - cwd: Some("$ZED_DIRNAME".to_owned()), + cwd: Some(RUST_MANIFEST_DIRNAME_TASK_VARIABLE.template_value()), ..TaskTemplate::default() }, ]; @@ -1126,9 +1132,11 @@ fn package_name_from_pkgid(pkgid: &str) -> Option<&str> { } async fn get_cached_server_binary(container_dir: PathBuf) -> Option { - maybe!(async { + let binary_result = maybe!(async { let mut last = None; - let mut entries = fs::read_dir(&container_dir).await?; + let mut entries = fs::read_dir(&container_dir) + .await + .with_context(|| format!("listing {container_dir:?}"))?; while let Some(entry) = entries.next().await { let path = entry?.path(); if path.extension().is_some_and(|ext| ext == "metadata") { @@ -1137,20 +1145,34 @@ async fn get_cached_server_binary(container_dir: PathBuf) -> Option last, + None => return Ok(None), + }; let path = match RustLspAdapter::GITHUB_ASSET_KIND { AssetKind::TarGz | AssetKind::Gz => path, // Tar and gzip extract in place. AssetKind::Zip => path.join("rust-analyzer.exe"), // zip contains a .exe }; - anyhow::Ok(LanguageServerBinary { + anyhow::Ok(Some(LanguageServerBinary { path, env: None, - arguments: Default::default(), - }) + arguments: Vec::new(), + })) }) - .await - .log_err() + .await; + + match binary_result { + Ok(Some(binary)) => Some(binary), + Ok(None) => { + log::info!("No cached rust-analyzer binary found"); + None + } + Err(e) => { + log::error!("Failed to look up cached rust-analyzer binary: {e:#}"); + None + } + } } fn test_fragment(variables: &TaskVariables, path: &Path, stem: &str) -> String { @@ -1576,6 +1598,78 @@ mod tests { ], )) ); + + // Postfix completion without actual tabstops (only implicit final $0) + // The label should use completion.label so it can be filtered by "ref" + let ref_completion = adapter + .label_for_completion( + &lsp::CompletionItem { + kind: Some(lsp::CompletionItemKind::SNIPPET), + label: "ref".to_string(), + filter_text: Some("ref".to_string()), + label_details: Some(CompletionItemLabelDetails { + detail: None, + description: Some("&expr".to_string()), + }), + detail: Some("&expr".to_string()), + insert_text_format: Some(lsp::InsertTextFormat::SNIPPET), + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: lsp::Range::default(), + new_text: "&String::new()".to_string(), + })), + ..Default::default() + }, + &language, + ) + .await; + assert!( + ref_completion.is_some(), + "ref postfix completion should have a label" + ); + let ref_label = ref_completion.unwrap(); + let filter_text = &ref_label.text[ref_label.filter_range.clone()]; + assert!( + filter_text.contains("ref"), + "filter range text '{filter_text}' should contain 'ref' for filtering to work", + ); + + // 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/tsx/highlights.scm b/crates/languages/src/tsx/highlights.scm index ef12b3d7913e07109e32bb5bf41909511aa2b555..83ed6d18d74f3820e27452ec093f8c6268b64e11 100644 --- a/crates/languages/src/tsx/highlights.scm +++ b/crates/languages/src/tsx/highlights.scm @@ -2,6 +2,40 @@ (identifier) @variable +(call_expression + function: (member_expression + object: (identifier) @type.builtin + (#any-of? + @type.builtin + "Promise" + "Array" + "Object" + "Map" + "Set" + "WeakMap" + "WeakSet" + "Date" + "Error" + "TypeError" + "RangeError" + "SyntaxError" + "ReferenceError" + "EvalError" + "URIError" + "RegExp" + "Function" + "Number" + "String" + "Boolean" + "Symbol" + "BigInt" + "Proxy" + "ArrayBuffer" + "DataView" + ) + ) +) + ; Properties (property_identifier) @property @@ -18,6 +52,12 @@ function: (member_expression property: [(property_identifier) (private_property_identifier)] @function.method)) +(new_expression + constructor: (identifier) @type) + +(nested_type_identifier + module: (identifier) @type) + ; Function and method definitions (function_expression @@ -47,13 +87,68 @@ left: (identifier) @function right: [(function_expression) (arrow_function)]) +; Parameters + +(required_parameter + (identifier) @variable.parameter) + +(required_parameter + (_ + ([ + (identifier) + (shorthand_property_identifier_pattern) + ]) @variable.parameter)) + +(optional_parameter + (identifier) @variable.parameter) + +(optional_parameter + (_ + ([ + (identifier) + (shorthand_property_identifier_pattern) + ]) @variable.parameter)) + +(catch_clause + parameter: (identifier) @variable.parameter) + +(index_signature + name: (identifier) @variable.parameter) + +(arrow_function + parameter: (identifier) @variable.parameter) + +(type_predicate + name: (identifier) @variable.parameter) + ; Special identifiers -((identifier) @type - (#match? @type "^[A-Z]")) +(type_annotation) @type (type_identifier) @type (predefined_type) @type.builtin +(type_alias_declaration + (type_identifier) @type) + +(type_alias_declaration + value: (_ + (type_identifier) @type)) + +(interface_declaration + (type_identifier) @type) + +(class_declaration + (type_identifier) @type.class) + +(extends_clause + value: (identifier) @type.class) + +(extends_type_clause + type: (type_identifier) @type) + +(implements_clause + (type_identifier) @type) + ([ (identifier) (shorthand_property_identifier) @@ -231,8 +326,42 @@ "<" @punctuation.bracket ">" @punctuation.bracket) +(type_parameters + "<" @punctuation.bracket + ">" @punctuation.bracket) + (decorator "@" @punctuation.special) +(union_type + ("|") @punctuation.special) + +(intersection_type + ("&") @punctuation.special) + +(type_annotation + (":") @punctuation.special) + +(index_signature + (":") @punctuation.special) + +(type_predicate_annotation + (":") @punctuation.special) + +(public_field_definition + ("?") @punctuation.special) + +(property_signature + ("?") @punctuation.special) + +(method_signature + ("?") @punctuation.special) + +(optional_parameter + ([ + "?" + ":" + ]) @punctuation.special) + ; Keywords [ "abstract" @@ -257,6 +386,34 @@ (jsx_closing_element (identifier) @tag.jsx (#match? @tag.jsx "^[a-z][^.]*$")) (jsx_self_closing_element (identifier) @tag.jsx (#match? @tag.jsx "^[a-z][^.]*$")) +(jsx_opening_element + [ + (identifier) @type + (member_expression + object: (identifier) @type + property: (property_identifier) @type + ) + ] +) +(jsx_closing_element + [ + (identifier) @type + (member_expression + object: (identifier) @type + property: (property_identifier) @type + ) + ] +) +(jsx_self_closing_element + [ + (identifier) @type + (member_expression + object: (identifier) @type + property: (property_identifier) @type + ) + ] +) + (jsx_attribute (property_identifier) @attribute.jsx) (jsx_opening_element (["<" ">"]) @punctuation.bracket.jsx) (jsx_closing_element ([""]) @punctuation.bracket.jsx) diff --git a/crates/languages/src/tsx/injections.scm b/crates/languages/src/tsx/injections.scm index 3cca9e8e81c31d3565554595456fa62be89bc81f..2cf3ea69ca2fd95402eba6fadb85f3505c5562b7 100644 --- a/crates/languages/src/tsx/injections.scm +++ b/crates/languages/src/tsx/injections.scm @@ -83,3 +83,46 @@ arguments: (arguments (template_string (string_fragment) @injection.content (#set! injection.language "isograph"))) ) + +; Parse the contents of strings and tagged template +; literals with leading ECMAScript comments: +; '/* html */' or '/*html*/' +( + ((comment) @_ecma_comment [ + (string (string_fragment) @injection.content) + (template_string (string_fragment) @injection.content) + ]) + (#match? @_ecma_comment "^\\/\\*\\s*html\\s*\\*\\/") + (#set! injection.language "html") +) + +; '/* sql */' or '/*sql*/' +( + ((comment) @_ecma_comment [ + (string (string_fragment) @injection.content) + (template_string (string_fragment) @injection.content) + ]) + (#match? @_ecma_comment "^\\/\\*\\s*sql\\s*\\*\\/") + (#set! injection.language "sql") +) + +; '/* gql */' or '/*gql*/' +; '/* graphql */' or '/*graphql*/' +( + ((comment) @_ecma_comment [ + (string (string_fragment) @injection.content) + (template_string (string_fragment) @injection.content) + ]) + (#match? @_ecma_comment "^\\/\\*\\s*(gql|graphql)\\s*\\*\\/") + (#set! injection.language "graphql") +) + +; '/* css */' or '/*css*/' +( + ((comment) @_ecma_comment [ + (string (string_fragment) @injection.content) + (template_string (string_fragment) @injection.content) + ]) + (#match? @_ecma_comment "^\\/\\*\\s*(css)\\s*\\*\\/") + (#set! injection.language "css") +) diff --git a/crates/languages/src/typescript.rs b/crates/languages/src/typescript.rs index a7aa1bc49c0132b01d0fe45d94a29af4efac6602..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::{ @@ -111,8 +112,7 @@ impl PackageJsonData { "--".to_owned(), "vitest".to_owned(), "run".to_owned(), - "--poolOptions.forks.minForks=0".to_owned(), - "--poolOptions.forks.maxForks=1".to_owned(), + "--no-file-parallelism".to_owned(), VariableName::File.template_value(), ], cwd: Some(TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE.template_value()), @@ -130,8 +130,7 @@ impl PackageJsonData { "--".to_owned(), "vitest".to_owned(), "run".to_owned(), - "--poolOptions.forks.minForks=0".to_owned(), - "--poolOptions.forks.maxForks=1".to_owned(), + "--no-file-parallelism".to_owned(), "--testNamePattern".to_owned(), format!( "\"{}\"", @@ -637,8 +636,8 @@ impl TypeScriptLspAdapter { } pub struct TypeScriptVersions { - typescript_version: String, - server_version: String, + typescript_version: Version, + server_version: Version, } impl LspInstaller for TypeScriptLspAdapter { @@ -649,7 +648,7 @@ impl LspInstaller for TypeScriptLspAdapter { _: &dyn LspAdapterDelegate, _: bool, _: &mut AsyncApp, - ) -> Result { + ) -> Result { Ok(TypeScriptVersions { typescript_version: self .node @@ -664,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 { @@ -676,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 { @@ -689,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 { @@ -705,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 { @@ -717,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/typescript/highlights.scm b/crates/languages/src/typescript/highlights.scm index 5e8d55581e3ae86c85ca2b845e8a07caa6444c1d..f5736f4271f7048042b6e1634e78a10043952496 100644 --- a/crates/languages/src/typescript/highlights.scm +++ b/crates/languages/src/typescript/highlights.scm @@ -2,13 +2,69 @@ (identifier) @variable +(call_expression + function: (member_expression + object: (identifier) @type.builtin + (#any-of? + @type.builtin + "Promise" + "Array" + "Object" + "Map" + "Set" + "WeakMap" + "WeakSet" + "Date" + "Error" + "TypeError" + "RangeError" + "SyntaxError" + "ReferenceError" + "EvalError" + "URIError" + "RegExp" + "Function" + "Number" + "String" + "Boolean" + "Symbol" + "BigInt" + "Proxy" + "ArrayBuffer" + "DataView" + ) + ) +) + ; Special identifiers -((identifier) @type - (#match? @type "^[A-Z]")) +(type_annotation) @type + (type_identifier) @type (predefined_type) @type.builtin +(type_alias_declaration + (type_identifier) @type) + +(type_alias_declaration + value: (_ + (type_identifier) @type)) + +(interface_declaration + (type_identifier) @type) + +(class_declaration + (type_identifier) @type.class) + +(extends_clause + value: (identifier) @type.class) + +(extends_type_clause + type: (type_identifier) @type) + +(implements_clause + (type_identifier) @type) + ;; Enables ts-pretty-errors ;; The Lsp returns "snippets" of typescript, which are not valid typescript in totality, ;; but should still be highlighted @@ -83,6 +139,12 @@ function: (member_expression property: [(property_identifier) (private_property_identifier)] @function.method)) +(new_expression + constructor: (identifier) @type) + +(nested_type_identifier + module: (identifier) @type) + ; Function and method definitions (function_expression @@ -114,6 +176,40 @@ (arrow_function) @function +; Parameters + +(required_parameter + (identifier) @variable.parameter) + +(required_parameter + (_ + ([ + (identifier) + (shorthand_property_identifier_pattern) + ]) @variable.parameter)) + +(optional_parameter + (identifier) @variable.parameter) + +(optional_parameter + (_ + ([ + (identifier) + (shorthand_property_identifier_pattern) + ]) @variable.parameter)) + +(catch_clause + parameter: (identifier) @variable.parameter) + +(index_signature + name: (identifier) @variable.parameter) + +(arrow_function + parameter: (identifier) @variable.parameter) + +(type_predicate + name: (identifier) @variable.parameter) + ; Literals (this) @variable.special @@ -244,8 +340,42 @@ "<" @punctuation.bracket ">" @punctuation.bracket) +(type_parameters + "<" @punctuation.bracket + ">" @punctuation.bracket) + (decorator "@" @punctuation.special) +(union_type + ("|") @punctuation.special) + +(intersection_type + ("&") @punctuation.special) + +(type_annotation + (":") @punctuation.special) + +(index_signature + (":") @punctuation.special) + +(type_predicate_annotation + (":") @punctuation.special) + +(public_field_definition + ("?") @punctuation.special) + +(property_signature + ("?") @punctuation.special) + +(method_signature + ("?") @punctuation.special) + +(optional_parameter + ([ + "?" + ":" + ]) @punctuation.special) + ; Keywords [ diff --git a/crates/languages/src/typescript/injections.scm b/crates/languages/src/typescript/injections.scm index 5321e606c118a41df127c8aa37c7c2811dc8bd23..91880407900e7407e46982a54dbeaa3e30277bdd 100644 --- a/crates/languages/src/typescript/injections.scm +++ b/crates/languages/src/typescript/injections.scm @@ -124,3 +124,46 @@ ] ))) (#set! injection.language "css")) + +; Parse the contents of strings and tagged template +; literals with leading ECMAScript comments: +; '/* html */' or '/*html*/' +( + ((comment) @_ecma_comment [ + (string (string_fragment) @injection.content) + (template_string (string_fragment) @injection.content) + ]) + (#match? @_ecma_comment "^\\/\\*\\s*html\\s*\\*\\/") + (#set! injection.language "html") +) + +; '/* sql */' or '/*sql*/' +( + ((comment) @_ecma_comment [ + (string (string_fragment) @injection.content) + (template_string (string_fragment) @injection.content) + ]) + (#match? @_ecma_comment "^\\/\\*\\s*sql\\s*\\*\\/") + (#set! injection.language "sql") +) + +; '/* gql */' or '/*gql*/' +; '/* graphql */' or '/*graphql*/' +( + ((comment) @_ecma_comment [ + (string (string_fragment) @injection.content) + (template_string (string_fragment) @injection.content) + ]) + (#match? @_ecma_comment "^\\/\\*\\s*(gql|graphql)\\s*\\*\\/") + (#set! injection.language "graphql") +) + +; '/* css */' or '/*css*/' +( + ((comment) @_ecma_comment [ + (string (string_fragment) @injection.content) + (template_string (string_fragment) @injection.content) + ]) + (#match? @_ecma_comment "^\\/\\*\\s*(css)\\s*\\*\\/") + (#set! injection.language "css") +) 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/livekit_client/src/livekit_client/playback/source.rs b/crates/livekit_client/src/livekit_client/playback/source.rs index cde4b19fda2e053346ad535e7c75b2abda60431a..a258c585285d8adafb1b0039400e6b6e787a509e 100644 --- a/crates/livekit_client/src/livekit_client/playback/source.rs +++ b/crates/livekit_client/src/livekit_client/playback/source.rs @@ -47,14 +47,17 @@ impl LiveKitStream { ); let (queue_input, queue_output) = rodio::queue::queue(true); // spawn rtc stream - let receiver_task = executor.spawn({ - async move { - while let Some(frame) = stream.next().await { - let samples = frame_to_samplesbuffer(frame); - queue_input.append(samples); + let receiver_task = executor.spawn_with_priority( + gpui::Priority::Realtime(gpui::RealtimePriority::Audio), + { + async move { + while let Some(frame) = stream.next().await { + let samples = frame_to_samplesbuffer(frame); + queue_input.append(samples); + } } - } - }); + }, + ); LiveKitStream { _receiver_task: receiver_task, diff --git a/crates/lsp/src/lsp.rs b/crates/lsp/src/lsp.rs index edfe2fec8c6ef41ce81b6c8a8a8dcb441c833c70..9ff6e245c49d771c162ca55fa98bbd7ca37d7bd0 100644 --- a/crates/lsp/src/lsp.rs +++ b/crates/lsp/src/lsp.rs @@ -331,14 +331,13 @@ impl LanguageServer { }; let root_uri = Uri::from_file_path(&working_dir) .map_err(|()| anyhow!("{working_dir:?} is not a valid URI"))?; - log::info!( - "starting language server process. binary path: {:?}, working directory: {:?}, args: {:?}", + "starting language server process. binary path: \ + {:?}, working directory: {:?}, args: {:?}", binary.path, working_dir, &binary.arguments ); - let mut command = util::command::new_smol_command(&binary.path); command .current_dir(working_dir) @@ -348,6 +347,7 @@ impl LanguageServer { .stdout(Stdio::piped()) .stderr(Stdio::piped()) .kill_on_drop(true); + let mut server = command .spawn() .with_context(|| format!("failed to spawn command {command:?}",))?; diff --git a/crates/markdown/examples/markdown_as_child.rs b/crates/markdown/examples/markdown_as_child.rs index 6affa243ae5cc5f4cac1dc7fea0af9b9cc183aa6..775e2a141a849636512264dda2628e28254c8e2b 100644 --- a/crates/markdown/examples/markdown_as_child.rs +++ b/crates/markdown/examples/markdown_as_child.rs @@ -54,11 +54,11 @@ impl Render for HelloWorld { ..Default::default() }, code_block: StyleRefinement { - text: Some(gpui::TextStyleRefinement { + text: gpui::TextStyleRefinement { font_family: Some("Zed Mono".into()), background_color: Some(cx.theme().colors().editor_background), ..Default::default() - }), + }, margin: gpui::EdgesRefinement { top: Some(Length::Definite(rems(4.).into())), left: Some(Length::Definite(rems(4.).into())), diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index 317657ea5f520cee15fc49d462b3f8ac5f0072dc..0bc3b9eb726e1782bafb2a31229ea21f308adc6e 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -70,6 +70,7 @@ pub struct MarkdownStyle { pub heading_level_styles: Option, pub height_is_multiple_of_line_height: bool, pub prevent_mouse_interaction: bool, + pub table_columns_min_size: bool, } impl Default for MarkdownStyle { @@ -91,6 +92,7 @@ impl Default for MarkdownStyle { heading_level_styles: None, height_is_multiple_of_line_height: false, prevent_mouse_interaction: false, + table_columns_min_size: false, } } } @@ -251,7 +253,7 @@ impl Markdown { self.autoscroll_request = None; self.pending_parse = None; self.should_reparse = false; - self.parsed_markdown = ParsedMarkdown::default(); + // Don't clear parsed_markdown here - keep existing content visible until new parse completes self.parse(cx); } @@ -422,28 +424,72 @@ impl Focusable for Markdown { } } -#[derive(Copy, Clone, Default, Debug)] +#[derive(Debug, Default, Clone)] +enum SelectMode { + #[default] + Character, + Word(Range), + Line(Range), + All, +} + +#[derive(Clone, Default)] struct Selection { start: usize, end: usize, reversed: bool, pending: bool, + mode: SelectMode, } impl Selection { - fn set_head(&mut self, head: usize) { - if head < self.tail() { - if !self.reversed { - self.end = self.start; - self.reversed = true; + fn set_head(&mut self, head: usize, rendered_text: &RenderedText) { + match &self.mode { + SelectMode::Character => { + if head < self.tail() { + if !self.reversed { + self.end = self.start; + self.reversed = true; + } + self.start = head; + } else { + if self.reversed { + self.start = self.end; + self.reversed = false; + } + self.end = head; + } } - self.start = head; - } else { - if self.reversed { - self.start = self.end; + SelectMode::Word(original_range) | SelectMode::Line(original_range) => { + let head_range = if matches!(self.mode, SelectMode::Word(_)) { + rendered_text.surrounding_word_range(head) + } else { + rendered_text.surrounding_line_range(head) + }; + + if head < original_range.start { + self.start = head_range.start; + self.end = original_range.end; + self.reversed = true; + } else if head >= original_range.end { + self.start = original_range.start; + self.end = head_range.end; + self.reversed = false; + } else { + self.start = original_range.start; + self.end = original_range.end; + self.reversed = false; + } + } + SelectMode::All => { + self.start = 0; + self.end = rendered_text + .lines + .last() + .map(|line| line.source_end) + .unwrap_or(0); self.reversed = false; } - self.end = head; } } @@ -532,7 +578,7 @@ impl MarkdownElement { window: &mut Window, cx: &mut App, ) { - let selection = self.markdown.read(cx).selection; + let selection = self.markdown.read(cx).selection.clone(); let selection_start = rendered_text.position_for_source_index(selection.start); let selection_end = rendered_text.position_for_source_index(selection.end); if let Some(((start_position, start_line_height), (end_position, end_line_height))) = @@ -632,20 +678,36 @@ impl MarkdownElement { match rendered_text.source_index_for_position(event.position) { Ok(ix) | Err(ix) => ix, }; - let range = if event.click_count == 2 { - rendered_text.surrounding_word_range(source_index) - } else if event.click_count == 3 { - rendered_text.surrounding_line_range(source_index) - } else { - source_index..source_index + let (range, mode) = match event.click_count { + 1 => { + let range = source_index..source_index; + (range, SelectMode::Character) + } + 2 => { + let range = rendered_text.surrounding_word_range(source_index); + (range.clone(), SelectMode::Word(range)) + } + 3 => { + let range = rendered_text.surrounding_line_range(source_index); + (range.clone(), SelectMode::Line(range)) + } + _ => { + let range = 0..rendered_text + .lines + .last() + .map(|line| line.source_end) + .unwrap_or(0); + (range, SelectMode::All) + } }; markdown.selection = Selection { start: range.start, end: range.end, reversed: false, pending: true, + mode, }; - window.focus(&markdown.focus_handle); + window.focus(&markdown.focus_handle, cx); } window.prevent_default(); @@ -672,7 +734,7 @@ impl MarkdownElement { { Ok(ix) | Err(ix) => ix, }; - markdown.selection.set_head(source_index); + markdown.selection.set_head(source_index, &rendered_text); markdown.autoscroll_request = Some(source_index); cx.notify(); } else { @@ -838,8 +900,7 @@ impl Element for MarkdownElement { heading.style().refine(&self.style.heading); - let text_style = - self.style.heading.text_style().clone().unwrap_or_default(); + let text_style = self.style.heading.text_style().clone(); builder.push_text_style(text_style); builder.push_div(heading, range, markdown_end); @@ -933,10 +994,7 @@ impl Element for MarkdownElement { } }); - if let Some(code_block_text_style) = &self.style.code_block.text - { - builder.push_text_style(code_block_text_style.to_owned()); - } + builder.push_text_style(self.style.code_block.text.to_owned()); builder.push_code_block(language); builder.push_div(code_block, range, markdown_end); } @@ -1015,15 +1073,23 @@ impl Element for MarkdownElement { } MarkdownTag::MetadataBlock(_) => {} MarkdownTag::Table(alignments) => { - builder.table_alignments = alignments.clone(); + builder.table.start(alignments.clone()); + let column_count = alignments.len(); builder.push_div( div() .id(("table", range.start)) - .min_w_0() + .grid() + .grid_cols(column_count as u16) + .when(self.style.table_columns_min_size, |this| { + this.grid_cols_min_content(column_count as u16) + }) + .when(!self.style.table_columns_min_size, |this| { + this.grid_cols(column_count as u16) + }) .size_full() .mb_2() - .border_1() + .border(px(1.5)) .border_color(cx.theme().colors().border) .rounded_sm() .overflow_hidden(), @@ -1032,38 +1098,33 @@ impl Element for MarkdownElement { ); } MarkdownTag::TableHead => { - let column_count = builder.table_alignments.len(); - - builder.push_div( - div() - .grid() - .grid_cols(column_count as u16) - .bg(cx.theme().colors().title_bar_background), - range, - markdown_end, - ); + builder.table.start_head(); builder.push_text_style(TextStyleRefinement { font_weight: Some(FontWeight::SEMIBOLD), ..Default::default() }); } MarkdownTag::TableRow => { - let column_count = builder.table_alignments.len(); - - builder.push_div( - div().grid().grid_cols(column_count as u16), - range, - markdown_end, - ); + builder.table.start_row(); } MarkdownTag::TableCell => { + let is_header = builder.table.in_head; + let row_index = builder.table.row_index; + let col_index = builder.table.col_index; + builder.push_div( div() - .min_w_0() - .border(px(0.5)) + .when(col_index > 0, |this| this.border_l_1()) + .when(row_index > 0, |this| this.border_t_1()) .border_color(cx.theme().colors().border) .px_1() - .py_0p5(), + .py_0p5() + .when(is_header, |this| { + this.bg(cx.theme().colors().title_bar_background) + }) + .when(!is_header && row_index % 2 == 1, |this| { + this.bg(cx.theme().colors().panel_background) + }), range, markdown_end, ); @@ -1091,9 +1152,7 @@ impl Element for MarkdownElement { builder.pop_div(); builder.pop_code_block(); - if self.style.code_block.text.is_some() { - builder.pop_text_style(); - } + builder.pop_text_style(); if let CodeBlockRenderer::Default { copy_button: true, .. @@ -1179,17 +1238,18 @@ impl Element for MarkdownElement { } MarkdownTagEnd::Table => { builder.pop_div(); - builder.table_alignments.clear(); + builder.table.end(); } MarkdownTagEnd::TableHead => { - builder.pop_div(); builder.pop_text_style(); + builder.table.end_head(); } MarkdownTagEnd::TableRow => { - builder.pop_div(); + builder.table.end_row(); } MarkdownTagEnd::TableCell => { builder.pop_div(); + builder.table.end_cell(); } _ => log::debug!("unsupported markdown tag end: {:?}", tag), }, @@ -1346,7 +1406,7 @@ fn apply_heading_style( }; if let Some(style) = style_opt { - heading.style().text = Some(style.clone()); + heading.style().text = style.clone(); } } @@ -1452,6 +1512,50 @@ impl ParentElement for AnyDiv { } } +#[derive(Default)] +struct TableState { + alignments: Vec, + in_head: bool, + row_index: usize, + col_index: usize, +} + +impl TableState { + fn start(&mut self, alignments: Vec) { + self.alignments = alignments; + self.in_head = false; + self.row_index = 0; + self.col_index = 0; + } + + fn end(&mut self) { + self.alignments.clear(); + self.in_head = false; + self.row_index = 0; + self.col_index = 0; + } + + fn start_head(&mut self) { + self.in_head = true; + } + + fn end_head(&mut self) { + self.in_head = false; + } + + fn start_row(&mut self) { + self.col_index = 0; + } + + fn end_row(&mut self) { + self.row_index += 1; + } + + fn end_cell(&mut self) { + self.col_index += 1; + } +} + struct MarkdownElementBuilder { div_stack: Vec, rendered_lines: Vec, @@ -1463,7 +1567,7 @@ struct MarkdownElementBuilder { text_style_stack: Vec, code_block_stack: Vec>>, list_stack: Vec, - table_alignments: Vec, + table: TableState, syntax_theme: Arc, } @@ -1499,7 +1603,7 @@ impl MarkdownElementBuilder { text_style_stack: Vec::new(), code_block_stack: Vec::new(), list_stack: Vec::new(), - table_alignments: Vec::new(), + table: TableState::default(), syntax_theme, } } @@ -1833,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 { @@ -1858,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> { @@ -1947,6 +2054,178 @@ mod tests { rendered.text } + #[gpui::test] + fn test_surrounding_word_range(cx: &mut TestAppContext) { + let rendered = render_markdown("Hello world tesεζ", cx); + + // Test word selection for "Hello" + let word_range = rendered.surrounding_word_range(2); // Simulate click on 'l' in "Hello" + let selected_text = rendered.text_for_range(word_range); + assert_eq!(selected_text, "Hello"); + + // Test word selection for "world" + let word_range = rendered.surrounding_word_range(7); // Simulate click on 'o' in "world" + let selected_text = rendered.text_for_range(word_range); + assert_eq!(selected_text, "world"); + + // Test word selection for "tesεζ" + let word_range = rendered.surrounding_word_range(14); // Simulate click on 's' in "tesεζ" + let selected_text = rendered.text_for_range(word_range); + assert_eq!(selected_text, "tesεζ"); + + // Test word selection at word boundary (space) + let word_range = rendered.surrounding_word_range(5); // Simulate click on space between "Hello" and "world", expect highlighting word to the left + let selected_text = rendered.text_for_range(word_range); + assert_eq!(selected_text, "Hello"); + } + + #[gpui::test] + fn test_surrounding_line_range(cx: &mut TestAppContext) { + let rendered = render_markdown("First line\n\nSecond line\n\nThird lineεζ", cx); + + // Test getting line range for first line + let line_range = rendered.surrounding_line_range(5); // Simulate click somewhere in first line + let selected_text = rendered.text_for_range(line_range); + assert_eq!(selected_text, "First line"); + + // Test getting line range for second line + let line_range = rendered.surrounding_line_range(13); // Simulate click at beginning in second line + let selected_text = rendered.text_for_range(line_range); + assert_eq!(selected_text, "Second line"); + + // Test getting line range for third line + let line_range = rendered.surrounding_line_range(37); // Simulate click at end of third line with multi-byte chars + let selected_text = rendered.text_for_range(line_range); + assert_eq!(selected_text, "Third lineεζ"); + } + + #[gpui::test] + fn test_selection_head_movement(cx: &mut TestAppContext) { + let rendered = render_markdown("Hello world test", cx); + + let mut selection = Selection { + start: 5, + end: 5, + reversed: false, + pending: false, + mode: SelectMode::Character, + }; + + // Test forward selection + selection.set_head(10, &rendered); + assert_eq!(selection.start, 5); + assert_eq!(selection.end, 10); + assert!(!selection.reversed); + assert_eq!(selection.tail(), 5); + + // Test backward selection + selection.set_head(2, &rendered); + assert_eq!(selection.start, 2); + assert_eq!(selection.end, 5); + assert!(selection.reversed); + assert_eq!(selection.tail(), 5); + + // Test forward selection again from reversed state + selection.set_head(15, &rendered); + assert_eq!(selection.start, 5); + assert_eq!(selection.end, 15); + assert!(!selection.reversed); + assert_eq!(selection.tail(), 5); + } + + #[gpui::test] + fn test_word_selection_drag(cx: &mut TestAppContext) { + let rendered = render_markdown("Hello world test", cx); + + // Start with a simulated double-click on "world" (index 6-10) + let word_range = rendered.surrounding_word_range(7); // Click on 'o' in "world" + let mut selection = Selection { + start: word_range.start, + end: word_range.end, + reversed: false, + pending: true, + mode: SelectMode::Word(word_range), + }; + + // Drag forward to "test" - should expand selection to include "test" + selection.set_head(13, &rendered); // Index in "test" + assert_eq!(selection.start, 6); // Start of "world" + assert_eq!(selection.end, 16); // End of "test" + assert!(!selection.reversed); + let selected_text = rendered.text_for_range(selection.start..selection.end); + assert_eq!(selected_text, "world test"); + + // Drag backward to "Hello" - should expand selection to include "Hello" + selection.set_head(2, &rendered); // Index in "Hello" + assert_eq!(selection.start, 0); // Start of "Hello" + assert_eq!(selection.end, 11); // End of "world" (original selection) + assert!(selection.reversed); + let selected_text = rendered.text_for_range(selection.start..selection.end); + assert_eq!(selected_text, "Hello world"); + + // Drag back within original word - should revert to original selection + selection.set_head(8, &rendered); // Back within "world" + assert_eq!(selection.start, 6); // Start of "world" + assert_eq!(selection.end, 11); // End of "world" + assert!(!selection.reversed); + let selected_text = rendered.text_for_range(selection.start..selection.end); + assert_eq!(selected_text, "world"); + } + + #[gpui::test] + fn test_selection_with_markdown_formatting(cx: &mut TestAppContext) { + let rendered = render_markdown( + "This is **bold** text, this is *italic* text, use `code` here", + cx, + ); + let word_range = rendered.surrounding_word_range(10); // Inside "bold" + let selected_text = rendered.text_for_range(word_range); + assert_eq!(selected_text, "bold"); + + let word_range = rendered.surrounding_word_range(32); // Inside "italic" + let selected_text = rendered.text_for_range(word_range); + assert_eq!(selected_text, "italic"); + + let word_range = rendered.surrounding_word_range(51); // Inside "code" + let selected_text = rendered.text_for_range(word_range); + assert_eq!(selected_text, "code"); + } + + #[gpui::test] + fn test_all_selection(cx: &mut TestAppContext) { + let rendered = render_markdown("Hello world\n\nThis is a test\n\nwith multiple lines", cx); + + let total_length = rendered + .lines + .last() + .map(|line| line.source_end) + .unwrap_or(0); + + let mut selection = Selection { + start: 0, + end: total_length, + reversed: false, + pending: true, + mode: SelectMode::All, + }; + + selection.set_head(5, &rendered); // Try to set head in middle + assert_eq!(selection.start, 0); + assert_eq!(selection.end, total_length); + assert!(!selection.reversed); + + selection.set_head(25, &rendered); // Try to set head near end + assert_eq!(selection.start, 0); + assert_eq!(selection.end, total_length); + assert!(!selection.reversed); + + let selected_text = rendered.text_for_range(selection.start..selection.end); + assert_eq!( + selected_text, + "Hello world\nThis is a test\nwith multiple lines" + ); + } + #[test] fn test_escape() { assert_eq!(Markdown::escape("hello `world`"), "hello \\`world\\`"); diff --git a/crates/markdown_preview/src/markdown_preview.rs b/crates/markdown_preview/src/markdown_preview.rs index 77bad89a629cbb1f660e1cd16158d4dbca03361e..61c99764add0a96135730d3cccfe4ef744a63d40 100644 --- a/crates/markdown_preview/src/markdown_preview.rs +++ b/crates/markdown_preview/src/markdown_preview.rs @@ -11,9 +11,19 @@ actions!( markdown, [ /// Scrolls up by one page in the markdown preview. - MovePageUp, + #[action(deprecated_aliases = ["markdown::MovePageUp"])] + ScrollPageUp, /// Scrolls down by one page in the markdown preview. - MovePageDown, + #[action(deprecated_aliases = ["markdown::MovePageDown"])] + ScrollPageDown, + /// Scrolls up by approximately one visual line. + ScrollUp, + /// Scrolls down by approximately one visual line. + ScrollDown, + /// Scrolls up by one markdown element in the markdown preview + ScrollUpByItem, + /// Scrolls down by one markdown element in the markdown preview + ScrollDownByItem, /// Opens a markdown preview for the current file. OpenPreview, /// Opens a markdown preview in a split pane. diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs index df8201dc7a3dad18c279582d668304ce9e1cf77b..650f369309561d76669289737277b45fb99af5ec 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/crates/markdown_preview/src/markdown_preview_view.rs @@ -1,3 +1,4 @@ +use std::cmp::min; use std::sync::Arc; use std::time::Duration; use std::{ops::Range, path::PathBuf}; @@ -20,11 +21,12 @@ use workspace::{Pane, Workspace}; use crate::markdown_elements::ParsedMarkdownElement; use crate::markdown_renderer::CheckboxClickedEvent; use crate::{ - MovePageDown, MovePageUp, OpenFollowingPreview, OpenPreview, OpenPreviewToTheSide, + OpenFollowingPreview, OpenPreview, OpenPreviewToTheSide, ScrollPageDown, ScrollPageUp, markdown_elements::ParsedMarkdown, markdown_parser::parse_markdown, markdown_renderer::{RenderContext, render_markdown_block}, }; +use crate::{ScrollDown, ScrollDownByItem, ScrollUp, ScrollUpByItem}; const REPARSE_DEBOUNCE: Duration = Duration::from_millis(200); @@ -94,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(); } }); @@ -368,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); }); } } @@ -425,7 +427,7 @@ impl MarkdownPreviewView { !(current_block.is_list_item() && next_block.map(|b| b.is_list_item()).unwrap_or(false)) } - fn scroll_page_up(&mut self, _: &MovePageUp, _window: &mut Window, cx: &mut Context) { + fn scroll_page_up(&mut self, _: &ScrollPageUp, _window: &mut Window, cx: &mut Context) { let viewport_height = self.list_state.viewport_bounds().size.height; if viewport_height.is_zero() { return; @@ -435,7 +437,12 @@ impl MarkdownPreviewView { cx.notify(); } - fn scroll_page_down(&mut self, _: &MovePageDown, _window: &mut Window, cx: &mut Context) { + fn scroll_page_down( + &mut self, + _: &ScrollPageDown, + _window: &mut Window, + cx: &mut Context, + ) { let viewport_height = self.list_state.viewport_bounds().size.height; if viewport_height.is_zero() { return; @@ -444,6 +451,56 @@ impl MarkdownPreviewView { self.list_state.scroll_by(viewport_height); cx.notify(); } + + fn scroll_up(&mut self, _: &ScrollUp, window: &mut Window, cx: &mut Context) { + let scroll_top = self.list_state.logical_scroll_top(); + if let Some(bounds) = self.list_state.bounds_for_item(scroll_top.item_ix) { + let item_height = bounds.size.height; + // Scroll no more than the rough equivalent of a large headline + let max_height = window.rem_size() * 2; + let scroll_height = min(item_height, max_height); + self.list_state.scroll_by(-scroll_height); + } + cx.notify(); + } + + fn scroll_down(&mut self, _: &ScrollDown, window: &mut Window, cx: &mut Context) { + let scroll_top = self.list_state.logical_scroll_top(); + if let Some(bounds) = self.list_state.bounds_for_item(scroll_top.item_ix) { + let item_height = bounds.size.height; + // Scroll no more than the rough equivalent of a large headline + let max_height = window.rem_size() * 2; + let scroll_height = min(item_height, max_height); + self.list_state.scroll_by(scroll_height); + } + cx.notify(); + } + + fn scroll_up_by_item( + &mut self, + _: &ScrollUpByItem, + _window: &mut Window, + cx: &mut Context, + ) { + let scroll_top = self.list_state.logical_scroll_top(); + if let Some(bounds) = self.list_state.bounds_for_item(scroll_top.item_ix) { + self.list_state.scroll_by(-bounds.size.height); + } + cx.notify(); + } + + fn scroll_down_by_item( + &mut self, + _: &ScrollDownByItem, + _window: &mut Window, + cx: &mut Context, + ) { + let scroll_top = self.list_state.logical_scroll_top(); + if let Some(bounds) = self.list_state.bounds_for_item(scroll_top.item_ix) { + self.list_state.scroll_by(bounds.size.height); + } + cx.notify(); + } } impl Focusable for MarkdownPreviewView { @@ -496,6 +553,10 @@ impl Render for MarkdownPreviewView { .track_focus(&self.focus_handle(cx)) .on_action(cx.listener(MarkdownPreviewView::scroll_page_up)) .on_action(cx.listener(MarkdownPreviewView::scroll_page_down)) + .on_action(cx.listener(MarkdownPreviewView::scroll_up)) + .on_action(cx.listener(MarkdownPreviewView::scroll_down)) + .on_action(cx.listener(MarkdownPreviewView::scroll_up_by_item)) + .on_action(cx.listener(MarkdownPreviewView::scroll_down_by_item)) .size_full() .bg(cx.theme().colors().editor_background) .p_4() diff --git a/crates/markdown_preview/src/markdown_renderer.rs b/crates/markdown_preview/src/markdown_renderer.rs index d9997b54274d53e4897b3a3810629054e5458275..d4c810245c0fcf874160957cff1b029c4c4c1702 100644 --- a/crates/markdown_preview/src/markdown_renderer.rs +++ b/crates/markdown_preview/src/markdown_renderer.rs @@ -9,7 +9,7 @@ use gpui::{ AbsoluteLength, AnyElement, App, AppContext as _, ClipboardItem, Context, Div, Element, ElementId, Entity, HighlightStyle, Hsla, ImageSource, InteractiveText, IntoElement, Keystroke, Modifiers, ParentElement, Render, Resource, SharedString, Styled, StyledText, TextStyle, - WeakEntity, Window, div, img, rems, + WeakEntity, Window, div, img, px, rems, }; use settings::Settings; use std::{ @@ -75,8 +75,10 @@ impl RenderContext { let settings = ThemeSettings::get_global(cx); let buffer_font_family = settings.buffer_font.family.clone(); + let buffer_font_features = settings.buffer_font.features.clone(); let mut buffer_text_style = window.text_style(); buffer_text_style.font_family = buffer_font_family.clone(); + buffer_text_style.font_features = buffer_font_features; buffer_text_style.font_size = AbsoluteLength::from(settings.buffer_font_size(cx)); RenderContext { @@ -519,7 +521,8 @@ fn render_markdown_table(parsed: &ParsedMarkdownTable, cx: &mut RenderContext) - .children(render_markdown_text(&cell.children, cx)) .px_2() .py_1() - .border_1() + .when(col_idx > 0, |this| this.border_l_1()) + .when(row_idx > 0, |this| this.border_t_1()) .border_color(cx.border_color) .when(cell.is_header, |this| { this.bg(cx.title_bar_background_color) @@ -549,7 +552,8 @@ fn render_markdown_table(parsed: &ParsedMarkdownTable, cx: &mut RenderContext) - } let empty_cell = div() - .border_1() + .when(col_idx > 0, |this| this.border_l_1()) + .when(row_idx > 0, |this| this.border_t_1()) .border_color(cx.border_color) .when(row_idx % 2 == 1, |this| this.bg(cx.panel_background_color)); @@ -566,8 +570,10 @@ fn render_markdown_table(parsed: &ParsedMarkdownTable, cx: &mut RenderContext) - div() .grid() .grid_cols(max_column_count as u16) - .border_1() + .border(px(1.5)) .border_color(cx.border_color) + .rounded_sm() + .overflow_hidden() .children(cells), ) .into_any() @@ -631,8 +637,14 @@ fn render_markdown_code_block( .tooltip(Tooltip::text("Copy code block")) .visible_on_hover("markdown-block"); + let font = gpui::Font { + family: cx.buffer_font_family.clone(), + features: cx.buffer_text_style.font_features.clone(), + ..Default::default() + }; + cx.with_common_p(div()) - .font_family(cx.buffer_font_family.clone()) + .font(font) .px_3() .py_3() .bg(cx.code_block_background_color) diff --git a/crates/migrator/src/migrations.rs b/crates/migrator/src/migrations.rs index 398d5aaf9405d34e8d8a4e93d5c9b9045ee49118..f3fdb8f36c70d1bfde474f842a7bcbeff2668b50 100644 --- a/crates/migrator/src/migrations.rs +++ b/crates/migrator/src/migrations.rs @@ -159,3 +159,15 @@ pub(crate) mod m_2025_12_01 { pub(crate) use settings::SETTINGS_PATTERNS; } + +pub(crate) mod m_2025_12_08 { + mod keymap; + + pub(crate) use keymap::KEYMAP_PATTERNS; +} + +pub(crate) mod m_2025_12_15 { + mod settings; + + pub(crate) use settings::SETTINGS_PATTERNS; +} diff --git a/crates/migrator/src/migrations/m_2025_12_08/keymap.rs b/crates/migrator/src/migrations/m_2025_12_08/keymap.rs new file mode 100644 index 0000000000000000000000000000000000000000..70acf4e453486526a30540bf2a15c34d6537411c --- /dev/null +++ b/crates/migrator/src/migrations/m_2025_12_08/keymap.rs @@ -0,0 +1,33 @@ +use collections::HashMap; +use std::{ops::Range, sync::LazyLock}; +use tree_sitter::{Query, QueryMatch}; + +use crate::MigrationPatterns; +use crate::patterns::KEYMAP_ACTION_STRING_PATTERN; + +pub const KEYMAP_PATTERNS: MigrationPatterns = + &[(KEYMAP_ACTION_STRING_PATTERN, replace_string_action)]; + +fn replace_string_action( + contents: &str, + mat: &QueryMatch, + query: &Query, +) -> Option<(Range, String)> { + let action_name_ix = query.capture_index_for_name("action_name")?; + let action_name_node = mat.nodes_for_capture_index(action_name_ix).next()?; + let action_name_range = action_name_node.byte_range(); + let action_name = contents.get(action_name_range.clone())?; + + if let Some(new_action_name) = STRING_REPLACE.get(&action_name) { + return Some((action_name_range, new_action_name.to_string())); + } + + None +} + +static STRING_REPLACE: LazyLock> = LazyLock::new(|| { + HashMap::from_iter([( + "editor::AcceptPartialEditPrediction", + "editor::AcceptNextWordEditPrediction", + )]) +}); diff --git a/crates/migrator/src/migrations/m_2025_12_15/settings.rs b/crates/migrator/src/migrations/m_2025_12_15/settings.rs new file mode 100644 index 0000000000000000000000000000000000000000..c875bdfdddffc62a58912bdc53bcf3e496e4eeab --- /dev/null +++ b/crates/migrator/src/migrations/m_2025_12_15/settings.rs @@ -0,0 +1,52 @@ +use std::ops::Range; +use tree_sitter::{Query, QueryMatch}; + +use crate::MigrationPatterns; +use crate::patterns::SETTINGS_NESTED_KEY_VALUE_PATTERN; + +pub const SETTINGS_PATTERNS: MigrationPatterns = &[( + SETTINGS_NESTED_KEY_VALUE_PATTERN, + rename_restore_on_startup_values, +)]; + +fn rename_restore_on_startup_values( + contents: &str, + mat: &QueryMatch, + query: &Query, +) -> Option<(Range, String)> { + if !is_restore_on_startup_setting(contents, mat, query) { + return None; + } + + let setting_value_ix = query.capture_index_for_name("setting_value")?; + let setting_value_range = mat + .nodes_for_capture_index(setting_value_ix) + .next()? + .byte_range(); + let setting_value = contents.get(setting_value_range.clone())?; + + // The value includes quotes, so we check for the quoted string + let new_value = match setting_value.trim() { + "\"none\"" => "\"empty_tab\"", + "\"welcome\"" => "\"launchpad\"", + _ => return None, + }; + + Some((setting_value_range, new_value.to_string())) +} + +fn is_restore_on_startup_setting(contents: &str, mat: &QueryMatch, query: &Query) -> bool { + // Check that the parent key is "workspace" (since restore_on_startup is under workspace settings) + // Actually, restore_on_startup can be at the root level too, so we need to handle both cases + // The SETTINGS_NESTED_KEY_VALUE_PATTERN captures parent_key and setting_name + + let setting_name_ix = match query.capture_index_for_name("setting_name") { + Some(ix) => ix, + None => return false, + }; + let setting_name_range = match mat.nodes_for_capture_index(setting_name_ix).next() { + Some(node) => node.byte_range(), + None => return false, + }; + contents.get(setting_name_range) == Some("restore_on_startup") +} diff --git a/crates/migrator/src/migrator.rs b/crates/migrator/src/migrator.rs index 9fb6d8a1151719f350ea7877bfe2492d6b443c23..8329d635ce321c1b6280f06cdabe105879cc03a0 100644 --- a/crates/migrator/src/migrator.rs +++ b/crates/migrator/src/migrator.rs @@ -139,6 +139,10 @@ pub fn migrate_keymap(text: &str) -> Result> { migrations::m_2025_04_15::KEYMAP_PATTERNS, &KEYMAP_QUERY_2025_04_15, ), + MigrationType::TreeSitter( + migrations::m_2025_12_08::KEYMAP_PATTERNS, + &KEYMAP_QUERY_2025_12_08, + ), ]; run_migrations(text, migrations) } @@ -228,6 +232,10 @@ pub fn migrate_settings(text: &str) -> Result> { &SETTINGS_QUERY_2025_11_20, ), MigrationType::Json(migrations::m_2025_11_25::remove_context_server_source), + MigrationType::TreeSitter( + migrations::m_2025_12_15::SETTINGS_PATTERNS, + &SETTINGS_QUERY_2025_12_15, + ), ]; run_migrations(text, migrations) } @@ -358,6 +366,14 @@ define_query!( SETTINGS_QUERY_2025_11_20, migrations::m_2025_11_20::SETTINGS_PATTERNS ); +define_query!( + KEYMAP_QUERY_2025_12_08, + migrations::m_2025_12_08::KEYMAP_PATTERNS +); +define_query!( + SETTINGS_QUERY_2025_12_15, + migrations::m_2025_12_15::SETTINGS_PATTERNS +); // custom query static EDIT_PREDICTION_SETTINGS_MIGRATION_QUERY: LazyLock = LazyLock::new(|| { diff --git a/crates/mistral/src/mistral.rs b/crates/mistral/src/mistral.rs index eca4743d0442b9ca169ac966f78af0112565fcbc..2fa8a2cedaee01daa1452ade35b20c440055b7fc 100644 --- a/crates/mistral/src/mistral.rs +++ b/crates/mistral/src/mistral.rs @@ -155,15 +155,15 @@ impl Model { pub fn max_token_count(&self) -> u64 { match self { Self::CodestralLatest => 256000, - Self::MistralLargeLatest => 131000, + Self::MistralLargeLatest => 256000, Self::MistralMediumLatest => 128000, Self::MistralSmallLatest => 32000, - Self::MagistralMediumLatest => 40000, - Self::MagistralSmallLatest => 40000, + Self::MagistralMediumLatest => 128000, + Self::MagistralSmallLatest => 128000, Self::OpenMistralNemo => 131000, Self::OpenCodestralMamba => 256000, - Self::DevstralMediumLatest => 128000, - Self::DevstralSmallLatest => 262144, + Self::DevstralMediumLatest => 256000, + Self::DevstralSmallLatest => 256000, Self::Pixtral12BLatest => 128000, Self::PixtralLargeLatest => 128000, Self::Custom { max_tokens, .. } => *max_tokens, diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index 442abe78ee65ba91ccf8e03ab3c0ad26f3679cfc..0c0e87b60a7b8950f7461228c929503d516791e0 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -1202,6 +1202,7 @@ impl MultiBuffer { } /// Returns an up-to-date snapshot of the MultiBuffer. + #[ztracing::instrument(skip_all)] pub fn snapshot(&self, cx: &App) -> MultiBufferSnapshot { self.sync(cx); self.snapshot.borrow().clone() @@ -1927,6 +1928,7 @@ impl MultiBuffer { cx.notify(); } + #[ztracing::instrument(skip_all)] pub fn excerpts_for_buffer( &self, buffer_id: BufferId, @@ -2608,9 +2610,8 @@ impl MultiBuffer { for range in ranges { let range = range.to_point(&snapshot); let start = snapshot.point_to_offset(Point::new(range.start.row, 0)); - let end = snapshot.point_to_offset(Point::new(range.end.row + 1, 0)); - let start = start.saturating_sub_usize(1); - let end = snapshot.len().min(end + 1usize); + let end = (snapshot.point_to_offset(Point::new(range.end.row + 1, 0)) + 1usize) + .min(snapshot.len()); cursor.seek(&start, Bias::Right); while let Some(item) = cursor.item() { if *cursor.start() >= end { @@ -2887,6 +2888,7 @@ impl MultiBuffer { cx.notify(); } + #[ztracing::instrument(skip_all)] fn sync(&self, cx: &App) { let changed = self.buffer_changed_since_sync.replace(false); if !changed { @@ -5627,6 +5629,7 @@ impl MultiBufferSnapshot { /// excerpt /// /// Can optionally pass a range_filter to filter the ranges of brackets to consider + #[ztracing::instrument(skip_all)] pub fn innermost_enclosing_bracket_ranges( &self, range: Range, 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/multi_buffer/src/path_key.rs b/crates/multi_buffer/src/path_key.rs index 119194d088c946941b13ffab3f6f2b3ea126cd09..10d4088fd4bc28449c8a4ee74095ad31a45fbcf3 100644 --- a/crates/multi_buffer/src/path_key.rs +++ b/crates/multi_buffer/src/path_key.rs @@ -43,8 +43,8 @@ impl PathKey { } impl MultiBuffer { - pub fn paths(&self) -> impl Iterator + '_ { - self.excerpts_by_path.keys().cloned() + pub fn paths(&self) -> impl Iterator + '_ { + self.excerpts_by_path.keys() } pub fn remove_excerpts_for_path(&mut self, path: PathKey, cx: &mut Context) { @@ -58,15 +58,18 @@ impl MultiBuffer { } } - pub fn location_for_path(&self, path: &PathKey, cx: &App) -> Option { + pub fn buffer_for_path(&self, path: &PathKey, cx: &App) -> Option> { let excerpt_id = self.excerpts_by_path.get(path)?.first()?; let snapshot = self.read(cx); let excerpt = snapshot.excerpt(*excerpt_id)?; - Some(Anchor::in_buffer(excerpt.id, excerpt.range.context.start)) + self.buffer(excerpt.buffer_id) } - pub fn excerpt_paths(&self) -> impl Iterator { - self.excerpts_by_path.keys() + pub fn location_for_path(&self, path: &PathKey, cx: &App) -> Option { + let excerpt_id = self.excerpts_by_path.get(path)?.first()?; + let snapshot = self.read(cx); + let excerpt = snapshot.excerpt(*excerpt_id)?; + Some(Anchor::in_buffer(excerpt.id, excerpt.range.context.start)) } /// Sets excerpts, returns `true` if at least one new excerpt was added. diff --git a/crates/node_runtime/src/node_runtime.rs b/crates/node_runtime/src/node_runtime.rs index 1faf22dc9844f648fec53654ef3bde500cec32e2..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)] @@ -206,14 +206,14 @@ impl NodeRuntime { pub async fn run_npm_subcommand( &self, - directory: &Path, + directory: Option<&Path>, subcommand: &str, args: &[&str], ) -> Result { let http = self.0.lock().await.http.clone(); self.instance() .await - .run_npm_subcommand(Some(directory), http.proxy(), subcommand, args) + .run_npm_subcommand(directory, http.proxy(), subcommand, args) .await } @@ -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,19 +271,22 @@ 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(directory, "install", &arguments) + self.run_npm_subcommand(Some(directory), "install", &arguments) .await?; Ok(()) } @@ -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)] @@ -559,7 +548,10 @@ impl NodeRuntimeTrait for ManagedNodeRuntime { command.env("PATH", env_path); command.env(NODE_CA_CERTS_ENV_VAR, node_ca_certs); command.arg(npm_file).arg(subcommand); - command.args(["--cache".into(), self.installation_path.join("cache")]); + command.arg(format!( + "--cache={}", + self.installation_path.join("cache").display() + )); command.args([ "--userconfig".into(), self.installation_path.join("blank_user_npmrc"), @@ -598,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 } } @@ -703,7 +695,10 @@ impl NodeRuntimeTrait for SystemNodeRuntime { .env("PATH", path) .env(NODE_CA_CERTS_ENV_VAR, node_ca_certs) .arg(subcommand) - .args(["--cache".into(), self.scratch_dir.join("cache")]) + .arg(format!( + "--cache={}", + self.scratch_dir.join("cache").display() + )) .args(args); configure_npm_command(&mut command, directory, proxy); let output = command.output().await?; @@ -720,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) } @@ -729,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 { @@ -745,7 +740,7 @@ pub async fn read_package_installed_version( #[derive(Deserialize)] struct PackageJson { - version: String, + version: Version, } let mut contents = String::new(); @@ -782,7 +777,7 @@ impl NodeRuntimeTrait for UnavailableNodeRuntime { &self, _local_package_directory: &Path, _: &str, - ) -> Result> { + ) -> Result> { bail!("{}", self.error_message) } } diff --git a/crates/notifications/src/status_toast.rs b/crates/notifications/src/status_toast.rs index 7affa93f5a496bd0e436c74e5ff32f8aa871d026..40c5bdc8f85d0b9a46474760954247e8bba76ca9 100644 --- a/crates/notifications/src/status_toast.rs +++ b/crates/notifications/src/status_toast.rs @@ -137,7 +137,8 @@ impl Render for StatusToast { let handle = self.this_handle.clone(); this.child( IconButton::new("dismiss", IconName::Close) - .icon_size(IconSize::XSmall) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) .icon_color(Color::Muted) .tooltip(Tooltip::text("Dismiss")) .on_click(move |_click_event, _window, cx| { diff --git a/crates/onboarding/Cargo.toml b/crates/onboarding/Cargo.toml index 2ff3467c4804f7c0a50488a2c4a1e283ea571292..e5e5b5cac93aa4021f8933bd38f8711d53b89902 100644 --- a/crates/onboarding/Cargo.toml +++ b/crates/onboarding/Cargo.toml @@ -22,7 +22,6 @@ db.workspace = true documented.workspace = true fs.workspace = true fuzzy.workspace = true -git.workspace = true gpui.workspace = true menu.workspace = true notifications.workspace = true 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 94581e142339cde9d4f1f01a3fb361ae810c1efa..495a55411fc936d476dfa0d443e155d1fa7faecd 100644 --- a/crates/onboarding/src/onboarding.rs +++ b/crates/onboarding/src/onboarding.rs @@ -1,5 +1,4 @@ -pub use crate::welcome::ShowWelcome; -use crate::{multibuffer_hint::MultibufferHint, welcome::WelcomePage}; +use crate::multibuffer_hint::MultibufferHint; use client::{Client, UserStore, zed_urls}; use db::kvp::KEY_VALUE_STORE; use fs::Fs; @@ -17,6 +16,8 @@ use ui::{ Divider, KeyBinding, ParentElement as _, StatefulInteractiveElement, Vector, VectorName, WithScrollbar as _, prelude::*, rems_from_px, }; +pub use workspace::welcome::ShowWelcome; +use workspace::welcome::WelcomePage; use workspace::{ AppState, Workspace, WorkspaceId, dock::DockPosition, @@ -24,12 +25,12 @@ use workspace::{ notifications::NotifyResultExt as _, open_new, register_serializable_item, with_active_or_new_workspace, }; +use zed_actions::OpenOnboarding; mod base_keymap_picker; mod basics_page; pub mod multibuffer_hint; mod theme_preview; -mod welcome; /// Imports settings from Visual Studio Code. #[derive(Copy, Clone, Debug, Default, PartialEq, Deserialize, JsonSchema, Action)] @@ -52,14 +53,6 @@ pub struct ImportCursorSettings { pub const FIRST_OPEN: &str = "first_open"; pub const DOCS_URL: &str = "https://zed.dev/docs/"; -actions!( - zed, - [ - /// Opens the onboarding view. - OpenOnboarding - ] -); - actions!( onboarding, [ @@ -121,7 +114,8 @@ pub fn init(cx: &mut App) { if let Some(existing) = existing { workspace.activate_item(&existing, true, true, window, cx); } else { - let settings_page = WelcomePage::new(window, cx); + let settings_page = cx + .new(|cx| WelcomePage::new(workspace.weak_handle(), false, window, cx)); workspace.add_item_to_active_pane( Box::new(settings_page), None, @@ -196,7 +190,7 @@ pub fn show_onboarding_view(app_state: Arc, cx: &mut App) -> Task, Section<3>) = ( - Section { - title: "Get Started", - entries: [ - SectionEntry { - icon: IconName::Plus, - title: "New File", - action: &NewFile, - }, - SectionEntry { - icon: IconName::FolderOpen, - title: "Open Project", - action: &Open, - }, - SectionEntry { - icon: IconName::CloudDownload, - title: "Clone Repository", - action: &git::Clone, - }, - SectionEntry { - icon: IconName::ListCollapse, - title: "Open Command Palette", - action: &command_palette::Toggle, - }, - ], - }, - Section { - title: "Configure", - entries: [ - SectionEntry { - icon: IconName::Settings, - title: "Open Settings", - action: &OpenSettings, - }, - SectionEntry { - icon: IconName::ZedAssistant, - title: "View AI Settings", - action: &agent::OpenSettings, - }, - SectionEntry { - icon: IconName::Blocks, - title: "Explore Extensions", - action: &Extensions { - category_filter: None, - id: None, - }, - }, - ], - }, -); - -struct Section { - title: &'static str, - entries: [SectionEntry; COLS], -} - -impl Section { - fn render(self, index_offset: usize, focus: &FocusHandle, cx: &mut App) -> impl IntoElement { - v_flex() - .min_w_full() - .child( - h_flex() - .px_1() - .mb_2() - .gap_2() - .child( - Label::new(self.title.to_ascii_uppercase()) - .buffer_font(cx) - .color(Color::Muted) - .size(LabelSize::XSmall), - ) - .child(Divider::horizontal().color(DividerColor::BorderVariant)), - ) - .children( - self.entries - .iter() - .enumerate() - .map(|(index, entry)| entry.render(index_offset + index, focus, cx)), - ) - } -} - -struct SectionEntry { - icon: IconName, - title: &'static str, - action: &'static dyn Action, -} - -impl SectionEntry { - fn render(&self, button_index: usize, focus: &FocusHandle, cx: &App) -> impl IntoElement { - ButtonLike::new(("onboarding-button-id", button_index)) - .tab_index(button_index as isize) - .full_width() - .size(ButtonSize::Medium) - .child( - h_flex() - .w_full() - .justify_between() - .child( - h_flex() - .gap_2() - .child( - Icon::new(self.icon) - .color(Color::Muted) - .size(IconSize::XSmall), - ) - .child(Label::new(self.title)), - ) - .child( - KeyBinding::for_action_in(self.action, focus, cx).size(rems_from_px(12.)), - ), - ) - .on_click(|_, window, cx| window.dispatch_action(self.action.boxed_clone(), cx)) - } -} - -pub struct WelcomePage { - focus_handle: FocusHandle, -} - -impl WelcomePage { - fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context) { - window.focus_next(); - cx.notify(); - } - - fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context) { - window.focus_prev(); - cx.notify(); - } -} - -impl Render for WelcomePage { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let (first_section, second_section) = CONTENT; - let first_section_entries = first_section.entries.len(); - let last_index = first_section_entries + second_section.entries.len(); - - h_flex() - .size_full() - .justify_center() - .overflow_hidden() - .bg(cx.theme().colors().editor_background) - .key_context("Welcome") - .track_focus(&self.focus_handle(cx)) - .on_action(cx.listener(Self::select_previous)) - .on_action(cx.listener(Self::select_next)) - .child( - h_flex() - .px_12() - .py_40() - .size_full() - .relative() - .max_w(px(1100.)) - .child( - div() - .size_full() - .max_w_128() - .mx_auto() - .child( - h_flex() - .w_full() - .justify_center() - .gap_4() - .child(Vector::square(VectorName::ZedLogo, rems(2.))) - .child( - div().child(Headline::new("Welcome to Zed")).child( - Label::new("The editor for what's next") - .size(LabelSize::Small) - .color(Color::Muted) - .italic(), - ), - ), - ) - .child( - v_flex() - .mt_10() - .gap_6() - .child(first_section.render( - Default::default(), - &self.focus_handle, - cx, - )) - .child(second_section.render( - first_section_entries, - &self.focus_handle, - cx, - )) - .child( - h_flex() - .w_full() - .pt_4() - .justify_center() - // We call this a hack - .rounded_b_xs() - .border_t_1() - .border_color(cx.theme().colors().border.opacity(0.6)) - .border_dashed() - .child( - Button::new("welcome-exit", "Return to Setup") - .tab_index(last_index as isize) - .full_width() - .label_size(LabelSize::XSmall) - .on_click(|_, window, cx| { - window.dispatch_action( - OpenOnboarding.boxed_clone(), - cx, - ); - - with_active_or_new_workspace(cx, |workspace, window, cx| { - let Some((welcome_id, welcome_idx)) = workspace - .active_pane() - .read(cx) - .items() - .enumerate() - .find_map(|(idx, item)| { - let _ = item.downcast::()?; - Some((item.item_id(), idx)) - }) - else { - return; - }; - - workspace.active_pane().update(cx, |pane, cx| { - // Get the index here to get around the borrow checker - let idx = pane.items().enumerate().find_map( - |(idx, item)| { - let _ = - item.downcast::()?; - Some(idx) - }, - ); - - if let Some(idx) = idx { - pane.activate_item( - idx, true, true, window, cx, - ); - } else { - let item = - Box::new(Onboarding::new(workspace, cx)); - pane.add_item( - item, - true, - true, - Some(welcome_idx), - window, - cx, - ); - } - - pane.remove_item( - welcome_id, - false, - false, - window, - cx, - ); - }); - }); - }), - ), - ), - ), - ), - ) - } -} - -impl WelcomePage { - pub fn new(window: &mut Window, cx: &mut App) -> Entity { - cx.new(|cx| { - let focus_handle = cx.focus_handle(); - cx.on_focus(&focus_handle, window, |_, _, cx| cx.notify()) - .detach(); - - WelcomePage { focus_handle } - }) - } -} - -impl EventEmitter for WelcomePage {} - -impl Focusable for WelcomePage { - fn focus_handle(&self, _: &App) -> gpui::FocusHandle { - self.focus_handle.clone() - } -} - -impl Item for WelcomePage { - type Event = ItemEvent; - - fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { - "Welcome".into() - } - - fn telemetry_event_text(&self) -> Option<&'static str> { - Some("New Welcome Page Opened") - } - - fn show_toolbar(&self) -> bool { - false - } - - fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) { - f(*event) - } -} - -impl workspace::SerializableItem for WelcomePage { - fn serialized_item_kind() -> &'static str { - "WelcomePage" - } - - fn cleanup( - workspace_id: workspace::WorkspaceId, - alive_items: Vec, - _window: &mut Window, - cx: &mut App, - ) -> Task> { - workspace::delete_unloaded_items( - alive_items, - workspace_id, - "welcome_pages", - &persistence::WELCOME_PAGES, - cx, - ) - } - - fn deserialize( - _project: Entity, - _workspace: gpui::WeakEntity, - workspace_id: workspace::WorkspaceId, - item_id: workspace::ItemId, - window: &mut Window, - cx: &mut App, - ) -> Task>> { - if persistence::WELCOME_PAGES - .get_welcome_page(item_id, workspace_id) - .ok() - .is_some_and(|is_open| is_open) - { - window.spawn(cx, async move |cx| cx.update(WelcomePage::new)) - } else { - Task::ready(Err(anyhow::anyhow!("No welcome page to deserialize"))) - } - } - - fn serialize( - &mut self, - workspace: &mut workspace::Workspace, - item_id: workspace::ItemId, - _closing: bool, - _window: &mut Window, - cx: &mut Context, - ) -> Option>> { - let workspace_id = workspace.database_id()?; - Some(cx.background_spawn(async move { - persistence::WELCOME_PAGES - .save_welcome_page(item_id, workspace_id, true) - .await - })) - } - - fn should_serialize(&self, event: &Self::Event) -> bool { - event == &ItemEvent::UpdateTab - } -} - -mod persistence { - use db::{ - query, - sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection}, - sqlez_macros::sql, - }; - use workspace::WorkspaceDb; - - pub struct WelcomePagesDb(ThreadSafeConnection); - - impl Domain for WelcomePagesDb { - const NAME: &str = stringify!(WelcomePagesDb); - - const MIGRATIONS: &[&str] = (&[sql!( - CREATE TABLE welcome_pages ( - workspace_id INTEGER, - item_id INTEGER UNIQUE, - is_open INTEGER DEFAULT FALSE, - - PRIMARY KEY(workspace_id, item_id), - FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) - ON DELETE CASCADE - ) STRICT; - )]); - } - - db::static_connection!(WELCOME_PAGES, WelcomePagesDb, [WorkspaceDb]); - - impl WelcomePagesDb { - query! { - pub async fn save_welcome_page( - item_id: workspace::ItemId, - workspace_id: workspace::WorkspaceId, - is_open: bool - ) -> Result<()> { - INSERT OR REPLACE INTO welcome_pages(item_id, workspace_id, is_open) - VALUES (?, ?, ?) - } - } - - query! { - pub fn get_welcome_page( - item_id: workspace::ItemId, - workspace_id: workspace::WorkspaceId - ) -> Result { - SELECT is_open - FROM welcome_pages - WHERE item_id = ? AND workspace_id = ? - } - } - } -} diff --git a/crates/open_ai/src/open_ai.rs b/crates/open_ai/src/open_ai.rs index 8ed70c9dd514cb59f5c7a160169031cbc28428e6..d8b472254383b673e9a9e60b728d2ae585a69b90 100644 --- a/crates/open_ai/src/open_ai.rs +++ b/crates/open_ai/src/open_ai.rs @@ -87,6 +87,8 @@ pub enum Model { FiveNano, #[serde(rename = "gpt-5.1")] FivePointOne, + #[serde(rename = "gpt-5.2")] + FivePointTwo, #[serde(rename = "custom")] Custom { name: String, @@ -123,6 +125,7 @@ impl Model { "gpt-5-mini" => Ok(Self::FiveMini), "gpt-5-nano" => Ok(Self::FiveNano), "gpt-5.1" => Ok(Self::FivePointOne), + "gpt-5.2" => Ok(Self::FivePointTwo), invalid_id => anyhow::bail!("invalid model id '{invalid_id}'"), } } @@ -145,6 +148,7 @@ impl Model { Self::FiveMini => "gpt-5-mini", Self::FiveNano => "gpt-5-nano", Self::FivePointOne => "gpt-5.1", + Self::FivePointTwo => "gpt-5.2", Self::Custom { name, .. } => name, } } @@ -167,6 +171,7 @@ impl Model { Self::FiveMini => "gpt-5-mini", Self::FiveNano => "gpt-5-nano", Self::FivePointOne => "gpt-5.1", + Self::FivePointTwo => "gpt-5.2", Self::Custom { name, display_name, .. } => display_name.as_ref().unwrap_or(name), @@ -191,6 +196,7 @@ impl Model { Self::FiveMini => 272_000, Self::FiveNano => 272_000, Self::FivePointOne => 400_000, + Self::FivePointTwo => 400_000, Self::Custom { max_tokens, .. } => *max_tokens, } } @@ -216,6 +222,7 @@ impl Model { Self::FiveMini => Some(128_000), Self::FiveNano => Some(128_000), Self::FivePointOne => Some(128_000), + Self::FivePointTwo => Some(128_000), } } @@ -244,6 +251,7 @@ impl Model { | Self::Five | Self::FiveMini | Self::FivePointOne + | Self::FivePointTwo | Self::FiveNano => true, Self::O1 | Self::O3 | Self::O3Mini | Self::O4Mini | Model::Custom { .. } => false, } diff --git a/crates/outline/src/outline.rs b/crates/outline/src/outline.rs index 1f5cf1edab15a190a9f15d6106190eae637b9f3d..366d48bcf71bb7761606b97cfff0f97de76acae8 100644 --- a/crates/outline/src/outline.rs +++ b/crates/outline/src/outline.rs @@ -311,7 +311,7 @@ impl PickerDelegate for OutlineViewDelegate { |s| s.select_ranges([rows.start..rows.start]), ); active_editor.clear_row_highlights::(); - 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 a787ad5b032ffcabc38790668fd4e0901ac1bebc..5a32bd73b74a9e8caade1042a381983af0da71d3 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -75,6 +75,16 @@ actions!( OpenSelectedEntry, /// Reveals the selected item in the system file manager. RevealInFileManager, + /// Scroll half a page upwards + ScrollUp, + /// Scroll half a page downwards + ScrollDown, + /// Scroll until the cursor displays at the center + ScrollCursorCenter, + /// Scroll until the cursor displays at the top + ScrollCursorTop, + /// Scroll until the cursor displays at the bottom + ScrollCursorBottom, /// Selects the parent of the current entry. SelectParent, /// Toggles the pin status of the active editor. @@ -100,6 +110,7 @@ pub struct OutlinePanel { active: bool, pinned: bool, scroll_handle: UniformListScrollHandle, + rendered_entries_len: usize, context_menu: Option<(Entity, Point, Subscription)>, focus_handle: FocusHandle, pending_serialization: Task>, @@ -839,6 +850,7 @@ impl OutlinePanel { fs: workspace.app_state().fs.clone(), max_width_item_index: None, scroll_handle, + rendered_entries_len: 0, focus_handle, filter_editor, fs_entries: Vec::new(), @@ -986,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() { @@ -1141,14 +1153,78 @@ 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); } } } } + fn scroll_up(&mut self, _: &ScrollUp, window: &mut Window, cx: &mut Context) { + for _ in 0..self.rendered_entries_len / 2 { + window.dispatch_action(SelectPrevious.boxed_clone(), cx); + } + } + + fn scroll_down(&mut self, _: &ScrollDown, window: &mut Window, cx: &mut Context) { + for _ in 0..self.rendered_entries_len / 2 { + window.dispatch_action(SelectNext.boxed_clone(), cx); + } + } + + fn scroll_cursor_center( + &mut self, + _: &ScrollCursorCenter, + _: &mut Window, + cx: &mut Context, + ) { + if let Some(selected_entry) = self.selected_entry() { + let index = self + .cached_entries + .iter() + .position(|cached_entry| &cached_entry.entry == selected_entry); + if let Some(index) = index { + self.scroll_handle + .scroll_to_item_strict(index, ScrollStrategy::Center); + cx.notify(); + } + } + } + + fn scroll_cursor_top(&mut self, _: &ScrollCursorTop, _: &mut Window, cx: &mut Context) { + if let Some(selected_entry) = self.selected_entry() { + let index = self + .cached_entries + .iter() + .position(|cached_entry| &cached_entry.entry == selected_entry); + if let Some(index) = index { + self.scroll_handle + .scroll_to_item_strict(index, ScrollStrategy::Top); + cx.notify(); + } + } + } + + fn scroll_cursor_bottom( + &mut self, + _: &ScrollCursorBottom, + _: &mut Window, + cx: &mut Context, + ) { + if let Some(selected_entry) = self.selected_entry() { + let index = self + .cached_entries + .iter() + .position(|cached_entry| &cached_entry.entry == selected_entry); + if let Some(index) = index { + self.scroll_handle + .scroll_to_item_strict(index, ScrollStrategy::Bottom); + cx.notify(); + } + } + } + fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context) { if let Some(entry_to_select) = self.selected_entry().and_then(|selected_entry| { self.cached_entries @@ -1382,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(); @@ -2610,7 +2686,7 @@ impl OutlinePanel { }) .when( is_active && self.focus_handle.contains_focused(window, cx), - |div| div.border_color(Color::Selected.color(cx)), + |div| div.border_color(cx.theme().colors().panel_focused_border), ) } @@ -4463,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 @@ -4578,6 +4654,7 @@ impl OutlinePanel { "entries", items_len, cx.processor(move |outline_panel, range: Range, window, cx| { + outline_panel.rendered_entries_len = range.end - range.start; let entries = outline_panel.cached_entries.get(range); entries .map(|entries| entries.to_vec()) @@ -4970,7 +5047,12 @@ impl Render for OutlinePanel { .key_context(self.dispatch_context(window, cx)) .on_action(cx.listener(Self::open_selected_entry)) .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::scroll_up)) + .on_action(cx.listener(Self::scroll_down)) .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::scroll_cursor_center)) + .on_action(cx.listener(Self::scroll_cursor_top)) + .on_action(cx.listener(Self::scroll_cursor_bottom)) .on_action(cx.listener(Self::select_previous)) .on_action(cx.listener(Self::select_first)) .on_action(cx.listener(Self::select_last)) diff --git a/crates/outline_panel/src/outline_panel_settings.rs b/crates/outline_panel/src/outline_panel_settings.rs index b2b1a6fe685c18853087d3eb04edeef2ceebd89f..bf73aebecc194baca0156c9cdb850ed89627e001 100644 --- a/crates/outline_panel/src/outline_panel_settings.rs +++ b/crates/outline_panel/src/outline_panel_settings.rs @@ -50,7 +50,13 @@ impl Settings for OutlinePanelSettings { dock: panel.dock.unwrap(), file_icons: panel.file_icons.unwrap(), folder_icons: panel.folder_icons.unwrap(), - git_status: panel.git_status.unwrap(), + git_status: panel.git_status.unwrap() + && content + .git + .unwrap() + .enabled + .unwrap() + .is_git_status_enabled(), indent_size: panel.indent_size.unwrap(), indent_guides: IndentGuidesSettings { show: panel.indent_guides.unwrap().show.unwrap(), diff --git a/crates/paths/src/paths.rs b/crates/paths/src/paths.rs index 7b5188b0f2b0db1c8b20876e6284209ce91fee6e..a6aa8354b4661fbdf6a3360704d0fb16e5b80614 100644 --- a/crates/paths/src/paths.rs +++ b/crates/paths/src/paths.rs @@ -408,6 +408,12 @@ pub fn remote_servers_dir() -> &'static PathBuf { REMOTE_SERVERS_DIR.get_or_init(|| data_dir().join("remote_servers")) } +/// Returns the path to the directory where the devcontainer CLI is installed. +pub fn devcontainer_dir() -> &'static PathBuf { + static DEVCONTAINER_DIR: OnceLock = OnceLock::new(); + DEVCONTAINER_DIR.get_or_init(|| data_dir().join("devcontainer")) +} + /// Returns the relative path to a `.zed` folder within a project. pub fn local_settings_folder_name() -> &'static str { ".zed" diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index 8fb4941b716efa8186937ec7b49bcc3cfb26d44b..2da40b5bf4b47651df7236b0decb25fac67a3b1b 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -97,6 +97,18 @@ pub trait PickerDelegate: Sized + 'static { window: &mut Window, cx: &mut Context>, ); + + /// Called before the picker handles `SelectPrevious` or `SelectNext`. Return `Some(query)` to + /// set a new query and prevent the default selection behavior. + fn select_history( + &mut self, + _direction: Direction, + _query: &str, + _window: &mut Window, + _cx: &mut App, + ) -> Option { + None + } fn can_select( &mut self, _ix: usize, @@ -372,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. @@ -448,6 +460,14 @@ impl Picker { window: &mut Window, cx: &mut Context, ) { + let query = self.query(cx); + if let Some(query) = self + .delegate + .select_history(Direction::Down, &query, window, cx) + { + self.set_query(query, window, cx); + return; + } let count = self.delegate.match_count(); if count > 0 { let index = self.delegate.selected_index(); @@ -467,6 +487,14 @@ impl Picker { window: &mut Window, cx: &mut Context, ) { + let query = self.query(cx); + if let Some(query) = self + .delegate + .select_history(Direction::Up, &query, window, cx) + { + self.set_query(query, window, cx); + return; + } let count = self.delegate.match_count(); if count > 0 { let index = self.delegate.selected_index(); diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml index 9e2789fc109b8217f0f1033cc6d4832105c0ad48..0d264f9e58363f5e8d8e23dff565d512f118a8d1 100644 --- a/crates/project/Cargo.toml +++ b/crates/project/Cargo.toml @@ -40,6 +40,7 @@ clock.workspace = true collections.workspace = true context_server.workspace = true dap.workspace = true +encoding_rs.workspace = true extension.workspace = true fancy-regex.workspace = true fs.workspace = true @@ -96,6 +97,7 @@ tracing.workspace = true [dev-dependencies] client = { workspace = true, features = ["test-support"] } +db = { workspace = true, features = ["test-support"] } collections = { workspace = true, features = ["test-support"] } context_server = { workspace = true, features = ["test-support"] } buffer_diff = { workspace = true, features = ["test-support"] } diff --git a/crates/project/src/agent_server_store.rs b/crates/project/src/agent_server_store.rs index 6524cc6d22a0bf7c5d7a5c4ad6ae0e86d795be28..1443e4d877d4e288fb379a02fee8a351075d8db8 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}; @@ -137,6 +138,7 @@ pub struct AgentServerStore { state: AgentServerStoreState, external_agents: HashMap>, agent_icons: HashMap, + agent_display_names: HashMap, } pub struct AgentServersUpdated; @@ -155,6 +157,7 @@ mod ext_agent_tests { state: AgentServerStoreState::Collab, external_agents: HashMap::default(), agent_icons: HashMap::default(), + agent_display_names: HashMap::default(), } } @@ -258,6 +261,7 @@ impl AgentServerStore { self.external_agents.retain(|name, agent| { if agent.downcast_mut::().is_some() { self.agent_icons.remove(name); + self.agent_display_names.remove(name); false } else { // Keep the hardcoded external agents that don't come from extensions @@ -275,6 +279,12 @@ impl AgentServerStore { for (ext_id, manifest) in manifests { for (agent_name, agent_entry) in &manifest.agent_servers { // Store absolute icon path if provided, resolving symlinks for dev extensions + // Store display name from manifest + self.agent_display_names.insert( + ExternalAgentServerName(agent_name.clone().into()), + SharedString::from(agent_entry.name.clone()), + ); + let icon_path = if let Some(icon) = &agent_entry.icon { let icon_path = extensions_dir.join(ext_id).join(icon); // Canonicalize to resolve symlinks (dev extensions are symlinked) @@ -310,6 +320,12 @@ impl AgentServerStore { let mut agents = vec![]; for (ext_id, manifest) in manifests { for (agent_name, agent_entry) in &manifest.agent_servers { + // Store display name from manifest + self.agent_display_names.insert( + ExternalAgentServerName(agent_name.clone().into()), + SharedString::from(agent_entry.name.clone()), + ); + // Store absolute icon path if provided, resolving symlinks for dev extensions let icon = if let Some(icon) = &agent_entry.icon { let icon_path = extensions_dir.join(ext_id).join(icon); @@ -369,6 +385,10 @@ impl AgentServerStore { self.agent_icons.get(name).cloned() } + pub fn agent_display_name(&self, name: &ExternalAgentServerName) -> Option { + self.agent_display_names.get(name).cloned() + } + pub fn init_remote(session: &AnyProtoClient) { session.add_entity_message_handler(Self::handle_external_agents_updated); session.add_entity_message_handler(Self::handle_loading_status_updated); @@ -440,7 +460,7 @@ impl AgentServerStore { .gemini .as_ref() .and_then(|settings| settings.ignore_system_version) - .unwrap_or(false), + .unwrap_or(true), }), ); self.external_agents.insert( @@ -559,6 +579,7 @@ impl AgentServerStore { }, external_agents: Default::default(), agent_icons: Default::default(), + agent_display_names: Default::default(), }; if let Some(_events) = extension::ExtensionEvents::try_global(cx) {} this.agent_servers_settings_changed(cx); @@ -609,6 +630,7 @@ impl AgentServerStore { }, external_agents: external_agents.into_iter().collect(), agent_icons: HashMap::default(), + agent_display_names: HashMap::default(), } } @@ -617,6 +639,7 @@ impl AgentServerStore { state: AgentServerStoreState::Collab, external_agents: Default::default(), agent_icons: Default::default(), + agent_display_names: Default::default(), } } @@ -952,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 }; @@ -982,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 { @@ -993,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, @@ -1006,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(); } } } @@ -1025,6 +1048,7 @@ fn get_or_npm_install_builtin_agent( package_name.clone(), )) .await? + .to_string() .into() }; @@ -1071,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)?; @@ -1087,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, @@ -2040,6 +2064,7 @@ mod extension_agent_tests { state: AgentServerStoreState::Collab, external_agents: HashMap::default(), agent_icons: HashMap::default(), + agent_display_names: HashMap::default(), }; // Seed with extension agents (contain ": ") and custom agents (don't contain ": ") diff --git a/crates/project/src/buffer_store.rs b/crates/project/src/buffer_store.rs index c38b898f5d79cf34563daa9bc7563f3c869d9a70..22106fa368904d91a5c3da4338e1a79cef7f0fd0 100644 --- a/crates/project/src/buffer_store.rs +++ b/crates/project/src/buffer_store.rs @@ -24,7 +24,7 @@ use rpc::{ use std::{io, sync::Arc, time::Instant}; use text::{BufferId, ReplicaId}; -use util::{ResultExt as _, TryFutureExt, debug_panic, maybe, paths::PathStyle, rel_path::RelPath}; +use util::{ResultExt as _, TryFutureExt, debug_panic, maybe, rel_path::RelPath}; use worktree::{File, PathChange, ProjectEntryId, Worktree, WorktreeId}; /// A set of open buffers. @@ -376,6 +376,8 @@ impl LocalBufferStore { let text = buffer.as_rope().clone(); let line_ending = buffer.line_ending(); + let encoding = buffer.encoding(); + let has_bom = buffer.has_bom(); let version = buffer.version(); let buffer_id = buffer.remote_id(); let file = buffer.file().cloned(); @@ -387,7 +389,7 @@ impl LocalBufferStore { } let save = worktree.update(cx, |worktree, cx| { - worktree.write_file(path, text, line_ending, cx) + worktree.write_file(path, text, line_ending, encoding, has_bom, cx) }); cx.spawn(async move |this, cx| { @@ -620,21 +622,7 @@ impl LocalBufferStore { let load_file = worktree.update(cx, |worktree, cx| worktree.load_file(path.as_ref(), cx)); cx.spawn(async move |this, cx| { let path = path.clone(); - let single_file_path = cx.update(|cx| { - if worktree.read(cx).is_single_file() { - Some(worktree.read(cx).abs_path()) - } else { - None - } - })?; - let path_string = single_file_path - .as_ref() - .map(|path| path.to_string_lossy()) - .unwrap_or_else(|| path.display(PathStyle::local())); - let buffer = match load_file - .await - .with_context(|| format!("Opening path \"{path_string}\"")) - { + let buffer = match load_file.await { Ok(loaded) => { let reservation = cx.reserve_entity::()?; let buffer_id = BufferId::from(reservation.entity_id().as_non_zero_u64()); @@ -644,7 +632,11 @@ impl LocalBufferStore { }) .await; cx.insert_entity(reservation, |_| { - Buffer::build(text_buffer, Some(loaded.file), Capability::ReadWrite) + let mut buffer = + Buffer::build(text_buffer, Some(loaded.file), Capability::ReadWrite); + buffer.set_encoding(loaded.encoding); + buffer.set_has_bom(loaded.has_bom); + buffer })? } Err(error) if is_not_found_error(&error) => cx.new(|cx| { diff --git a/crates/project/src/context_server_store.rs b/crates/project/src/context_server_store.rs index 59bef36f06502f11d06f76ac7819a4c9ea806176..7ba46a46872ba57c758baccf9f67b0039818ee75 100644 --- a/crates/project/src/context_server_store.rs +++ b/crates/project/src/context_server_store.rs @@ -411,11 +411,11 @@ impl ContextServerStore { ) { self.stop_server(&id, cx).log_err(); } - let task = cx.spawn({ let id = server.id(); let server = server.clone(); let configuration = configuration.clone(); + async move |this, cx| { match server.clone().start(cx).await { Ok(_) => { diff --git a/crates/project/src/debugger/locators/cargo.rs b/crates/project/src/debugger/locators/cargo.rs index 1bafd256ad8589e354b0df332715904914d608dd..2f7d8cdc5f20d2ae8c463fada572a89d3dec2da7 100644 --- a/crates/project/src/debugger/locators/cargo.rs +++ b/crates/project/src/debugger/locators/cargo.rs @@ -115,18 +115,17 @@ impl DapLocator for CargoLocator { .clone() .context("Couldn't get cwd from debug config which is needed for locators")?; let builder = ShellBuilder::new(&build_config.shell, cfg!(windows)).non_interactive(); - let (program, args) = builder.build( - Some("cargo".into()), - &build_config - .args - .iter() - .cloned() - .take_while(|arg| arg != "--") - .chain(Some("--message-format=json".to_owned())) - .collect::>(), - ); - let mut child = util::command::new_smol_command(program) - .args(args) + let mut child = builder + .build_command( + Some("cargo".into()), + &build_config + .args + .iter() + .cloned() + .take_while(|arg| arg != "--") + .chain(Some("--message-format=json".to_owned())) + .collect::>(), + ) .envs(build_config.env.iter().map(|(k, v)| (k.clone(), v.clone()))) .current_dir(cwd) .stdout(Stdio::piped()) 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 3efbb57e0312dc7e07d0dbed69f5e096a2e52eb3..85ff38ab67f873d8197729de9577075951676597 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -1031,6 +1031,7 @@ impl GitStore { Some(version) => buffer.rope_for_version(version), None => buffer.as_rope().clone(), }; + let line_ending = buffer.line_ending(); let version = version.unwrap_or(buffer.version()); let buffer_id = buffer.remote_id(); @@ -1042,7 +1043,7 @@ impl GitStore { .map_err(|err| anyhow::anyhow!(err))?; match repository_state { RepositoryState::Local(LocalRepositoryState { backend, .. }) => backend - .blame(repo_path.clone(), content) + .blame(repo_path.clone(), content, line_ending) .await .with_context(|| format!("Failed to blame {:?}", repo_path.as_ref())) .map(Some), @@ -4692,11 +4693,9 @@ impl Repository { }); let this = cx.weak_entity(); - let rx = self.run_hook(RunHook::PrePush, cx); self.send_job( Some(format!("git push {} {} {}", args, remote, branch).into()), move |git_repo, mut cx| async move { - rx.await??; match git_repo { RepositoryState::Local(LocalRepositoryState { backend, @@ -5868,6 +5867,11 @@ impl Repository { self.pending_ops.edit(edits, ()); ids } + pub fn default_remote_url(&self) -> Option { + self.remote_upstream_url + .clone() + .or(self.remote_origin_url.clone()) + } } fn get_permalink_in_rust_registry_src( diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 6856c0ba49da63888cdd81015ca7f725ca3cb81f..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, @@ -201,7 +204,10 @@ pub enum LspFormatTarget { Ranges(BTreeMap>>), } -pub type OpenLspBufferHandle = Entity>; +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct OpenLspBufferHandle(Entity); + +struct OpenLspBuffer(Entity); impl FormatTrigger { fn from_proto(value: i32) -> FormatTrigger { @@ -293,6 +299,7 @@ pub struct LocalLspStore { LanguageServerId, HashMap, HashMap>>, >, + restricted_worktrees_tasks: HashMap)>, } impl LocalLspStore { @@ -364,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(); @@ -372,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(); @@ -417,7 +455,7 @@ impl LocalLspStore { server_id, server_name, binary, - &root_path, + &worktree_abs_path, code_action_kinds, Some(pending_workspace_folders), cx, @@ -553,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 @@ -568,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()); @@ -611,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) @@ -989,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 { @@ -1015,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) @@ -2219,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) => { @@ -3235,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 @@ -3255,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); @@ -3775,6 +3854,7 @@ pub enum LspStoreEvent { edits: Vec<(lsp::Range, Snippet)>, most_recent_edit: clock::Lamport, }, + WorkspaceEditApplied(ProjectTransaction), } #[derive(Clone, Debug, Serialize)] @@ -3971,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(), }), @@ -4207,7 +4288,7 @@ impl LspStore { cx: &mut Context, ) -> OpenLspBufferHandle { let buffer_id = buffer.read(cx).remote_id(); - let handle = cx.new(|_| buffer.clone()); + let handle = OpenLspBufferHandle(cx.new(|_| OpenLspBuffer(buffer.clone()))); if let Some(local) = self.as_local_mut() { let refcount = local.registered_buffers.entry(buffer_id).or_insert(0); if !ignore_refcounts { @@ -4229,7 +4310,7 @@ impl LspStore { local.register_buffer_with_language_servers(buffer, only_register_servers, cx); } if !ignore_refcounts { - cx.observe_release(&handle, move |lsp_store, buffer, cx| { + cx.observe_release(&handle.0, move |lsp_store, buffer, cx| { let refcount = { let local = lsp_store.as_local_mut().unwrap(); let Some(refcount) = local.registered_buffers.get_mut(&buffer_id) else { @@ -4246,8 +4327,8 @@ impl LspStore { local.registered_buffers.remove(&buffer_id); local.buffers_opened_in_servers.remove(&buffer_id); - if let Some(file) = File::from_dyn(buffer.read(cx).file()).cloned() { - local.unregister_old_buffer_from_language_servers(buffer, &file, cx); + if let Some(file) = File::from_dyn(buffer.0.read(cx).file()).cloned() { + local.unregister_old_buffer_from_language_servers(&buffer.0, &file, cx); let buffer_abs_path = file.abs_path(cx); for (_, buffer_pull_diagnostics_result_ids) in @@ -6412,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(()) @@ -6571,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; } @@ -6782,7 +6863,7 @@ impl LspStore { }) } else { let servers = buffer.update(cx, |buffer, cx| { - self.language_servers_for_local_buffer(buffer, cx) + self.running_language_servers_for_local_buffer(buffer, cx) .map(|(_, server)| server.clone()) .collect::>() }); @@ -6846,9 +6927,15 @@ impl LspStore { ranges: &[Range], cx: &mut Context, ) -> Vec> { + let buffer_snapshot = buffer.read(cx).snapshot(); + let ranges = ranges + .iter() + .map(|range| range.to_point(&buffer_snapshot)) + .collect::>(); + self.latest_lsp_data(buffer, cx) .inlay_hints - .applicable_chunks(ranges) + .applicable_chunks(ranges.as_slice()) .map(|chunk| chunk.row_range()) .collect() } @@ -6895,6 +6982,12 @@ impl LspStore { .map(|(_, known_chunks)| known_chunks) .unwrap_or_default(); + let buffer_snapshot = buffer.read(cx).snapshot(); + let ranges = ranges + .iter() + .map(|range| range.to_point(&buffer_snapshot)) + .collect::>(); + let mut hint_fetch_tasks = Vec::new(); let mut cached_inlay_hints = None; let mut ranges_to_query = None; @@ -6919,9 +7012,7 @@ impl LspStore { .cloned(), ) { (None, None) => { - let Some(chunk_range) = existing_inlay_hints.chunk_range(row_chunk) else { - continue; - }; + let chunk_range = row_chunk.anchor_range(); ranges_to_query .get_or_insert_with(Vec::new) .push((row_chunk, chunk_range)); @@ -8122,7 +8213,7 @@ impl LspStore { }) } - pub fn language_servers_for_local_buffer<'a>( + pub fn running_language_servers_for_local_buffer<'a>( &'a self, buffer: &Buffer, cx: &mut App, @@ -8144,6 +8235,17 @@ impl LspStore { ) } + pub fn language_servers_for_local_buffer( + &self, + buffer: &Buffer, + cx: &mut App, + ) -> Vec { + let local = self.as_local(); + local + .map(|local| local.language_server_ids_for_buffer(buffer, cx)) + .unwrap_or_default() + } + pub fn language_server_for_local_buffer<'a>( &'a self, buffer: &'a Buffer, @@ -12712,10 +12814,11 @@ impl LspStore { .update(cx, |buffer, _| buffer.wait_for_version(version))? .await?; lsp_store.update(cx, |lsp_store, cx| { + let buffer_snapshot = buffer.read(cx).snapshot(); let lsp_data = lsp_store.latest_lsp_data(&buffer, cx); let chunks_queried_for = lsp_data .inlay_hints - .applicable_chunks(&[range]) + .applicable_chunks(&[range.to_point(&buffer_snapshot)]) .collect::>(); match chunks_queried_for.as_slice() { &[chunk] => { @@ -13893,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/lsp_store/inlay_hint_cache.rs b/crates/project/src/lsp_store/inlay_hint_cache.rs index 804552b52cee9f31799e12f3c42e0614291eeab9..0cd9698e74bbfa4c53ad58569ebf59db99b5decd 100644 --- a/crates/project/src/lsp_store/inlay_hint_cache.rs +++ b/crates/project/src/lsp_store/inlay_hint_cache.rs @@ -8,7 +8,7 @@ use language::{ row_chunk::{RowChunk, RowChunks}, }; use lsp::LanguageServerId; -use text::Anchor; +use text::Point; use crate::{InlayHint, InlayId}; @@ -90,10 +90,7 @@ impl BufferInlayHints { } } - pub fn applicable_chunks( - &self, - ranges: &[Range], - ) -> impl Iterator { + pub fn applicable_chunks(&self, ranges: &[Range]) -> impl Iterator { self.chunks.applicable_chunks(ranges) } @@ -226,8 +223,4 @@ impl BufferInlayHints { } } } - - pub fn chunk_range(&self, chunk: RowChunk) -> Option> { - self.chunks.chunk_range(chunk) - } } 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 f1060ee2560c82c540497133c046eed67d9f8eed..5e31f2a90cf137f1e4d788952832e1eb2ee0ec35 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::{ @@ -63,6 +65,7 @@ use debugger::{ dap_store::{DapStore, DapStoreEvent}, session::Session, }; +use encoding_rs; pub use environment::ProjectEnvironment; #[cfg(test)] use futures::future::join_all; @@ -348,6 +351,7 @@ pub enum Event { SnippetEdit(BufferId, Vec<(lsp::Range, Snippet)>), ExpandedAllForEntry(WorktreeId, ProjectEntryId), EntryRenamed(ProjectTransaction, ProjectPath, PathBuf), + WorkspaceEditApplied(ProjectTransaction), AgentLocationChanged, } @@ -966,6 +970,14 @@ impl DirectoryLister { } } } + + pub fn path_style(&self, cx: &App) -> PathStyle { + match self { + Self::Local(project, ..) | Self::Project(project, ..) => { + project.read(cx).path_style(cx) + } + } + } } #[cfg(any(test, feature = "test-support"))] @@ -1061,6 +1073,7 @@ impl Project { languages: Arc, fs: Arc, env: Option>, + init_worktree_trust: bool, cx: &mut App, ) -> Entity { cx.new(|cx: &mut Context| { @@ -1069,6 +1082,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(); @@ -1242,6 +1264,7 @@ impl Project { user_store: Entity, languages: Arc, fs: Arc, + init_worktree_trust: bool, cx: &mut App, ) -> Entity { cx.new(|cx: &mut Context| { @@ -1250,8 +1273,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, @@ -1260,8 +1289,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 = @@ -1442,6 +1486,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); @@ -1802,6 +1849,7 @@ impl Project { Arc::new(languages), fs, None, + false, cx, ) }) @@ -1826,6 +1874,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; @@ -1842,6 +1909,7 @@ impl Project { Arc::new(languages), fs, None, + init_worktree_trust, cx, ) }); @@ -2417,13 +2485,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; @@ -2622,6 +2688,12 @@ impl Project { !self.is_local() } + pub fn disable_worktree_scanner(&mut self, cx: &mut Context) { + self.worktree_store.update(cx, |worktree_store, _cx| { + worktree_store.disable_scanner(); + }); + } + #[inline] pub fn create_buffer( &mut self, @@ -3179,6 +3251,9 @@ impl Project { cx.emit(Event::SnippetEdit(*buffer_id, edits.clone())) } } + LspStoreEvent::WorkspaceEditApplied(transaction) => { + cx.emit(Event::WorkspaceEditApplied(transaction.clone())) + } } } @@ -4657,6 +4732,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(()) @@ -4743,9 +4826,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); @@ -4772,6 +4860,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, @@ -5184,7 +5324,7 @@ impl Project { #[cfg(any(test, feature = "test-support"))] pub fn has_language_servers_for(&self, buffer: &Buffer, cx: &mut App) -> bool { self.lsp_store.update(cx, |this, cx| { - this.language_servers_for_local_buffer(buffer, cx) + this.running_language_servers_for_local_buffer(buffer, cx) .next() .is_some() }) @@ -5322,13 +5462,22 @@ impl Project { .await .context("Failed to load settings file")?; + let has_bom = file.has_bom; + let new_text = cx.read_global::(|store, cx| { store.new_text_for_update(file.text, move |settings| update(settings, cx)) })?; worktree .update(cx, |worktree, cx| { let line_ending = text::LineEnding::detect(&new_text); - worktree.write_file(rel_path.clone(), new_text.into(), line_ending, cx) + worktree.write_file( + rel_path.clone(), + new_text.into(), + line_ending, + encoding_rs::UTF_8, + has_bom, + cx, + ) })? .await .context("Failed to write settings file")?; diff --git a/crates/project/src/project_settings.rs b/crates/project/src/project_settings.rs index b7dadc52f74f4800741f5cf537ac9f52c09643e3..633f2bbd3b40139f6355e109211d665cfd0c1e5f 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)] @@ -325,6 +332,10 @@ impl GoToDiagnosticSeverityFilter { #[derive(Copy, Clone, Debug)] pub struct GitSettings { + /// Whether or not git integration is enabled. + /// + /// Default: true + pub enabled: GitEnabledSettings, /// Whether or not to show the git gutter. /// /// Default: tracked_files @@ -354,6 +365,18 @@ pub struct GitSettings { pub path_style: GitPathStyle, } +#[derive(Clone, Copy, Debug)] +pub struct GitEnabledSettings { + /// Whether git integration is enabled for showing git status. + /// + /// Default: true + pub status: bool, + /// Whether git integration is enabled for showing diffs. + /// + /// Default: true + pub diff: bool, +} + #[derive(Clone, Copy, Debug, PartialEq, Default)] pub enum GitPathStyle { #[default] @@ -495,7 +518,14 @@ impl Settings for ProjectSettings { let inline_diagnostics = diagnostics.inline.as_ref().unwrap(); let git = content.git.as_ref().unwrap(); + let git_enabled = { + GitEnabledSettings { + status: git.enabled.as_ref().unwrap().is_git_status_enabled(), + diff: git.enabled.as_ref().unwrap().is_git_diff_enabled(), + } + }; let git_settings = GitSettings { + enabled: git_enabled, git_gutter: git.git_gutter.unwrap(), gutter_debounce: git.gutter_debounce.unwrap_or_default(), inline_blame: { @@ -570,6 +600,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 +626,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 +654,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 +761,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(), @@ -792,13 +878,20 @@ impl SettingsObserver { event: &WorktreeStoreEvent, cx: &mut Context, ) { - if let WorktreeStoreEvent::WorktreeAdded(worktree) = event { - cx.subscribe(worktree, |this, worktree, event, cx| { - if let worktree::Event::UpdatedEntries(changes) = event { - this.update_local_worktree_settings(&worktree, changes, cx) - } - }) - .detach() + match event { + WorktreeStoreEvent::WorktreeAdded(worktree) => cx + .subscribe(worktree, |this, worktree, event, cx| { + if let worktree::Event::UpdatedEntries(changes) = event { + this.update_local_worktree_settings(&worktree, changes, cx) + } + }) + .detach(), + WorktreeStoreEvent::WorktreeRemoved(_, worktree_id) => { + cx.update_global::(|store, cx| { + store.clear_local_settings(*worktree_id, cx).log_err(); + }); + } + _ => {} } } @@ -968,36 +1061,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( @@ -1060,16 +1149,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(); + } } } } @@ -1186,6 +1277,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/project_tests.rs b/crates/project/src/project_tests.rs index 4a724809c2c4196be49122e7065abe8ec8f139a7..4cebc72073cfda1bf07f028b1aff9fa7410c527d 100644 --- a/crates/project/src/project_tests.rs +++ b/crates/project/src/project_tests.rs @@ -691,7 +691,7 @@ async fn test_running_multiple_instances_of_a_single_server_in_one_worktree( let servers = project.update(cx, |project, cx| { project.lsp_store.update(cx, |this, cx| { first_buffer.update(cx, |buffer, cx| { - this.language_servers_for_local_buffer(buffer, cx) + this.running_language_servers_for_local_buffer(buffer, cx) .map(|(adapter, server)| (adapter.clone(), server.clone())) .collect::>() }) @@ -720,7 +720,7 @@ async fn test_running_multiple_instances_of_a_single_server_in_one_worktree( let servers = project.update(cx, |project, cx| { project.lsp_store.update(cx, |this, cx| { second_project_buffer.update(cx, |buffer, cx| { - this.language_servers_for_local_buffer(buffer, cx) + this.running_language_servers_for_local_buffer(buffer, cx) .map(|(adapter, server)| (adapter.clone(), server.clone())) .collect::>() }) @@ -791,7 +791,7 @@ async fn test_running_multiple_instances_of_a_single_server_in_one_worktree( let servers = project.update(cx, |project, cx| { project.lsp_store.update(cx, |this, cx| { second_project_buffer.update(cx, |buffer, cx| { - this.language_servers_for_local_buffer(buffer, cx) + this.running_language_servers_for_local_buffer(buffer, cx) .map(|(adapter, server)| (adapter.clone(), server.clone())) .collect::>() }) 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/src/worktree_store.rs b/crates/project/src/worktree_store.rs index 676c96f4331d73b87d4bc16766a5f6c4d6194864..7c3eabd609c5efd79d506e8c62384bcb6cc16b52 100644 --- a/crates/project/src/worktree_store.rs +++ b/crates/project/src/worktree_store.rs @@ -57,6 +57,7 @@ pub struct WorktreeStore { retain_worktrees: bool, worktrees: Vec, worktrees_reordered: bool, + scanning_enabled: bool, #[allow(clippy::type_complexity)] loading_worktrees: HashMap, Shared, Arc>>>>, @@ -93,6 +94,7 @@ impl WorktreeStore { downstream_client: None, worktrees: Vec::new(), worktrees_reordered: false, + scanning_enabled: true, retain_worktrees, state: WorktreeStoreState::Local { fs }, } @@ -110,6 +112,7 @@ impl WorktreeStore { downstream_client: None, worktrees: Vec::new(), worktrees_reordered: false, + scanning_enabled: true, retain_worktrees, state: WorktreeStoreState::Remote { upstream_client, @@ -119,6 +122,10 @@ impl WorktreeStore { } } + pub fn disable_scanner(&mut self) { + self.scanning_enabled = false; + } + /// Iterates through all worktrees, including ones that don't appear in the project panel pub fn worktrees(&self) -> impl '_ + DoubleEndedIterator> { self.worktrees @@ -576,6 +583,7 @@ impl WorktreeStore { cx: &mut Context, ) -> Task, Arc>> { let next_entry_id = self.next_entry_id.clone(); + let scanning_enabled = self.scanning_enabled; cx.spawn(async move |this, cx| { let worktree = Worktree::local( @@ -583,6 +591,7 @@ impl WorktreeStore { visible, fs, next_entry_id, + scanning_enabled, cx, ) .await; 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 9fde01f64169279f1683979cfd77ce5594cb54a3..3211cc7af0f083f9b07edcdb439e7075f774bdcd 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -891,7 +891,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); } } } @@ -1153,7 +1153,7 @@ impl ProjectPanel { ) .when(has_git_repo, |menu| { menu.separator() - .action("File History", Box::new(git::FileHistory)) + .action("View File History", Box::new(git::FileHistory)) }) .when(!should_hide_rename, |menu| { menu.separator().action("Rename", Box::new(Rename)) @@ -1183,7 +1183,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(); @@ -1390,7 +1390,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(); } } @@ -1413,7 +1413,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(); } } @@ -1677,12 +1677,20 @@ impl ProjectPanel { let edit_state = self.state.edit_state.as_mut()?; let worktree_id = edit_state.worktree_id; let is_new_entry = edit_state.is_new_entry(); - let filename = self.filename_editor.read(cx).text(cx); + let mut filename = self.filename_editor.read(cx).text(cx); + let path_style = self.project.read(cx).path_style(cx); + if path_style.is_windows() { + // on windows, trailing dots are ignored in paths + // this can cause project panel to create a new entry with a trailing dot + // while the actual one without the dot gets populated by the file watcher + while let Some(trimmed) = filename.strip_suffix('.') { + filename = trimmed.to_string(); + } + } if filename.trim().is_empty() { return None; } - let path_style = self.project.read(cx).path_style(cx); let filename_indicates_dir = if path_style.is_windows() { filename.ends_with('/') || filename.ends_with('\\') } else { @@ -1725,7 +1733,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; } @@ -1738,7 +1746,7 @@ impl ProjectPanel { let edit_state = self.state.edit_state.as_mut()?; if refocus { - window.focus(&self.focus_handle); + window.focus(&self.focus_handle, cx); } edit_state.processing_filename = Some(filename); cx.notify(); @@ -1847,7 +1855,7 @@ impl ProjectPanel { self.autoscroll(cx); } - window.focus(&self.focus_handle); + window.focus(&self.focus_handle, cx); cx.notify(); } @@ -3719,7 +3727,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 { @@ -6057,7 +6065,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/project_panel/src/project_panel_settings.rs b/crates/project_panel/src/project_panel_settings.rs index b0316270340203177278edebaececd0d86e39869..5d498da0f9d519bc25d738bcf9368c394bbdabfd 100644 --- a/crates/project_panel/src/project_panel_settings.rs +++ b/crates/project_panel/src/project_panel_settings.rs @@ -92,7 +92,13 @@ impl Settings for ProjectPanelSettings { entry_spacing: project_panel.entry_spacing.unwrap(), file_icons: project_panel.file_icons.unwrap(), folder_icons: project_panel.folder_icons.unwrap(), - git_status: project_panel.git_status.unwrap(), + git_status: project_panel.git_status.unwrap() + && content + .git + .unwrap() + .enabled + .unwrap() + .is_git_status_enabled(), indent_size: project_panel.indent_size.unwrap(), indent_guides: IndentGuidesSettings { show: project_panel.indent_guides.unwrap().show.unwrap(), diff --git a/crates/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs index 37e2a588e5a0ffd937689c0084d0950d8159cc92..3e25741570079e37cfdbcaa1a1cdafb27996c14f 100644 --- a/crates/project_panel/src/project_panel_tests.rs +++ b/crates/project_panel/src/project_panel_tests.rs @@ -6662,6 +6662,74 @@ async fn test_create_entries_without_selection_hide_root(cx: &mut gpui::TestAppC ); } +#[cfg(windows)] +#[gpui::test] +async fn test_create_entry_with_trailing_dot_windows(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/root"), + json!({ + "dir1": { + "file1.txt": "", + }, + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let panel = workspace + .update(cx, |workspace, window, cx| { + let panel = ProjectPanel::new(workspace, window, cx); + workspace.add_panel(panel.clone(), window, cx); + panel + }) + .unwrap(); + cx.run_until_parked(); + + #[rustfmt::skip] + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v root", + " > dir1", + ], + "Initial state with nothing selected" + ); + + panel.update_in(cx, |panel, window, cx| { + panel.new_file(&NewFile, window, cx); + }); + cx.run_until_parked(); + panel.update_in(cx, |panel, window, cx| { + assert!(panel.filename_editor.read(cx).is_focused(window)); + }); + panel + .update_in(cx, |panel, window, cx| { + panel + .filename_editor + .update(cx, |editor, cx| editor.set_text("foo.", window, cx)); + panel.confirm_edit(true, window, cx).unwrap() + }) + .await + .unwrap(); + cx.run_until_parked(); + #[rustfmt::skip] + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v root", + " > dir1", + " foo <== selected <== marked", + ], + "A new file is created under the root directory without the trailing dot" + ); +} + #[gpui::test] async fn test_highlight_entry_for_external_drag(cx: &mut gpui::TestAppContext) { init_test(cx); diff --git a/crates/prompt_store/Cargo.toml b/crates/prompt_store/Cargo.toml index 13bacbfad3bf2b5deb4a20af866f37dad47288ff..a7df9d13ee82da62838175029b9bdfd7c9375508 100644 --- a/crates/prompt_store/Cargo.toml +++ b/crates/prompt_store/Cargo.toml @@ -28,6 +28,11 @@ parking_lot.workspace = true paths.workspace = true rope.workspace = true serde.workspace = true +strum.workspace = true text.workspace = true util.workspace = true uuid.workspace = true + +[dev-dependencies] +gpui = { workspace = true, features = ["test-support"] } +tempfile.workspace = true diff --git a/crates/prompt_store/src/prompt_store.rs b/crates/prompt_store/src/prompt_store.rs index fb087ce34d6d67fe4ea11a33f554307ed558c18a..2c45410c2aa172c8a4f7118a914cacca69ea7ca8 100644 --- a/crates/prompt_store/src/prompt_store.rs +++ b/crates/prompt_store/src/prompt_store.rs @@ -1,6 +1,6 @@ mod prompts; -use anyhow::{Context as _, Result, anyhow}; +use anyhow::{Result, anyhow}; use chrono::{DateTime, Utc}; use collections::HashMap; use futures::FutureExt as _; @@ -23,6 +23,7 @@ use std::{ path::PathBuf, sync::{Arc, atomic::AtomicBool}, }; +use strum::{EnumIter, IntoEnumIterator as _}; use text::LineEnding; use util::ResultExt; use uuid::Uuid; @@ -51,11 +52,51 @@ pub struct PromptMetadata { pub saved_at: DateTime, } +impl PromptMetadata { + fn builtin(builtin: BuiltInPrompt) -> Self { + Self { + id: PromptId::BuiltIn(builtin), + title: Some(builtin.title().into()), + default: false, + saved_at: DateTime::default(), + } + } +} + +/// Built-in prompts that have default content and can be customized by users. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, EnumIter)] +pub enum BuiltInPrompt { + CommitMessage, +} + +impl BuiltInPrompt { + pub fn title(&self) -> &'static str { + match self { + Self::CommitMessage => "Commit message", + } + } + + /// Returns the default content for this built-in prompt. + pub fn default_content(&self) -> &'static str { + match self { + Self::CommitMessage => include_str!("../../git_ui/src/commit_message_prompt.txt"), + } + } +} + +impl std::fmt::Display for BuiltInPrompt { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::CommitMessage => write!(f, "Commit message"), + } + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(tag = "kind")] pub enum PromptId { User { uuid: UserPromptId }, - EditWorkflow, + BuiltIn(BuiltInPrompt), } impl PromptId { @@ -63,8 +104,37 @@ impl PromptId { UserPromptId::new().into() } + pub fn as_user(&self) -> Option { + match self { + Self::User { uuid } => Some(*uuid), + Self::BuiltIn { .. } => None, + } + } + + pub fn as_built_in(&self) -> Option { + match self { + Self::User { .. } => None, + Self::BuiltIn(builtin) => Some(*builtin), + } + } + pub fn is_built_in(&self) -> bool { - !matches!(self, PromptId::User { .. }) + matches!(self, Self::BuiltIn { .. }) + } + + pub fn can_edit(&self) -> bool { + match self { + Self::User { .. } => true, + Self::BuiltIn(builtin) => match builtin { + BuiltInPrompt::CommitMessage => true, + }, + } + } +} + +impl From for PromptId { + fn from(builtin: BuiltInPrompt) -> Self { + PromptId::BuiltIn(builtin) } } @@ -94,7 +164,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::BuiltIn(builtin) => write!(f, "{}", builtin), } } } @@ -127,6 +197,16 @@ impl MetadataCache { cache.metadata.push(metadata.clone()); cache.metadata_by_id.insert(prompt_id, metadata); } + + // Insert all the built-in prompts that were not customized by the user + for builtin in BuiltInPrompt::iter() { + let builtin_id = PromptId::BuiltIn(builtin); + if !cache.metadata_by_id.contains_key(&builtin_id) { + let metadata = PromptMetadata::builtin(builtin); + cache.metadata.push(metadata.clone()); + cache.metadata_by_id.insert(builtin_id, metadata); + } + } cache.sort(); Ok(cache) } @@ -175,12 +255,6 @@ impl PromptStore { let mut txn = db_env.write_txn()?; 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(); - txn.commit()?; Self::upgrade_dbs(&db_env, metadata, bodies).log_err(); @@ -273,7 +347,16 @@ impl PromptStore { let bodies = self.bodies; cx.background_spawn(async move { let txn = env.read_txn()?; - let mut prompt = bodies.get(&txn, &id)?.context("prompt not found")?.into(); + let mut prompt: String = match bodies.get(&txn, &id)? { + Some(body) => body.into(), + None => { + if let Some(built_in) = id.as_built_in() { + built_in.default_content().into() + } else { + anyhow::bail!("prompt not found") + } + } + }; LineEnding::normalize(&mut prompt); Ok(prompt) }) @@ -318,11 +401,6 @@ impl PromptStore { }) } - /// Returns the number of prompts in the store. - pub fn prompt_count(&self) -> usize { - self.metadata_cache.read().metadata.len() - } - pub fn metadata(&self, id: PromptId) -> Option { self.metadata_cache.read().metadata_by_id.get(&id).cloned() } @@ -387,27 +465,42 @@ 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 { - id, - title, - default, - saved_at: Utc::now(), + let body = body.to_string(); + let is_default_content = id + .as_built_in() + .is_some_and(|builtin| body.trim() == builtin.default_content().trim()); + + let metadata = if let Some(builtin) = id.as_built_in() { + PromptMetadata::builtin(builtin) + } else { + PromptMetadata { + id, + title, + default, + saved_at: Utc::now(), + } }; - self.metadata_cache.write().insert(prompt_metadata.clone()); + + self.metadata_cache.write().insert(metadata.clone()); let db_connection = self.env.clone(); let bodies = self.bodies; - let metadata = self.metadata; + let metadata_db = self.metadata; let task = cx.background_spawn(async move { let mut txn = db_connection.write_txn()?; - metadata.put(&mut txn, &id, &prompt_metadata)?; - bodies.put(&mut txn, &id, &body.to_string())?; + if is_default_content { + metadata_db.delete(&mut txn, &id)?; + bodies.delete(&mut txn, &id)?; + } else { + metadata_db.put(&mut txn, &id, &metadata)?; + bodies.put(&mut txn, &id, &body)?; + } txn.commit()?; @@ -430,7 +523,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) @@ -469,3 +562,122 @@ impl PromptStore { pub struct GlobalPromptStore(Shared, Arc>>>); impl Global for GlobalPromptStore {} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::TestAppContext; + + #[gpui::test] + async fn test_built_in_prompt_load_save(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + + let temp_dir = tempfile::tempdir().unwrap(); + let db_path = temp_dir.path().join("prompts-db"); + + let store = cx.update(|cx| PromptStore::new(db_path, cx)).await.unwrap(); + let store = cx.new(|_cx| store); + + let commit_message_id = PromptId::BuiltIn(BuiltInPrompt::CommitMessage); + + let loaded_content = store + .update(cx, |store, cx| store.load(commit_message_id, cx)) + .await + .unwrap(); + + let mut expected_content = BuiltInPrompt::CommitMessage.default_content().to_string(); + LineEnding::normalize(&mut expected_content); + assert_eq!( + loaded_content.trim(), + expected_content.trim(), + "Loading a built-in prompt not in DB should return default content" + ); + + let metadata = store.read_with(cx, |store, _| store.metadata(commit_message_id)); + assert!( + metadata.is_some(), + "Built-in prompt should always have metadata" + ); + assert!( + store.read_with(cx, |store, _| { + store + .metadata_cache + .read() + .metadata_by_id + .contains_key(&commit_message_id) + }), + "Built-in prompt should always be in cache" + ); + + let custom_content = "Custom commit message prompt"; + store + .update(cx, |store, cx| { + store.save( + commit_message_id, + Some("Commit message".into()), + false, + Rope::from(custom_content), + cx, + ) + }) + .await + .unwrap(); + + let loaded_custom = store + .update(cx, |store, cx| store.load(commit_message_id, cx)) + .await + .unwrap(); + assert_eq!( + loaded_custom.trim(), + custom_content.trim(), + "Custom content should be loaded after saving" + ); + + assert!( + store + .read_with(cx, |store, _| store.metadata(commit_message_id)) + .is_some(), + "Built-in prompt should have metadata after customization" + ); + + store + .update(cx, |store, cx| { + store.save( + commit_message_id, + Some("Commit message".into()), + false, + Rope::from(BuiltInPrompt::CommitMessage.default_content()), + cx, + ) + }) + .await + .unwrap(); + + let metadata_after_reset = + store.read_with(cx, |store, _| store.metadata(commit_message_id)); + assert!( + metadata_after_reset.is_some(), + "Built-in prompt should still have metadata after reset" + ); + assert_eq!( + metadata_after_reset + .as_ref() + .and_then(|m| m.title.as_ref().map(|t| t.as_ref())), + Some("Commit message"), + "Built-in prompt should have default title after reset" + ); + + let loaded_after_reset = store + .update(cx, |store, cx| store.load(commit_message_id, cx)) + .await + .unwrap(); + let mut expected_content_after_reset = + BuiltInPrompt::CommitMessage.default_content().to_string(); + LineEnding::normalize(&mut expected_content_after_reset); + assert_eq!( + loaded_after_reset.trim(), + expected_content_after_reset.trim(), + "After saving default content, load should return default" + ); + } +} diff --git a/crates/prompt_store/src/prompts.rs b/crates/prompt_store/src/prompts.rs index d6a172218a8eb3d4538363e6202a7e721d2b7bc1..6a845bb8dd394f8a1ff26a8a0e130156a2a158bd 100644 --- a/crates/prompt_store/src/prompts.rs +++ b/crates/prompt_store/src/prompts.rs @@ -20,6 +20,18 @@ use util::{ use crate::UserPromptId; +pub const RULES_FILE_NAMES: &[&str] = &[ + ".rules", + ".cursorrules", + ".windsurfrules", + ".clinerules", + ".github/copilot-instructions.md", + "CLAUDE.md", + "AGENT.md", + "AGENTS.md", + "GEMINI.md", +]; + #[derive(Default, Debug, Clone, Serialize)] pub struct ProjectContext { pub worktrees: Vec, @@ -100,7 +112,7 @@ pub struct ContentPromptContextV2 { pub language_name: Option, pub is_truncated: bool, pub document_content: String, - pub rewrite_section: Option, + pub rewrite_section: String, pub diagnostic_errors: Vec, } @@ -286,7 +298,7 @@ impl PromptBuilder { Ok(()) } - pub fn generate_inline_transformation_prompt_v2( + pub fn generate_inline_transformation_prompt_tools( &self, language_name: Option<&LanguageName>, buffer: BufferSnapshot, @@ -298,7 +310,6 @@ impl PromptBuilder { }; const MAX_CTX: usize = 50000; - let is_insert = range.is_empty(); let mut is_truncated = false; let before_range = 0..range.start; @@ -323,28 +334,19 @@ impl PromptBuilder { for chunk in buffer.text_for_range(truncated_before) { document_content.push_str(chunk); } - if is_insert { - document_content.push_str(""); - } else { - document_content.push_str("\n"); - for chunk in buffer.text_for_range(range.clone()) { - document_content.push_str(chunk); - } - document_content.push_str("\n"); + + document_content.push_str("\n"); + for chunk in buffer.text_for_range(range.clone()) { + document_content.push_str(chunk); } + document_content.push_str("\n"); + for chunk in buffer.text_for_range(truncated_after) { document_content.push_str(chunk); } - let rewrite_section = if !is_insert { - let mut section = String::new(); - for chunk in buffer.text_for_range(range.clone()) { - section.push_str(chunk); - } - Some(section) - } else { - None - }; + let rewrite_section: String = buffer.text_for_range(range.clone()).collect(); + let diagnostics = buffer.diagnostics_in_range::<_, Point>(range, false); let diagnostic_errors: Vec = diagnostics .map(|entry| { diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index 6e3573b91a690290b71e626f3bd67fc81d8d8e92..d1e56f4f8c89e655dc0e153be013903d48afc99f 100644 --- a/crates/proto/proto/git.proto +++ b/crates/proto/proto/git.proto @@ -580,7 +580,7 @@ message GitCreateWorktree { message RunGitHook { enum GitHook { PRE_COMMIT = 0; - PRE_PUSH = 1; + reserved 1; } uint64 project_id = 1; 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/Cargo.toml b/crates/recent_projects/Cargo.toml index abaeafa335fd48991da46268ccd59450e908528c..feaf511b81c73bbf50aae6387b3114b1d96f04c4 100644 --- a/crates/recent_projects/Cargo.toml +++ b/crates/recent_projects/Cargo.toml @@ -16,6 +16,7 @@ doctest = false anyhow.workspace = true askpass.workspace = true auto_update.workspace = true +db.workspace = true editor.workspace = true extension_host.workspace = true file_finder.workspace = true @@ -26,6 +27,7 @@ language.workspace = true log.workspace = true markdown.workspace = true menu.workspace = true +node_runtime.workspace = true ordered-float.workspace = true paths.workspace = true picker.workspace = true @@ -34,6 +36,7 @@ release_channel.workspace = true remote.workspace = true semver.workspace = true serde.workspace = true +serde_json.workspace = true settings.workspace = true smol.workspace = true task.workspace = true @@ -42,6 +45,7 @@ theme.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true +worktree.workspace = true zed_actions.workspace = true indoc.workspace = true diff --git a/crates/recent_projects/src/dev_container.rs b/crates/recent_projects/src/dev_container.rs new file mode 100644 index 0000000000000000000000000000000000000000..0e6b8b381df32d688e062948460707a5f8cfb552 --- /dev/null +++ b/crates/recent_projects/src/dev_container.rs @@ -0,0 +1,295 @@ +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use gpui::AsyncWindowContext; +use node_runtime::NodeRuntime; +use serde::Deserialize; +use settings::DevContainerConnection; +use smol::fs; +use workspace::Workspace; + +use crate::remote_connections::Connection; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DevContainerUp { + _outcome: String, + container_id: String, + _remote_user: String, + remote_workspace_folder: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DevContainerConfiguration { + name: Option, +} + +#[derive(Debug, Deserialize)] +struct DevContainerConfigurationOutput { + configuration: DevContainerConfiguration, +} + +#[cfg(not(target_os = "windows"))] +fn dev_container_cli() -> String { + "devcontainer".to_string() +} + +#[cfg(target_os = "windows")] +fn dev_container_cli() -> String { + "devcontainer.cmd".to_string() +} + +async fn check_for_docker() -> Result<(), DevContainerError> { + let mut command = util::command::new_smol_command("docker"); + command.arg("--version"); + + match command.output().await { + Ok(_) => Ok(()), + Err(e) => { + log::error!("Unable to find docker in $PATH: {:?}", e); + Err(DevContainerError::DockerNotAvailable) + } + } +} + +async fn ensure_devcontainer_cli(node_runtime: NodeRuntime) -> Result { + let mut command = util::command::new_smol_command(&dev_container_cli()); + command.arg("--version"); + + if let Err(e) = command.output().await { + log::error!( + "Unable to find devcontainer CLI in $PATH. Checking for a zed installed version. Error: {:?}", + e + ); + + let datadir_cli_path = paths::devcontainer_dir() + .join("node_modules") + .join(".bin") + .join(&dev_container_cli()); + + let mut command = + util::command::new_smol_command(&datadir_cli_path.as_os_str().display().to_string()); + command.arg("--version"); + + if let Err(e) = command.output().await { + log::error!( + "Unable to find devcontainer CLI in Data dir. Will try to install. Error: {:?}", + e + ); + } else { + log::info!("Found devcontainer CLI in Data dir"); + return Ok(datadir_cli_path.clone()); + } + + if let Err(e) = fs::create_dir_all(paths::devcontainer_dir()).await { + log::error!("Unable to create devcontainer directory. Error: {:?}", e); + return Err(DevContainerError::DevContainerCliNotAvailable); + } + + if let Err(e) = node_runtime + .npm_install_packages( + &paths::devcontainer_dir(), + &[("@devcontainers/cli", "latest")], + ) + .await + { + log::error!( + "Unable to install devcontainer CLI to data directory. Error: {:?}", + e + ); + return Err(DevContainerError::DevContainerCliNotAvailable); + }; + + let mut command = util::command::new_smol_command(&datadir_cli_path.display().to_string()); + command.arg("--version"); + if let Err(e) = command.output().await { + log::error!( + "Unable to find devcontainer cli after NPM install. Error: {:?}", + e + ); + Err(DevContainerError::DevContainerCliNotAvailable) + } else { + Ok(datadir_cli_path) + } + } else { + log::info!("Found devcontainer cli on $PATH, using it"); + Ok(PathBuf::from(&dev_container_cli())) + } +} + +async fn devcontainer_up( + path_to_cli: &PathBuf, + path: Arc, +) -> Result { + let mut command = util::command::new_smol_command(path_to_cli.display().to_string()); + command.arg("up"); + command.arg("--workspace-folder"); + command.arg(path.display().to_string()); + + match command.output().await { + Ok(output) => { + if output.status.success() { + let raw = String::from_utf8_lossy(&output.stdout); + serde_json::from_str::(&raw).map_err(|e| { + log::error!( + "Unable to parse response from 'devcontainer up' command, error: {:?}", + e + ); + DevContainerError::DevContainerParseFailed + }) + } else { + log::error!( + "Non-success status running devcontainer up for workspace: out: {:?}, err: {:?}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + Err(DevContainerError::DevContainerUpFailed) + } + } + Err(e) => { + log::error!("Error running devcontainer up: {:?}", e); + Err(DevContainerError::DevContainerUpFailed) + } + } +} + +async fn devcontainer_read_configuration( + path_to_cli: &PathBuf, + path: Arc, +) -> Result { + let mut command = util::command::new_smol_command(path_to_cli.display().to_string()); + command.arg("read-configuration"); + command.arg("--workspace-folder"); + command.arg(path.display().to_string()); + match command.output().await { + Ok(output) => { + if output.status.success() { + let raw = String::from_utf8_lossy(&output.stdout); + serde_json::from_str::(&raw).map_err(|e| { + log::error!( + "Unable to parse response from 'devcontainer read-configuration' command, error: {:?}", + e + ); + DevContainerError::DevContainerParseFailed + }) + } else { + log::error!( + "Non-success status running devcontainer read-configuration for workspace: out: {:?}, err: {:?}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + Err(DevContainerError::DevContainerUpFailed) + } + } + Err(e) => { + log::error!("Error running devcontainer read-configuration: {:?}", e); + Err(DevContainerError::DevContainerUpFailed) + } + } +} + +// Name the project with two fallbacks +async fn get_project_name( + path_to_cli: &PathBuf, + path: Arc, + remote_workspace_folder: String, + container_id: String, +) -> Result { + if let Ok(dev_container_configuration) = + devcontainer_read_configuration(path_to_cli, path).await + && let Some(name) = dev_container_configuration.configuration.name + { + // Ideally, name the project after the name defined in devcontainer.json + Ok(name) + } else { + // Otherwise, name the project after the remote workspace folder name + Ok(Path::new(&remote_workspace_folder) + .file_name() + .and_then(|name| name.to_str()) + .map(|string| string.into()) + // Finally, name the project after the container ID as a last resort + .unwrap_or_else(|| container_id.clone())) + } +} + +fn project_directory(cx: &mut AsyncWindowContext) -> Option> { + let Some(workspace) = cx.window_handle().downcast::() else { + return None; + }; + + match workspace.update(cx, |workspace, _, cx| { + workspace.project().read(cx).active_project_directory(cx) + }) { + Ok(dir) => dir, + Err(e) => { + log::error!("Error getting project directory from workspace: {:?}", e); + None + } + } +} + +pub(crate) async fn start_dev_container( + cx: &mut AsyncWindowContext, + node_runtime: NodeRuntime, +) -> Result<(Connection, String), DevContainerError> { + check_for_docker().await?; + + let path_to_devcontainer_cli = ensure_devcontainer_cli(node_runtime).await?; + + let Some(directory) = project_directory(cx) else { + return Err(DevContainerError::DevContainerNotFound); + }; + + if let Ok(DevContainerUp { + container_id, + remote_workspace_folder, + .. + }) = devcontainer_up(&path_to_devcontainer_cli, directory.clone()).await + { + let project_name = get_project_name( + &path_to_devcontainer_cli, + directory, + remote_workspace_folder.clone(), + container_id.clone(), + ) + .await?; + + let connection = Connection::DevContainer(DevContainerConnection { + name: project_name.into(), + container_id: container_id.into(), + }); + + Ok((connection, remote_workspace_folder)) + } else { + Err(DevContainerError::DevContainerUpFailed) + } +} + +#[derive(Debug)] +pub(crate) enum DevContainerError { + DockerNotAvailable, + DevContainerCliNotAvailable, + DevContainerUpFailed, + DevContainerNotFound, + DevContainerParseFailed, +} + +#[cfg(test)] +mod test { + + use crate::dev_container::DevContainerUp; + + #[test] + fn should_parse_from_devcontainer_json() { + let json = r#"{"outcome":"success","containerId":"826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a","remoteUser":"vscode","remoteWorkspaceFolder":"/workspaces/zed"}"#; + let up: DevContainerUp = serde_json::from_str(json).unwrap(); + assert_eq!(up._outcome, "success"); + assert_eq!( + up.container_id, + "826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a" + ); + assert_eq!(up._remote_user, "vscode"); + assert_eq!(up.remote_workspace_folder, "/workspaces/zed"); + } +} diff --git a/crates/recent_projects/src/dev_container_suggest.rs b/crates/recent_projects/src/dev_container_suggest.rs new file mode 100644 index 0000000000000000000000000000000000000000..1e50080ea15fad714d17e1648b72455b3d401a7a --- /dev/null +++ b/crates/recent_projects/src/dev_container_suggest.rs @@ -0,0 +1,106 @@ +use db::kvp::KEY_VALUE_STORE; +use gpui::{SharedString, Window}; +use project::{Project, WorktreeId}; +use std::sync::LazyLock; +use ui::prelude::*; +use util::rel_path::RelPath; +use workspace::Workspace; +use workspace::notifications::NotificationId; +use workspace::notifications::simple_message_notification::MessageNotification; +use worktree::UpdatedEntriesSet; + +const DEV_CONTAINER_SUGGEST_KEY: &str = "dev_container_suggest_dismissed"; + +fn devcontainer_path() -> &'static RelPath { + static PATH: LazyLock<&'static RelPath> = + LazyLock::new(|| RelPath::unix(".devcontainer").expect("valid path")); + *PATH +} + +fn project_devcontainer_key(project_path: &str) -> String { + format!("{}_{}", DEV_CONTAINER_SUGGEST_KEY, project_path) +} + +pub fn suggest_on_worktree_updated( + worktree_id: WorktreeId, + updated_entries: &UpdatedEntriesSet, + project: &gpui::Entity, + window: &mut Window, + cx: &mut Context, +) { + let devcontainer_updated = updated_entries + .iter() + .any(|(path, _, _)| path.as_ref() == devcontainer_path()); + + if !devcontainer_updated { + return; + } + + let Some(worktree) = project.read(cx).worktree_for_id(worktree_id, cx) else { + return; + }; + + let worktree = worktree.read(cx); + + if !worktree.is_local() { + return; + } + + let has_devcontainer = worktree + .entry_for_path(devcontainer_path()) + .is_some_and(|entry| entry.is_dir()); + + if !has_devcontainer { + return; + } + + let abs_path = worktree.abs_path(); + let project_path = abs_path.to_string_lossy().to_string(); + let key_for_dismiss = project_devcontainer_key(&project_path); + + let already_dismissed = KEY_VALUE_STORE + .read_kvp(&key_for_dismiss) + .ok() + .flatten() + .is_some(); + + if already_dismissed { + return; + } + + cx.on_next_frame(window, move |workspace, _window, cx| { + struct DevContainerSuggestionNotification; + + let notification_id = NotificationId::composite::( + SharedString::from(project_path.clone()), + ); + + workspace.show_notification(notification_id, cx, |cx| { + cx.new(move |cx| { + MessageNotification::new( + "This project contains a Dev Container configuration file. Would you like to re-open it in a container?", + cx, + ) + .primary_message("Yes, Open in Container") + .primary_icon(IconName::Check) + .primary_icon_color(Color::Success) + .primary_on_click({ + move |window, cx| { + window.dispatch_action(Box::new(zed_actions::OpenDevContainer), cx); + } + }) + .secondary_message("Don't Show Again") + .secondary_icon(IconName::Close) + .secondary_icon_color(Color::Error) + .secondary_on_click({ + move |_window, cx| { + let key = key_for_dismiss.clone(); + db::write_and_log(cx, move || { + KEY_VALUE_STORE.write_kvp(key, "dismissed".to_string()) + }); + } + }) + }) + }); + }); +} diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index 7647dc1ed46cb9d87c7f889188f834dcbd3a456a..435933a880123c00d3f3fbaaea2c54f6554f0d3b 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -1,8 +1,12 @@ +mod dev_container; +mod dev_container_suggest; pub mod disconnected_overlay; mod remote_connections; mod remote_servers; mod ssh_config; +use std::path::PathBuf; + #[cfg(target_os = "windows")] mod wsl_picker; @@ -31,7 +35,7 @@ use workspace::{ WORKSPACE_DB, Workspace, WorkspaceId, notifications::DetachAndPromptErr, with_active_or_new_workspace, }; -use zed_actions::{OpenRecent, OpenRemote}; +use zed_actions::{OpenDevContainer, OpenRecent, OpenRemote}; pub fn init(cx: &mut App) { #[cfg(target_os = "windows")] @@ -161,6 +165,95 @@ pub fn init(cx: &mut App) { }); cx.observe_new(DisconnectedOverlay::register).detach(); + + cx.on_action(|_: &OpenDevContainer, cx| { + with_active_or_new_workspace(cx, move |workspace, window, cx| { + let app_state = workspace.app_state().clone(); + let replace_window = window.window_handle().downcast::(); + + cx.spawn_in(window, async move |_, mut cx| { + let (connection, starting_dir) = match dev_container::start_dev_container( + &mut cx, + app_state.node_runtime.clone(), + ) + .await + { + Ok((c, s)) => (c, s), + Err(e) => { + log::error!("Failed to start Dev Container: {:?}", e); + cx.prompt( + gpui::PromptLevel::Critical, + "Failed to start Dev Container", + Some(&format!("{:?}", e)), + &["Ok"], + ) + .await + .ok(); + return; + } + }; + + let result = open_remote_project( + connection.into(), + vec![starting_dir].into_iter().map(PathBuf::from).collect(), + app_state, + OpenOptions { + replace_window, + ..OpenOptions::default() + }, + &mut cx, + ) + .await; + + if let Err(e) = result { + log::error!("Failed to connect: {e:#}"); + cx.prompt( + gpui::PromptLevel::Critical, + "Failed to connect", + Some(&e.to_string()), + &["Ok"], + ) + .await + .ok(); + } + }) + .detach(); + + let fs = workspace.project().read(cx).fs().clone(); + let handle = cx.entity().downgrade(); + workspace.toggle_modal(window, cx, |window, cx| { + RemoteServerProjects::new_dev_container(fs, window, handle, cx) + }); + }); + }); + + // Subscribe to worktree additions to suggest opening the project in a dev container + cx.observe_new( + |workspace: &mut Workspace, window: Option<&mut Window>, cx: &mut Context| { + let Some(window) = window else { + return; + }; + cx.subscribe_in( + workspace.project(), + window, + move |_, project, event, window, cx| { + if let project::Event::WorktreeUpdatedEntries(worktree_id, updated_entries) = + event + { + dev_container_suggest::suggest_on_worktree_updated( + *worktree_id, + updated_entries, + project, + window, + cx, + ); + } + }, + ) + .detach(); + }, + ) + .detach(); } #[cfg(target_os = "windows")] @@ -609,6 +702,7 @@ impl PickerDelegate for RecentProjectsDelegate { Icon::new(match options { RemoteConnectionOptions::Ssh { .. } => IconName::Server, RemoteConnectionOptions::Wsl { .. } => IconName::Linux, + RemoteConnectionOptions::Docker(_) => IconName::Box, }) .color(Color::Muted) .into_any_element() diff --git a/crates/recent_projects/src/remote_connections.rs b/crates/recent_projects/src/remote_connections.rs index 562fcccb204212fb43e0b9457b1c08bdb15c3772..1bab31b4d0ebb80444c40c99feb984ebd23feb60 100644 --- a/crates/recent_projects/src/remote_connections.rs +++ b/crates/recent_projects/src/remote_connections.rs @@ -16,18 +16,19 @@ use gpui::{ use language::{CursorShape, Point}; use markdown::{Markdown, MarkdownElement, MarkdownStyle}; +use project::trusted_worktrees; use release_channel::ReleaseChannel; use remote::{ - ConnectionIdentifier, RemoteClient, RemoteConnection, RemoteConnectionOptions, RemotePlatform, - SshConnectionOptions, + ConnectionIdentifier, DockerConnectionOptions, RemoteClient, RemoteConnection, + RemoteConnectionOptions, RemotePlatform, SshConnectionOptions, }; use semver::Version; pub use settings::SshConnection; -use settings::{ExtendingVec, RegisterSetting, Settings, WslConnection}; +use settings::{DevContainerConnection, ExtendingVec, RegisterSetting, Settings, WslConnection}; use theme::ThemeSettings; use ui::{ - ActiveTheme, Color, CommonAnimationExt, Context, Icon, IconName, IconSize, InteractiveElement, - IntoElement, Label, LabelCommon, Styled, Window, prelude::*, + ActiveTheme, Color, CommonAnimationExt, Context, InteractiveElement, IntoElement, KeyBinding, + LabelCommon, ListItem, Styled, Window, prelude::*, }; use util::paths::PathWithPosition; use workspace::{AppState, ModalView, Workspace}; @@ -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() @@ -85,6 +86,7 @@ impl SshSettings { pub enum Connection { Ssh(SshConnection), Wsl(WslConnection), + DevContainer(DevContainerConnection), } impl From for RemoteConnectionOptions { @@ -92,6 +94,13 @@ impl From for RemoteConnectionOptions { match val { Connection::Ssh(conn) => RemoteConnectionOptions::Ssh(conn.into()), Connection::Wsl(conn) => RemoteConnectionOptions::Wsl(conn.into()), + Connection::DevContainer(conn) => { + RemoteConnectionOptions::Docker(DockerConnectionOptions { + name: conn.name.to_string(), + container_id: conn.container_id.to_string(), + upload_binary_over_docker_exec: false, + }) + } } } } @@ -123,6 +132,7 @@ pub struct RemoteConnectionPrompt { connection_string: SharedString, nickname: Option, is_wsl: bool, + is_devcontainer: bool, status_message: Option, prompt: Option<(Entity, oneshot::Sender)>, cancellation: Option>, @@ -148,6 +158,7 @@ impl RemoteConnectionPrompt { connection_string: String, nickname: Option, is_wsl: bool, + is_devcontainer: bool, window: &mut Window, cx: &mut Context, ) -> Self { @@ -155,6 +166,7 @@ impl RemoteConnectionPrompt { connection_string: connection_string.into(), nickname: nickname.map(|nickname| nickname.into()), is_wsl, + is_devcontainer, editor: cx.new(|cx| Editor::single_line(window, cx)), status_message: None, cancellation: None, @@ -197,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(); } @@ -244,17 +256,16 @@ impl Render for RemoteConnectionPrompt { v_flex() .key_context("PasswordPrompt") - .py_2() - .px_3() + .p_2() .size_full() .text_buffer(cx) .when_some(self.status_message.clone(), |el, status_message| { el.child( h_flex() - .gap_1() + .gap_2() .child( Icon::new(IconName::ArrowCircle) - .size(IconSize::Medium) + .color(Color::Muted) .with_rotate_animation(2), ) .child( @@ -287,15 +298,28 @@ impl RemoteConnectionModal { window: &mut Window, cx: &mut Context, ) -> Self { - let (connection_string, nickname, is_wsl) = match connection_options { - RemoteConnectionOptions::Ssh(options) => { - (options.connection_string(), options.nickname.clone(), false) + let (connection_string, nickname, is_wsl, is_devcontainer) = match connection_options { + RemoteConnectionOptions::Ssh(options) => ( + options.connection_string(), + options.nickname.clone(), + false, + false, + ), + RemoteConnectionOptions::Wsl(options) => { + (options.distro_name.clone(), None, true, false) } - RemoteConnectionOptions::Wsl(options) => (options.distro_name.clone(), None, true), + RemoteConnectionOptions::Docker(options) => (options.name.clone(), None, false, true), }; Self { prompt: cx.new(|cx| { - RemoteConnectionPrompt::new(connection_string, nickname, is_wsl, window, cx) + RemoteConnectionPrompt::new( + connection_string, + nickname, + is_wsl, + is_devcontainer, + window, + cx, + ) }), finished: false, paths, @@ -328,6 +352,7 @@ pub(crate) struct SshConnectionHeader { pub(crate) paths: Vec, pub(crate) nickname: Option, pub(crate) is_wsl: bool, + pub(crate) is_devcontainer: bool, } impl RenderOnce for SshConnectionHeader { @@ -343,9 +368,12 @@ impl RenderOnce for SshConnectionHeader { (self.connection_string, None) }; - let icon = match self.is_wsl { - true => IconName::Linux, - false => IconName::Server, + let icon = if self.is_wsl { + IconName::Linux + } else if self.is_devcontainer { + IconName::Box + } else { + IconName::Server }; h_flex() @@ -388,6 +416,7 @@ impl Render for RemoteConnectionModal { let nickname = self.prompt.read(cx).nickname.clone(); let connection_string = self.prompt.read(cx).connection_string.clone(); let is_wsl = self.prompt.read(cx).is_wsl; + let is_devcontainer = self.prompt.read(cx).is_devcontainer; let theme = cx.theme().clone(); let body_color = theme.colors().editor_background; @@ -407,18 +436,34 @@ impl Render for RemoteConnectionModal { connection_string, nickname, is_wsl, + is_devcontainer, } .render(window, cx), ) .child( div() .w_full() - .rounded_b_lg() .bg(body_color) - .border_t_1() + .border_y_1() .border_color(theme.colors().border_variant) .child(self.prompt.clone()), ) + .child( + div().w_full().py_1().child( + ListItem::new("li-devcontainer-go-back") + .inset(true) + .spacing(ui::ListItemSpacing::Sparse) + .start_slot(Icon::new(IconName::Close).color(Color::Muted)) + .child(Label::new("Cancel")) + .end_slot( + KeyBinding::for_action_in(&menu::Cancel, &self.focus_handle(cx), cx) + .size(rems_from_px(12.)), + ) + .on_click(cx.listener(|this, _, window, cx| { + this.dismiss(&menu::Cancel, window, cx); + })), + ), + ) } } @@ -488,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, ) @@ -519,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 @@ -602,6 +647,7 @@ pub async fn open_remote_project( app_state.languages.clone(), app_state.fs.clone(), None, + false, cx, ); cx.new(|cx| { @@ -671,6 +717,9 @@ pub async fn open_remote_project( match connection_options { RemoteConnectionOptions::Ssh(_) => "Failed to connect over SSH", RemoteConnectionOptions::Wsl(_) => "Failed to connect to WSL", + RemoteConnectionOptions::Docker(_) => { + "Failed to connect to Dev Container" + } }, Some(&format!("{e:#}")), &["Retry", "Cancel"], @@ -727,6 +776,9 @@ pub async fn open_remote_project( match connection_options { RemoteConnectionOptions::Ssh(_) => "Failed to connect over SSH", RemoteConnectionOptions::Wsl(_) => "Failed to connect to WSL", + RemoteConnectionOptions::Docker(_) => { + "Failed to connect to Dev Container" + } }, Some(&format!("{e:#}")), &["Retry", "Cancel"], @@ -738,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 6dff231b30ddde741f69ba9d4e0366517d8e2751..15735b6664e4b72749b0149013d02428eb2735de 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -1,4 +1,5 @@ use crate::{ + dev_container::start_dev_container, remote_connections::{ Connection, RemoteConnectionModal, RemoteConnectionPrompt, SshConnection, SshConnectionHeader, SshSettings, connect, determine_paths_with_positions, @@ -24,7 +25,7 @@ use remote::{ remote_client::ConnectionIdentifier, }; use settings::{ - RemoteSettingsContent, Settings as _, SettingsStore, SshProject, update_settings_file, + RemoteProject, RemoteSettingsContent, Settings as _, SettingsStore, update_settings_file, watch_config_file, }; use smol::stream::StreamExt as _; @@ -39,12 +40,13 @@ use std::{ }, }; use ui::{ - IconButtonShape, List, ListItem, ListSeparator, Modal, ModalHeader, Navigable, NavigableEntry, - Section, Tooltip, WithScrollbar, prelude::*, + CommonAnimationExt, IconButtonShape, KeyBinding, List, ListItem, ListSeparator, Modal, + ModalHeader, Navigable, NavigableEntry, Section, Tooltip, WithScrollbar, prelude::*, }; use util::{ ResultExt, paths::{PathStyle, RemotePathBuf}, + rel_path::RelPath, }; use workspace::{ ModalView, OpenOptions, Toast, Workspace, @@ -74,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, @@ -85,6 +87,39 @@ impl CreateRemoteServer { } } +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +enum DevContainerCreationProgress { + Initial, + Creating, + Error(String), +} + +#[derive(Clone)] +struct CreateRemoteDevContainer { + // 3 Navigable Options + // - Create from devcontainer.json + // - Edit devcontainer.json + // - Go back + entries: [NavigableEntry; 3], + progress: DevContainerCreationProgress, +} + +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, cx); + Self { + entries, + progress: DevContainerCreationProgress::Initial, + } + } + + fn progress(&mut self, progress: DevContainerCreationProgress) -> Self { + self.progress = progress; + self.clone() + } +} + #[cfg(target_os = "windows")] struct AddWslDistro { picker: Entity>, @@ -164,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 } } @@ -182,14 +217,13 @@ impl ProjectPicker { connection: RemoteConnectionOptions, project: Entity, home_dir: RemotePathBuf, - path_style: PathStyle, workspace: WeakEntity, window: &mut Window, cx: &mut Context, ) -> Entity { let (tx, rx) = oneshot::channel(); let lister = project::DirectoryLister::Project(project.clone()); - let delegate = file_finder::OpenPathDelegate::new(tx, lister, false, path_style); + let delegate = file_finder::OpenPathDelegate::new(tx, lister, false, cx); let picker = cx.new(|cx| { let picker = Picker::uniform_list(delegate, window, cx) @@ -207,6 +241,11 @@ impl ProjectPicker { RemoteConnectionOptions::Wsl(connection) => ProjectPickerData::Wsl { distro_name: connection.distro_name.clone().into(), }, + RemoteConnectionOptions::Docker(_) => ProjectPickerData::Ssh { + // Not implemented as a project picker at this time + connection_string: "".into(), + nickname: None, + }, }; let _path_task = cx .spawn_in(window, { @@ -259,7 +298,7 @@ impl ProjectPicker { .as_mut() .and_then(|connections| connections.get_mut(index.0)) { - server.projects.insert(SshProject { paths }); + server.projects.insert(RemoteProject { paths }); }; } ServerIndex::Wsl(index) => { @@ -269,7 +308,7 @@ impl ProjectPicker { .as_mut() .and_then(|connections| connections.get_mut(index.0)) { - server.projects.insert(SshProject { paths }); + server.projects.insert(RemoteProject { paths }); }; } } @@ -349,6 +388,7 @@ impl gpui::Render for ProjectPicker { paths: Default::default(), nickname: nickname.clone(), is_wsl: false, + is_devcontainer: false, } .render(window, cx), ProjectPickerData::Wsl { distro_name } => SshConnectionHeader { @@ -356,6 +396,7 @@ impl gpui::Render for ProjectPicker { paths: Default::default(), nickname: None, is_wsl: true, + is_devcontainer: false, } .render(window, cx), }) @@ -406,7 +447,7 @@ impl From for ServerIndex { enum RemoteEntry { Project { open_folder: NavigableEntry, - projects: Vec<(NavigableEntry, SshProject)>, + projects: Vec<(NavigableEntry, RemoteProject)>, configure: NavigableEntry, connection: Connection, index: ServerIndex, @@ -440,6 +481,7 @@ impl RemoteEntry { struct DefaultState { scroll_handle: ScrollHandle, add_new_server: NavigableEntry, + add_new_devcontainer: NavigableEntry, add_new_wsl: NavigableEntry, servers: Vec, } @@ -448,6 +490,7 @@ impl DefaultState { fn new(ssh_config_servers: &BTreeSet, cx: &mut App) -> Self { let handle = ScrollHandle::new(); let add_new_server = NavigableEntry::new(&handle, cx); + let add_new_devcontainer = NavigableEntry::new(&handle, cx); let add_new_wsl = NavigableEntry::new(&handle, cx); let ssh_settings = SshSettings::get_global(cx); @@ -517,6 +560,7 @@ impl DefaultState { Self { scroll_handle: handle, add_new_server, + add_new_devcontainer, add_new_wsl, servers, } @@ -552,6 +596,7 @@ enum Mode { EditNickname(EditNicknameState), ProjectPicker(Entity), CreateRemoteServer(CreateRemoteServer), + CreateRemoteDevContainer(CreateRemoteDevContainer), #[cfg(target_os = "windows")] AddWslDistro(AddWslDistro), } @@ -598,6 +643,27 @@ impl RemoteServerProjects { ) } + /// Creates a new RemoteServerProjects modal that opens directly in dev container creation mode. + /// Used when suggesting dev container connection from toast notification. + pub fn new_dev_container( + fs: Arc, + window: &mut Window, + workspace: WeakEntity, + cx: &mut Context, + ) -> Self { + Self::new_inner( + Mode::CreateRemoteDevContainer( + CreateRemoteDevContainer::new(window, cx) + .progress(DevContainerCreationProgress::Creating), + ), + false, + fs, + window, + workspace, + cx, + ) + } + fn new_inner( mode: Mode, create_new_window: bool, @@ -652,7 +718,6 @@ impl RemoteServerProjects { connection_options: remote::RemoteConnectionOptions, project: Entity, home_dir: RemotePathBuf, - path_style: PathStyle, window: &mut Window, cx: &mut Context, workspace: WeakEntity, @@ -665,7 +730,6 @@ impl RemoteServerProjects { connection_options, project, home_dir, - path_style, workspace, window, cx, @@ -703,6 +767,7 @@ impl RemoteServerProjects { connection_options.connection_string(), connection_options.nickname.clone(), false, + false, window, cx, ) @@ -727,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(), @@ -778,6 +843,7 @@ impl RemoteServerProjects { connection_options.distro_name.clone(), None, true, + false, window, cx, ) @@ -809,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| { @@ -858,7 +924,16 @@ impl RemoteServerProjects { return; } }); - self.focus_handle(cx).focus(window); + self.focus_handle(cx).focus(window, cx); + cx.notify(); + } + + fn view_in_progress_dev_container(&mut self, window: &mut Window, cx: &mut Context) { + self.mode = Mode::CreateRemoteDevContainer( + CreateRemoteDevContainer::new(window, cx) + .progress(DevContainerCreationProgress::Creating), + ); + self.focus_handle(cx).focus(window, cx); cx.notify(); } @@ -925,6 +1000,7 @@ impl RemoteServerProjects { app_state.user_store.clone(), app_state.languages.clone(), app_state.fs.clone(), + true, cx, ), ) @@ -952,7 +1028,6 @@ impl RemoteServerProjects { connection_options, project, home_dir, - path_style, window, cx, weak, @@ -981,6 +1056,7 @@ impl RemoteServerProjects { self.create_ssh_server(state.address_editor.clone(), window, cx); } + Mode::CreateRemoteDevContainer(_) => {} Mode::EditNickname(state) => { let text = Some(state.editor.read(cx).text(cx)).filter(|text| !text.is_empty()); let index = state.index; @@ -992,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) => { @@ -1018,20 +1094,20 @@ 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(); } } } - fn render_ssh_connection( + fn render_remote_connection( &mut self, ix: usize, - ssh_server: RemoteEntry, + remote_server: RemoteEntry, window: &mut Window, cx: &mut Context, ) -> impl IntoElement { - let connection = ssh_server.connection().into_owned(); + let connection = remote_server.connection().into_owned(); let (main_label, aux_label, is_wsl) = match &connection { Connection::Ssh(connection) => { @@ -1045,6 +1121,9 @@ impl RemoteServerProjects { Connection::Wsl(wsl_connection_options) => { (wsl_connection_options.distro_name.clone(), None, true) } + Connection::DevContainer(dev_container_options) => { + (dev_container_options.name.clone(), None, false) + } }; v_flex() .w_full() @@ -1082,7 +1161,7 @@ impl RemoteServerProjects { }), ), ) - .child(match &ssh_server { + .child(match &remote_server { RemoteEntry::Project { open_folder, projects, @@ -1094,9 +1173,9 @@ impl RemoteServerProjects { List::new() .empty_message("No projects.") .children(projects.iter().enumerate().map(|(pix, p)| { - v_flex().gap_0p5().child(self.render_ssh_project( + v_flex().gap_0p5().child(self.render_remote_project( index, - ssh_server.clone(), + remote_server.clone(), pix, p, window, @@ -1222,12 +1301,12 @@ impl RemoteServerProjects { }) } - fn render_ssh_project( + fn render_remote_project( &mut self, server_ix: ServerIndex, server: RemoteEntry, ix: usize, - (navigation, project): &(NavigableEntry, SshProject), + (navigation, project): &(NavigableEntry, RemoteProject), window: &mut Window, cx: &mut Context, ) -> impl IntoElement { @@ -1372,7 +1451,7 @@ impl RemoteServerProjects { fn delete_remote_project( &mut self, server: ServerIndex, - project: &SshProject, + project: &RemoteProject, cx: &mut Context, ) { match server { @@ -1388,7 +1467,7 @@ impl RemoteServerProjects { fn delete_ssh_project( &mut self, server: SshServerIndex, - project: &SshProject, + project: &RemoteProject, cx: &mut Context, ) { let project = project.clone(); @@ -1406,7 +1485,7 @@ impl RemoteServerProjects { fn delete_wsl_project( &mut self, server: WslServerIndex, - project: &SshProject, + project: &RemoteProject, cx: &mut Context, ) { let project = project.clone(); @@ -1439,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(), @@ -1447,8 +1526,345 @@ impl RemoteServerProjects { args: connection_options.args.unwrap_or_default(), upload_binary_over_ssh: None, port_forwards: connection_options.port_forwards, + connection_timeout: connection_options.connection_timeout, + }) + }); + } + + fn edit_in_dev_container_json(&mut self, window: &mut Window, cx: &mut Context) { + let Some(workspace) = self.workspace.upgrade() else { + cx.emit(DismissEvent); + cx.notify(); + return; + }; + + workspace.update(cx, |workspace, cx| { + let project = workspace.project().clone(); + + let worktree = project + .read(cx) + .visible_worktrees(cx) + .find_map(|tree| tree.read(cx).root_entry()?.is_dir().then_some(tree)); + + if let Some(worktree) = worktree { + let tree_id = worktree.read(cx).id(); + let devcontainer_path = RelPath::unix(".devcontainer/devcontainer.json").unwrap(); + cx.spawn_in(window, async move |workspace, cx| { + workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path( + (tree_id, devcontainer_path), + None, + true, + window, + cx, + ) + })? + .await }) + .detach(); + } else { + return; + } }); + cx.emit(DismissEvent); + cx.notify(); + } + + fn open_dev_container(&self, window: &mut Window, cx: &mut Context) { + let Some(app_state) = self + .workspace + .read_with(cx, |workspace, _| workspace.app_state().clone()) + .log_err() + else { + return; + }; + + let replace_window = window.window_handle().downcast::(); + + cx.spawn_in(window, async move |entity, cx| { + let (connection, starting_dir) = + match start_dev_container(cx, app_state.node_runtime.clone()).await { + Ok((c, s)) => (c, s), + Err(e) => { + log::error!("Failed to start dev container: {:?}", e); + entity + .update_in(cx, |remote_server_projects, window, cx| { + remote_server_projects.mode = Mode::CreateRemoteDevContainer( + CreateRemoteDevContainer::new(window, cx).progress( + DevContainerCreationProgress::Error(format!("{:?}", e)), + ), + ); + }) + .log_err(); + return; + } + }; + entity + .update(cx, |_, cx| { + cx.emit(DismissEvent); + }) + .log_err(); + + let result = open_remote_project( + connection.into(), + vec![starting_dir].into_iter().map(PathBuf::from).collect(), + app_state, + OpenOptions { + replace_window, + ..OpenOptions::default() + }, + cx, + ) + .await; + if let Err(e) = result { + log::error!("Failed to connect: {e:#}"); + cx.prompt( + gpui::PromptLevel::Critical, + "Failed to connect", + Some(&e.to_string()), + &["Ok"], + ) + .await + .ok(); + } + }) + .detach(); + } + + fn render_create_dev_container( + &self, + state: &CreateRemoteDevContainer, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + match &state.progress { + DevContainerCreationProgress::Error(message) => { + self.focus_handle(cx).focus(window, cx); + return div() + .track_focus(&self.focus_handle(cx)) + .size_full() + .child( + v_flex() + .py_1() + .child( + ListItem::new("Error") + .inset(true) + .selectable(false) + .spacing(ui::ListItemSpacing::Sparse) + .start_slot(Icon::new(IconName::XCircle).color(Color::Error)) + .child(Label::new("Error Creating Dev Container:")) + .child(Label::new(message).buffer_font(cx)), + ) + .child(ListSeparator) + .child( + div() + .id("devcontainer-go-back") + .track_focus(&state.entries[0].focus_handle) + .on_action(cx.listener( + |this, _: &menu::Confirm, window, cx| { + this.mode = + Mode::default_mode(&this.ssh_config_servers, cx); + cx.focus_self(window); + cx.notify(); + }, + )) + .child( + ListItem::new("li-devcontainer-go-back") + .toggle_state( + state.entries[0] + .focus_handle + .contains_focused(window, cx), + ) + .inset(true) + .spacing(ui::ListItemSpacing::Sparse) + .start_slot( + Icon::new(IconName::ArrowLeft).color(Color::Muted), + ) + .child(Label::new("Go Back")) + .end_slot( + KeyBinding::for_action_in( + &menu::Cancel, + &self.focus_handle, + cx, + ) + .size(rems_from_px(12.)), + ) + .on_click(cx.listener(|this, _, window, cx| { + let state = + CreateRemoteDevContainer::new(window, cx); + this.mode = Mode::CreateRemoteDevContainer(state); + + cx.notify(); + })), + ), + ), + ) + .into_any_element(); + } + _ => {} + }; + + let mut view = Navigable::new( + div() + .track_focus(&self.focus_handle(cx)) + .size_full() + .child( + v_flex() + .pb_1() + .child( + ModalHeader::new() + .child(Headline::new("Dev Containers").size(HeadlineSize::XSmall)), + ) + .child(ListSeparator) + .child( + div() + .id("confirm-create-from-devcontainer-json") + .track_focus(&state.entries[0].focus_handle) + .on_action(cx.listener({ + move |this, _: &menu::Confirm, window, cx| { + this.open_dev_container(window, cx); + this.view_in_progress_dev_container(window, cx); + } + })) + .map(|this| { + if state.progress == DevContainerCreationProgress::Creating { + this.child( + ListItem::new("creating") + .inset(true) + .spacing(ui::ListItemSpacing::Sparse) + .disabled(true) + .start_slot( + Icon::new(IconName::ArrowCircle) + .color(Color::Muted) + .with_rotate_animation(2), + ) + .child( + h_flex() + .opacity(0.6) + .gap_1() + .child(Label::new("Creating From")) + .child( + Label::new("devcontainer.json") + .buffer_font(cx), + ) + .child(LoadingLabel::new("")), + ), + ) + } else { + this.child( + ListItem::new( + "li-confirm-create-from-devcontainer-json", + ) + .toggle_state( + state.entries[0] + .focus_handle + .contains_focused(window, cx), + ) + .inset(true) + .spacing(ui::ListItemSpacing::Sparse) + .start_slot( + Icon::new(IconName::Plus).color(Color::Muted), + ) + .child( + h_flex() + .gap_1() + .child(Label::new("Open or Create New From")) + .child( + Label::new("devcontainer.json") + .buffer_font(cx), + ), + ) + .on_click( + cx.listener({ + move |this, _, window, cx| { + this.open_dev_container(window, cx); + this.view_in_progress_dev_container( + window, cx, + ); + cx.notify(); + } + }), + ), + ) + } + }), + ) + .child( + div() + .id("edit-devcontainer-json") + .track_focus(&state.entries[1].focus_handle) + .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| { + this.edit_in_dev_container_json(window, cx); + })) + .child( + ListItem::new("li-edit-devcontainer-json") + .toggle_state( + state.entries[1] + .focus_handle + .contains_focused(window, cx), + ) + .inset(true) + .spacing(ui::ListItemSpacing::Sparse) + .start_slot(Icon::new(IconName::Pencil).color(Color::Muted)) + .child( + h_flex().gap_1().child(Label::new("Edit")).child( + Label::new("devcontainer.json").buffer_font(cx), + ), + ) + .on_click(cx.listener(move |this, _, window, cx| { + this.edit_in_dev_container_json(window, cx); + })), + ), + ) + .child(ListSeparator) + .child( + div() + .id("devcontainer-go-back") + .track_focus(&state.entries[2].focus_handle) + .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| { + this.mode = Mode::default_mode(&this.ssh_config_servers, cx); + cx.focus_self(window); + cx.notify(); + })) + .child( + ListItem::new("li-devcontainer-go-back") + .toggle_state( + state.entries[2] + .focus_handle + .contains_focused(window, cx), + ) + .inset(true) + .spacing(ui::ListItemSpacing::Sparse) + .start_slot( + Icon::new(IconName::ArrowLeft).color(Color::Muted), + ) + .child(Label::new("Go Back")) + .end_slot( + KeyBinding::for_action_in( + &menu::Cancel, + &self.focus_handle, + cx, + ) + .size(rems_from_px(12.)), + ) + .on_click(cx.listener(|this, _, window, cx| { + this.mode = + Mode::default_mode(&this.ssh_config_servers, cx); + cx.focus_self(window); + cx.notify() + })), + ), + ), + ) + .into_any_element(), + ); + + view = view.entry(state.entries[0].clone()); + view = view.entry(state.entries[1].clone()); + view = view.entry(state.entries[2].clone()); + + view.render(window, cx).into_any_element() } fn render_create_remote_server( @@ -1536,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() @@ -1567,10 +1983,11 @@ 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, + is_devcontainer: false, } .render(window, cx) .into_any_element(), @@ -1579,6 +1996,7 @@ impl RemoteServerProjects { paths: Default::default(), nickname: None, is_wsl: true, + is_devcontainer: false, } .render(window, cx) .into_any_element(), @@ -1730,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({ @@ -1917,6 +2335,7 @@ impl RemoteServerProjects { paths: Default::default(), nickname, is_wsl: false, + is_devcontainer: false, } .render(window, cx), ) @@ -1998,7 +2417,7 @@ impl RemoteServerProjects { .track_focus(&state.add_new_server.focus_handle) .anchor_scroll(state.add_new_server.scroll_anchor.clone()) .child( - ListItem::new("register-remove-server-button") + ListItem::new("register-remote-server-button") .toggle_state( state .add_new_server @@ -2008,7 +2427,7 @@ impl RemoteServerProjects { .inset(true) .spacing(ui::ListItemSpacing::Sparse) .start_slot(Icon::new(IconName::Plus).color(Color::Muted)) - .child(Label::new("Connect New Server")) + .child(Label::new("Connect SSH Server")) .on_click(cx.listener(|this, _, window, cx| { let state = CreateRemoteServer::new(window, cx); this.mode = Mode::CreateRemoteServer(state); @@ -2023,6 +2442,36 @@ impl RemoteServerProjects { cx.notify(); })); + let connect_dev_container_button = div() + .id("connect-new-dev-container") + .track_focus(&state.add_new_devcontainer.focus_handle) + .anchor_scroll(state.add_new_devcontainer.scroll_anchor.clone()) + .child( + ListItem::new("register-dev-container-button") + .toggle_state( + state + .add_new_devcontainer + .focus_handle + .contains_focused(window, cx), + ) + .inset(true) + .spacing(ui::ListItemSpacing::Sparse) + .start_slot(Icon::new(IconName::Plus).color(Color::Muted)) + .child(Label::new("Connect Dev Container")) + .on_click(cx.listener(|this, _, window, cx| { + let state = CreateRemoteDevContainer::new(window, cx); + this.mode = Mode::CreateRemoteDevContainer(state); + + cx.notify(); + })), + ) + .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| { + let state = CreateRemoteDevContainer::new(window, cx); + this.mode = Mode::CreateRemoteDevContainer(state); + + cx.notify(); + })); + #[cfg(target_os = "windows")] let wsl_connect_button = div() .id("wsl-connect-new-server") @@ -2049,13 +2498,30 @@ impl RemoteServerProjects { cx.notify(); })); + let has_open_project = self + .workspace + .upgrade() + .map(|workspace| { + workspace + .read(cx) + .project() + .read(cx) + .visible_worktrees(cx) + .next() + .is_some() + }) + .unwrap_or(false); + let modal_section = v_flex() .track_focus(&self.focus_handle(cx)) .id("ssh-server-list") .overflow_y_scroll() .track_scroll(&state.scroll_handle) .size_full() - .child(connect_button); + .child(connect_button) + .when(has_open_project, |this| { + this.child(connect_dev_container_button) + }); #[cfg(target_os = "windows")] let modal_section = modal_section.child(wsl_connect_button); @@ -2067,17 +2533,20 @@ impl RemoteServerProjects { .child( List::new() .empty_message( - v_flex() + h_flex() + .size_full() + .p_2() + .justify_center() + .border_t_1() + .border_color(cx.theme().colors().border_variant) .child( - div().px_3().child( - Label::new("No remote servers registered yet.") - .color(Color::Muted), - ), + Label::new("No remote servers registered yet.") + .color(Color::Muted), ) .into_any_element(), ) .children(state.servers.iter().enumerate().map(|(ix, connection)| { - self.render_ssh_connection(ix, connection.clone(), window, cx) + self.render_remote_connection(ix, connection.clone(), window, cx) .into_any_element() })), ) @@ -2085,6 +2554,10 @@ impl RemoteServerProjects { ) .entry(state.add_new_server.clone()); + if has_open_project { + modal_section = modal_section.entry(state.add_new_devcontainer.clone()); + } + if cfg!(target_os = "windows") { modal_section = modal_section.entry(state.add_new_wsl.clone()); } @@ -2186,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, @@ -2279,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(_)) { @@ -2297,6 +2770,9 @@ impl Render for RemoteServerProjects { Mode::CreateRemoteServer(state) => self .render_create_remote_server(state, window, cx) .into_any_element(), + Mode::CreateRemoteDevContainer(state) => self + .render_create_dev_container(state, window, cx) + .into_any_element(), Mode::EditNickname(state) => self .render_edit_nickname(state, window, cx) .into_any_element(), diff --git a/crates/refineable/derive_refineable/src/derive_refineable.rs b/crates/refineable/derive_refineable/src/derive_refineable.rs index ddf3855a4dc5ae6917309ced57391bd244f1b465..c7c8a91ad9b05d054a94c8ca7f55a54c75150d81 100644 --- a/crates/refineable/derive_refineable/src/derive_refineable.rs +++ b/crates/refineable/derive_refineable/src/derive_refineable.rs @@ -528,7 +528,12 @@ fn get_wrapper_type(field: &Field, ty: &Type) -> syn::Type { } else { panic!("Expected struct type for a refineable field"); }; - let refinement_struct_name = format_ident!("{}Refinement", struct_name); + + let refinement_struct_name = if struct_name.to_string().ends_with("Refinement") { + format_ident!("{}", struct_name) + } else { + format_ident!("{}Refinement", struct_name) + }; let generics = if let Type::Path(tp) = ty { &tp.path.segments.last().unwrap().arguments } else { diff --git a/crates/refineable/src/refineable.rs b/crates/refineable/src/refineable.rs index d2a7c3d3f2148ae174be10121dc23b4dbfc5a650..b2305d4b5a7c1e5e45287394a49a258d98767c66 100644 --- a/crates/refineable/src/refineable.rs +++ b/crates/refineable/src/refineable.rs @@ -13,7 +13,7 @@ pub use derive_refineable::Refineable; /// wrapped appropriately: /// /// - **Refineable fields** (marked with `#[refineable]`): Become the corresponding refinement type -/// (e.g., `Bar` becomes `BarRefinement`) +/// (e.g., `Bar` becomes `BarRefinement`, or `BarRefinement` remains `BarRefinement`) /// - **Optional fields** (`Option`): Remain as `Option` /// - **Regular fields**: Become `Option` /// diff --git a/crates/remote/src/remote.rs b/crates/remote/src/remote.rs index 783dde16acb350367ed82243e138e5c58f64224b..2db918ecce331acac91bb974df1b784f0d6532b3 100644 --- a/crates/remote/src/remote.rs +++ b/crates/remote/src/remote.rs @@ -7,8 +7,10 @@ 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}; pub use transport::wsl::WslConnectionOptions; diff --git a/crates/remote/src/remote_client.rs b/crates/remote/src/remote_client.rs index b0f9914c90545263a830ec034512a7e423109409..79bdbe540d070bfa18a6417622b386458ff221a8 100644 --- a/crates/remote/src/remote_client.rs +++ b/crates/remote/src/remote_client.rs @@ -3,6 +3,7 @@ use crate::{ protocol::MessageId, proxy::ProxyLaunchError, transport::{ + docker::{DockerConnectionOptions, DockerExecConnection}, ssh::SshRemoteConnection, wsl::{WslConnectionOptions, WslRemoteConnection}, }, @@ -48,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)] @@ -88,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; @@ -920,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() }); @@ -1042,6 +1094,11 @@ impl ConnectionPool { .await .map(|connection| Arc::new(connection) as Arc) } + RemoteConnectionOptions::Docker(opts) => { + DockerExecConnection::new(opts, delegate, cx) + .await + .map(|connection| Arc::new(connection) as Arc) + } }; cx.update_global(|pool: &mut Self, _| { @@ -1077,13 +1134,15 @@ impl ConnectionPool { pub enum RemoteConnectionOptions { Ssh(SshConnectionOptions), Wsl(WslConnectionOptions), + Docker(DockerConnectionOptions), } 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 1976be5656d7a227541d7adf6a36d91b5bfdcc59..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}, }; @@ -12,6 +12,7 @@ use gpui::{AppContext as _, AsyncApp, Task}; use rpc::proto::Envelope; use smol::process::Child; +pub mod docker; pub mod ssh; pub mod wsl; @@ -25,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" ), @@ -38,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" @@ -64,15 +65,15 @@ fn parse_shell(output: &str, fallback_shell: &str) -> String { } fn handle_rpc_messages_over_child_process_stdio( - mut ssh_proxy_process: Child, + mut remote_proxy_process: Child, incoming_tx: UnboundedSender, mut outgoing_rx: UnboundedReceiver, mut connection_activity_tx: Sender<()>, cx: &AsyncApp, ) -> Task> { - let mut child_stderr = ssh_proxy_process.stderr.take().unwrap(); - let mut child_stdout = ssh_proxy_process.stdout.take().unwrap(); - let mut child_stdin = ssh_proxy_process.stdin.take().unwrap(); + let mut child_stderr = remote_proxy_process.stderr.take().unwrap(); + let mut child_stdout = remote_proxy_process.stdout.take().unwrap(); + let mut child_stdin = remote_proxy_process.stdin.take().unwrap(); let mut stdin_buffer = Vec::new(); let mut stdout_buffer = Vec::new(); @@ -156,7 +157,7 @@ fn handle_rpc_messages_over_child_process_stdio( result.context("stderr") } }; - let status = ssh_proxy_process.status().await?.code().unwrap_or(1); + let status = remote_proxy_process.status().await?.code().unwrap_or(1); match result { Ok(_) => Ok(status), Err(error) => Err(error), @@ -192,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(()) } @@ -202,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") { @@ -220,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") { @@ -231,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( @@ -307,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); @@ -373,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 new file mode 100644 index 0000000000000000000000000000000000000000..9c14aa874941a5cdcd824d4adaeb41d694e347d8 --- /dev/null +++ b/crates/remote/src/transport/docker.rs @@ -0,0 +1,757 @@ +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use async_trait::async_trait; +use collections::HashMap; +use parking_lot::Mutex; +use release_channel::{AppCommitSha, AppVersion, ReleaseChannel}; +use semver::Version as SemanticVersion; +use std::time::Instant; +use std::{ + path::{Path, PathBuf}, + process::Stdio, + sync::Arc, +}; +use util::ResultExt; +use util::shell::ShellKind; +use util::{ + paths::{PathStyle, RemotePathBuf}, + rel_path::RelPath, +}; + +use futures::channel::mpsc::{Sender, UnboundedReceiver, UnboundedSender}; +use gpui::{App, AppContext, AsyncApp, Task}; +use rpc::proto::Envelope; + +use crate::{ + RemoteArch, RemoteClientDelegate, RemoteConnection, RemoteConnectionOptions, RemoteOs, + RemotePlatform, remote_client::CommandTemplate, +}; + +#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)] +pub struct DockerConnectionOptions { + pub name: String, + pub container_id: String, + pub upload_binary_over_docker_exec: bool, +} + +pub(crate) struct DockerExecConnection { + proxy_process: Mutex>, + remote_dir_for_server: String, + remote_binary_relpath: Option>, + connection_options: DockerConnectionOptions, + remote_platform: Option, + path_style: Option, + shell: Option, +} + +impl DockerExecConnection { + pub async fn new( + connection_options: DockerConnectionOptions, + delegate: Arc, + cx: &mut AsyncApp, + ) -> Result { + let mut this = Self { + proxy_process: Mutex::new(None), + remote_dir_for_server: "/".to_string(), + remote_binary_relpath: None, + connection_options, + remote_platform: None, + path_style: None, + shell: None, + }; + let (release_channel, version, commit) = cx.update(|cx| { + ( + ReleaseChannel::global(cx), + AppVersion::global(cx), + AppCommitSha::try_global(cx), + ) + })?; + let remote_platform = this.check_remote_platform().await?; + + this.path_style = match remote_platform.os { + RemoteOs::Windows => Some(PathStyle::Windows), + _ => Some(PathStyle::Posix), + }; + + this.remote_platform = Some(remote_platform); + + this.shell = Some(this.discover_shell().await); + + this.remote_dir_for_server = this.docker_user_home_dir().await?.trim().to_string(); + + this.remote_binary_relpath = Some( + this.ensure_server_binary( + &delegate, + release_channel, + version, + &this.remote_dir_for_server, + commit, + cx, + ) + .await?, + ); + + Ok(this) + } + + async fn discover_shell(&self) -> String { + let default_shell = "sh"; + match self + .run_docker_exec("sh", None, &Default::default(), &["-c", "echo $SHELL"]) + .await + { + Ok(shell) => match shell.trim() { + "" => { + log::error!("$SHELL is not set, falling back to {default_shell}"); + default_shell.to_owned() + } + shell => shell.to_owned(), + }, + Err(e) => { + log::error!("Failed to get shell: {e}"); + default_shell.to_owned() + } + } + } + + async fn check_remote_platform(&self) -> Result { + let uname = self + .run_docker_exec("uname", None, &Default::default(), &["-sm"]) + .await?; + let Some((os, arch)) = uname.split_once(" ") else { + anyhow::bail!("unknown uname: {uname:?}") + }; + + let os = match os.trim() { + "Darwin" => RemoteOs::MacOs, + "Linux" => RemoteOs::Linux, + _ => anyhow::bail!( + "Prebuilt remote servers are not yet available for {os:?}. See https://zed.dev/docs/remote-development" + ), + }; + // exclude armv5,6,7 as they are 32-bit. + let arch = if arch.starts_with("armv8") + || arch.starts_with("armv9") + || arch.starts_with("arm64") + || arch.starts_with("aarch64") + { + RemoteArch::Aarch64 + } else if arch.starts_with("x86") { + RemoteArch::X86_64 + } else { + anyhow::bail!( + "Prebuilt remote servers are not yet available for {arch:?}. See https://zed.dev/docs/remote-development" + ) + }; + + Ok(RemotePlatform { os, arch }) + } + + async fn ensure_server_binary( + &self, + delegate: &Arc, + release_channel: ReleaseChannel, + version: SemanticVersion, + remote_dir_for_server: &str, + commit: Option, + cx: &mut AsyncApp, + ) -> Result> { + let remote_platform = if self.remote_platform.is_some() { + self.remote_platform.unwrap() + } else { + anyhow::bail!("No remote platform defined; cannot proceed.") + }; + + let version_str = match release_channel { + ReleaseChannel::Nightly => { + let commit = commit.map(|s| s.full()).unwrap_or_default(); + format!("{}-{}", version, commit) + } + ReleaseChannel::Dev => "build".to_string(), + _ => version.to_string(), + }; + let binary_name = format!( + "zed-remote-server-{}-{}", + release_channel.dev_name(), + version_str + ); + let dst_path = + paths::remote_server_dir_relative().join(RelPath::unix(&binary_name).unwrap()); + + #[cfg(debug_assertions)] + if let Some(remote_server_path) = + super::build_remote_server_from_source(&remote_platform, delegate.as_ref(), cx).await? + { + let tmp_path = paths::remote_server_dir_relative().join( + RelPath::unix(&format!( + "download-{}-{}", + std::process::id(), + remote_server_path.file_name().unwrap().to_string_lossy() + )) + .unwrap(), + ); + self.upload_local_server_binary( + &remote_server_path, + &tmp_path, + &remote_dir_for_server, + delegate, + cx, + ) + .await?; + self.extract_server_binary(&dst_path, &tmp_path, &remote_dir_for_server, delegate, cx) + .await?; + return Ok(dst_path); + } + + if self + .run_docker_exec( + &dst_path.display(self.path_style()), + Some(&remote_dir_for_server), + &Default::default(), + &["version"], + ) + .await + .is_ok() + { + return Ok(dst_path); + } + + let wanted_version = cx.update(|cx| match release_channel { + ReleaseChannel::Nightly => Ok(None), + ReleaseChannel::Dev => { + anyhow::bail!( + "ZED_BUILD_REMOTE_SERVER is not set and no remote server exists at ({:?})", + dst_path + ) + } + _ => Ok(Some(AppVersion::global(cx))), + })??; + + let tmp_path_gz = paths::remote_server_dir_relative().join( + RelPath::unix(&format!( + "{}-download-{}.gz", + binary_name, + std::process::id() + )) + .unwrap(), + ); + if !self.connection_options.upload_binary_over_docker_exec + && let Some(url) = delegate + .get_download_url(remote_platform, release_channel, wanted_version.clone(), cx) + .await? + { + match self + .download_binary_on_server(&url, &tmp_path_gz, &remote_dir_for_server, delegate, cx) + .await + { + Ok(_) => { + self.extract_server_binary( + &dst_path, + &tmp_path_gz, + &remote_dir_for_server, + delegate, + cx, + ) + .await + .context("extracting server binary")?; + return Ok(dst_path); + } + Err(e) => { + log::error!( + "Failed to download binary on server, attempting to download locally and then upload it the server: {e:#}", + ) + } + } + } + + let src_path = delegate + .download_server_binary_locally(remote_platform, release_channel, wanted_version, cx) + .await + .context("downloading server binary locally")?; + self.upload_local_server_binary( + &src_path, + &tmp_path_gz, + &remote_dir_for_server, + delegate, + cx, + ) + .await + .context("uploading server binary")?; + self.extract_server_binary( + &dst_path, + &tmp_path_gz, + &remote_dir_for_server, + delegate, + cx, + ) + .await + .context("extracting server binary")?; + Ok(dst_path) + } + + async fn docker_user_home_dir(&self) -> Result { + let inner_program = self.shell(); + self.run_docker_exec( + &inner_program, + None, + &Default::default(), + &["-c", "echo $HOME"], + ) + .await + } + + async fn extract_server_binary( + &self, + dst_path: &RelPath, + tmp_path: &RelPath, + remote_dir_for_server: &str, + delegate: &Arc, + cx: &mut AsyncApp, + ) -> Result<()> { + delegate.set_status(Some("Extracting remote development server"), cx); + let server_mode = 0o755; + + let shell_kind = ShellKind::Posix; + let orig_tmp_path = tmp_path.display(self.path_style()); + let server_mode = format!("{:o}", server_mode); + let server_mode = shell_kind + .try_quote(&server_mode) + .context("shell quoting")?; + let dst_path = dst_path.display(self.path_style()); + let dst_path = shell_kind.try_quote(&dst_path).context("shell quoting")?; + let script = if let Some(tmp_path) = orig_tmp_path.strip_suffix(".gz") { + let orig_tmp_path = shell_kind + .try_quote(&orig_tmp_path) + .context("shell quoting")?; + let tmp_path = shell_kind.try_quote(&tmp_path).context("shell quoting")?; + format!( + "gunzip -f {orig_tmp_path} && chmod {server_mode} {tmp_path} && mv {tmp_path} {dst_path}", + ) + } else { + let orig_tmp_path = shell_kind + .try_quote(&orig_tmp_path) + .context("shell quoting")?; + format!("chmod {server_mode} {orig_tmp_path} && mv {orig_tmp_path} {dst_path}",) + }; + let args = shell_kind.args_for_shell(false, script.to_string()); + self.run_docker_exec( + "sh", + Some(&remote_dir_for_server), + &Default::default(), + &args, + ) + .await + .log_err(); + Ok(()) + } + + async fn upload_local_server_binary( + &self, + src_path: &Path, + tmp_path_gz: &RelPath, + remote_dir_for_server: &str, + delegate: &Arc, + cx: &mut AsyncApp, + ) -> Result<()> { + if let Some(parent) = tmp_path_gz.parent() { + self.run_docker_exec( + "mkdir", + Some(remote_dir_for_server), + &Default::default(), + &["-p", parent.display(self.path_style()).as_ref()], + ) + .await?; + } + + let src_stat = smol::fs::metadata(&src_path).await?; + let size = src_stat.len(); + + let t0 = Instant::now(); + delegate.set_status(Some("Uploading remote development server"), cx); + log::info!( + "uploading remote development server to {:?} ({}kb)", + tmp_path_gz, + size / 1024 + ); + self.upload_file(src_path, tmp_path_gz, remote_dir_for_server) + .await + .context("failed to upload server binary")?; + log::info!("uploaded remote development server in {:?}", t0.elapsed()); + Ok(()) + } + + async fn upload_file( + &self, + src_path: &Path, + dest_path: &RelPath, + remote_dir_for_server: &str, + ) -> Result<()> { + log::debug!("uploading file {:?} to {:?}", src_path, dest_path); + + let src_path_display = src_path.display().to_string(); + let dest_path_str = dest_path.display(self.path_style()); + + let mut command = util::command::new_smol_command("docker"); + command.arg("cp"); + command.arg("-a"); + command.arg(&src_path_display); + command.arg(format!( + "{}:{}/{}", + &self.connection_options.container_id, remote_dir_for_server, dest_path_str + )); + + let output = command.output().await?; + + if output.status.success() { + return Ok(()); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + log::debug!( + "failed to upload file via docker cp {src_path_display} -> {dest_path_str}: {stderr}", + ); + anyhow::bail!( + "failed to upload file via docker cp {} -> {}: {}", + src_path_display, + dest_path_str, + stderr, + ); + } + + async fn run_docker_command( + &self, + subcommand: &str, + args: &[impl AsRef], + ) -> Result { + let mut command = util::command::new_smol_command("docker"); + command.arg(subcommand); + for arg in args { + command.arg(arg.as_ref()); + } + let output = command.output().await?; + anyhow::ensure!( + output.status.success(), + "failed to run command {command:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } + + async fn run_docker_exec( + &self, + inner_program: &str, + working_directory: Option<&str>, + env: &HashMap, + program_args: &[impl AsRef], + ) -> Result { + let mut args = match working_directory { + Some(dir) => vec!["-w".to_string(), dir.to_string()], + None => vec![], + }; + + for (k, v) in env.iter() { + args.push("-e".to_string()); + let env_declaration = format!("{}={}", k, v); + args.push(env_declaration); + } + + args.push(self.connection_options.container_id.clone()); + args.push(inner_program.to_string()); + + for arg in program_args { + args.push(arg.as_ref().to_owned()); + } + self.run_docker_command("exec", args.as_ref()).await + } + + async fn download_binary_on_server( + &self, + url: &str, + tmp_path_gz: &RelPath, + remote_dir_for_server: &str, + delegate: &Arc, + cx: &mut AsyncApp, + ) -> Result<()> { + if let Some(parent) = tmp_path_gz.parent() { + self.run_docker_exec( + "mkdir", + Some(remote_dir_for_server), + &Default::default(), + &["-p", parent.display(self.path_style()).as_ref()], + ) + .await?; + } + + delegate.set_status(Some("Downloading remote development server on host"), cx); + + match self + .run_docker_exec( + "curl", + Some(remote_dir_for_server), + &Default::default(), + &[ + "-f", + "-L", + url, + "-o", + &tmp_path_gz.display(self.path_style()), + ], + ) + .await + { + Ok(_) => {} + Err(e) => { + if self + .run_docker_exec("which", None, &Default::default(), &["curl"]) + .await + .is_ok() + { + return Err(e); + } + + log::info!("curl is not available, trying wget"); + match self + .run_docker_exec( + "wget", + Some(remote_dir_for_server), + &Default::default(), + &[url, "-O", &tmp_path_gz.display(self.path_style())], + ) + .await + { + Ok(_) => {} + Err(e) => { + if self + .run_docker_exec("which", None, &Default::default(), &["wget"]) + .await + .is_ok() + { + return Err(e); + } else { + anyhow::bail!("Neither curl nor wget is available"); + } + } + } + } + } + Ok(()) + } + + fn kill_inner(&self) -> Result<()> { + if let Some(pid) = self.proxy_process.lock().take() { + if let Ok(_) = util::command::new_smol_command("kill") + .arg(pid.to_string()) + .spawn() + { + Ok(()) + } else { + Err(anyhow::anyhow!("Failed to kill process")) + } + } else { + Ok(()) + } + } +} + +#[async_trait(?Send)] +impl RemoteConnection for DockerExecConnection { + fn has_wsl_interop(&self) -> bool { + false + } + fn start_proxy( + &self, + unique_identifier: String, + reconnect: bool, + incoming_tx: UnboundedSender, + outgoing_rx: UnboundedReceiver, + connection_activity_tx: Sender<()>, + delegate: Arc, + cx: &mut AsyncApp, + ) -> Task> { + // We'll try connecting anew every time we open a devcontainer, so proactively try to kill any old connections. + if !self.has_been_killed() { + if let Err(e) = self.kill_inner() { + return Task::ready(Err(e)); + }; + } + + delegate.set_status(Some("Starting proxy"), cx); + + let Some(remote_binary_relpath) = self.remote_binary_relpath.clone() else { + return Task::ready(Err(anyhow!("Remote binary path not set"))); + }; + + let mut docker_args = vec![ + "exec".to_string(), + "-w".to_string(), + self.remote_dir_for_server.clone(), + "-i".to_string(), + self.connection_options.container_id.to_string(), + ]; + for env_var in ["RUST_LOG", "RUST_BACKTRACE", "ZED_GENERATE_MINIDUMPS"] { + if let Some(value) = std::env::var(env_var).ok() { + docker_args.push("-e".to_string()); + docker_args.push(format!("{}='{}'", env_var, value)); + } + } + let val = remote_binary_relpath + .display(self.path_style()) + .into_owned(); + docker_args.push(val); + docker_args.push("proxy".to_string()); + docker_args.push("--identifier".to_string()); + docker_args.push(unique_identifier); + if reconnect { + docker_args.push("--reconnect".to_string()); + } + let mut command = util::command::new_smol_command("docker"); + command + .kill_on_drop(true) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .args(docker_args); + + let Ok(child) = command.spawn() else { + return Task::ready(Err(anyhow::anyhow!( + "Failed to start remote server process" + ))); + }; + + let mut proxy_process = self.proxy_process.lock(); + *proxy_process = Some(child.id()); + + super::handle_rpc_messages_over_child_process_stdio( + child, + incoming_tx, + outgoing_rx, + connection_activity_tx, + cx, + ) + } + + fn upload_directory( + &self, + src_path: PathBuf, + dest_path: RemotePathBuf, + cx: &App, + ) -> Task> { + let dest_path_str = dest_path.to_string(); + let src_path_display = src_path.display().to_string(); + + let mut command = util::command::new_smol_command("docker"); + command.arg("cp"); + command.arg("-a"); // Archive mode is required to assign the file ownership to the default docker exec user + command.arg(src_path_display); + command.arg(format!( + "{}:{}", + self.connection_options.container_id, dest_path_str + )); + + cx.background_spawn(async move { + let output = command.output().await?; + + if output.status.success() { + Ok(()) + } else { + Err(anyhow::anyhow!("Failed to upload directory")) + } + }) + } + + async fn kill(&self) -> Result<()> { + self.kill_inner() + } + + fn has_been_killed(&self) -> bool { + self.proxy_process.lock().is_none() + } + + fn build_command( + &self, + program: Option, + args: &[String], + env: &HashMap, + working_dir: Option, + _port_forward: Option<(u16, String, u16)>, + ) -> Result { + let mut parsed_working_dir = None; + + let path_style = self.path_style(); + + if let Some(working_dir) = working_dir { + let working_dir = RemotePathBuf::new(working_dir, path_style).to_string(); + + const TILDE_PREFIX: &'static str = "~/"; + if working_dir.starts_with(TILDE_PREFIX) { + let working_dir = working_dir.trim_start_matches("~").trim_start_matches("/"); + parsed_working_dir = Some(format!("$HOME/{working_dir}")); + } else { + parsed_working_dir = Some(working_dir); + } + } + + let mut inner_program = Vec::new(); + + if let Some(program) = program { + inner_program.push(program); + for arg in args { + inner_program.push(arg.clone()); + } + } else { + inner_program.push(self.shell()); + inner_program.push("-l".to_string()); + }; + + let mut docker_args = vec!["exec".to_string()]; + + if let Some(parsed_working_dir) = parsed_working_dir { + docker_args.push("-w".to_string()); + docker_args.push(parsed_working_dir); + } + + for (k, v) in env.iter() { + docker_args.push("-e".to_string()); + docker_args.push(format!("{}={}", k, v)); + } + + docker_args.push("-it".to_string()); + docker_args.push(self.connection_options.container_id.to_string()); + + docker_args.append(&mut inner_program); + + Ok(CommandTemplate { + program: "docker".to_string(), + args: docker_args, + // Docker-exec pipes in environment via the "-e" argument + env: Default::default(), + }) + } + + fn build_forward_ports_command( + &self, + _forwards: Vec<(u16, String, u16)>, + ) -> Result { + Err(anyhow::anyhow!("Not currently supported for docker_exec")) + } + + fn connection_options(&self) -> RemoteConnectionOptions { + RemoteConnectionOptions::Docker(self.connection_options.clone()) + } + + fn path_style(&self) -> PathStyle { + self.path_style.unwrap_or(PathStyle::Posix) + } + + fn shell(&self) -> String { + match &self.shell { + Some(shell) => shell.clone(), + None => self.default_system_shell(), + } + } + + fn default_system_shell(&self) -> String { + String::from("/bin/sh") + } +} diff --git a/crates/remote/src/transport/ssh.rs b/crates/remote/src/transport/ssh.rs index 6260653d53c2040da5e70c2c6764f9790d7abb58..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, @@ -31,7 +32,8 @@ use tempfile::TempDir; use util::{ paths::{PathStyle, RemotePathBuf}, rel_path::RelPath, - shell::ShellKind, + shell::{Shell, ShellKind}, + shell_builder::ShellBuilder, }; pub(crate) struct SshRemoteConnection { @@ -46,14 +48,64 @@ 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, pub args: Option>, pub port_forwards: Option>, + pub connection_timeout: Option, pub nickname: Option, pub upload_binary_over_ssh: bool, @@ -62,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, @@ -70,6 +122,7 @@ impl From for SshConnectionOptions { nickname: val.nickname, upload_binary_over_ssh: val.upload_binary_over_ssh.unwrap_or_default(), port_forwards: val.port_forwards, + connection_timeout: val.connection_timeout, } } } @@ -93,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", @@ -115,9 +168,9 @@ impl MasterProcess { .args(additional_args) .args(args); - master_process.arg(format!("ControlPath='{}'", socket_path.display())); + 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 }) } @@ -140,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 @@ -162,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()?; @@ -349,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() @@ -409,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") @@ -434,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! { @@ -492,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, @@ -543,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()); @@ -657,19 +733,29 @@ 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); - const CONNECT_TIMEOUT_SECS: &str = "10"; + let connection_timeout = self + .socket + .connection_options + .connection_timeout + .unwrap_or(10) + .to_string(); match self .socket @@ -680,7 +766,7 @@ impl SshRemoteConnection { "-f", "-L", "--connect-timeout", - CONNECT_TIMEOUT_SECS, + &connection_timeout, url, "-o", &tmp_path_gz.display(self.path_style()), @@ -708,7 +794,7 @@ impl SshRemoteConnection { "wget", &[ "--connect-timeout", - CONNECT_TIMEOUT_SECS, + &connection_timeout, "--tries", "1", url, @@ -747,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(); @@ -808,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(()) } @@ -832,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 @@ -848,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 } @@ -978,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"); } @@ -996,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:?}: {}", @@ -1055,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 } @@ -1063,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) @@ -1085,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 { @@ -1200,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()) } @@ -1217,7 +1391,7 @@ impl SshConnectionOptions { }; Ok(Self { - host: hostname, + host: hostname.into(), username, port, port_forwards, @@ -1225,22 +1399,20 @@ impl SshConnectionOptions { password: None, nickname: None, upload_binary_over_ssh: false, + connection_timeout: None, }) } - 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 } @@ -1251,6 +1423,15 @@ impl SshConnectionOptions { pub fn additional_args(&self) -> Vec { let mut args = self.additional_args_for_scp(); + if let Some(timeout) = self.connection_timeout { + 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 { @@ -1272,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 } @@ -1362,6 +1544,8 @@ fn build_command( } else { write!(exec, "{ssh_shell} -l")?; }; + let (command, command_args) = ShellBuilder::new(&Shell::Program(ssh_shell.to_owned()), false) + .build(Some(exec.clone()), &[]); let mut args = Vec::new(); args.extend(ssh_args); @@ -1372,7 +1556,9 @@ fn build_command( } args.push("-t".into()); - args.push(exec); + args.push(command); + args.extend(command_args); + Ok(CommandTemplate { program: "ssh".into(), args, @@ -1411,6 +1597,9 @@ mod tests { "-p", "2222", "-t", + "/bin/fish", + "-i", + "-c", "cd \"$HOME/work\" && exec env INPUT_VA=val remote_program arg1 arg2" ] ); @@ -1443,6 +1632,9 @@ mod tests { "-L", "1:foo:2", "-t", + "/bin/fish", + "-i", + "-c", "cd && exec env INPUT_VA=val /bin/fish -l" ] ); @@ -1487,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 570266c8a8466265b56be11fc295ef403bdaeb80..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}, }; @@ -23,7 +23,8 @@ use std::{ use util::{ paths::{PathStyle, RemotePathBuf}, rel_path::RelPath, - shell::ShellKind, + shell::{Shell, ShellKind}, + shell_builder::ShellBuilder, }; #[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Deserialize, schemars::JsonSchema)] @@ -69,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"), @@ -453,8 +457,10 @@ impl RemoteConnection for WslRemoteConnection { } else { write!(&mut exec, "{} -l", self.shell)?; } + let (command, args) = + ShellBuilder::new(&Shell::Program(self.shell.clone()), false).build(Some(exec), &[]); - let wsl_args = if let Some(user) = &self.connection_options.user { + let mut wsl_args = if let Some(user) = &self.connection_options.user { vec![ "--distribution".to_string(), self.connection_options.distro_name.clone(), @@ -463,9 +469,7 @@ impl RemoteConnection for WslRemoteConnection { "--cd".to_string(), working_dir, "--".to_string(), - self.shell.clone(), - "-c".to_string(), - exec, + command, ] } else { vec![ @@ -474,11 +478,10 @@ impl RemoteConnection for WslRemoteConnection { "--cd".to_string(), working_dir, "--".to_string(), - self.shell.clone(), - "-c".to_string(), - exec, + command, ] }; + wsl_args.extend(args); Ok(CommandTemplate { program: "wsl.exe".to_string(), 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 f5cce907f956d7127aeb272cfef27ecb5f6375a7..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); @@ -449,6 +464,7 @@ impl HeadlessProject { message.payload.visible, this.fs.clone(), this.next_entry_id.clone(), + true, &mut cx, ) })? @@ -594,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

impl Dispatcher { impl Dispatcher for ZedDispatcher { fn dispatch(&self, runnable: Runnable) { self.dispatcher - .dispatch(RunnableVariant::Compat(runnable), None); + .dispatch(RunnableVariant::Compat(runnable), None, Priority::default()); } fn dispatch_after(&self, duration: Duration, runnable: Runnable) { diff --git a/crates/rope/src/chunk.rs b/crates/rope/src/chunk.rs index a2a8e8d58df2d5ddc3336e8e56dd8446f4dcf118..c1916768c1f8a0980fb4d5aa1b718483b08c6087 100644 --- a/crates/rope/src/chunk.rs +++ b/crates/rope/src/chunk.rs @@ -47,22 +47,59 @@ impl Chunk { #[inline(always)] pub fn new(text: &str) -> Self { - let mut this = Chunk::default(); - this.push_str(text); - this + let text = ArrayString::from(text).unwrap(); + + const CHUNK_SIZE: usize = 8; + + let mut chars_bytes = [0; MAX_BASE / CHUNK_SIZE]; + let mut newlines_bytes = [0; MAX_BASE / CHUNK_SIZE]; + let mut tabs_bytes = [0; MAX_BASE / CHUNK_SIZE]; + let mut chars_utf16_bytes = [0; MAX_BASE / CHUNK_SIZE]; + + let mut chunk_ix = 0; + + let mut bytes = text.as_bytes(); + while !bytes.is_empty() { + let (chunk, rest) = bytes.split_at(bytes.len().min(CHUNK_SIZE)); + bytes = rest; + + let mut chars = 0; + let mut newlines = 0; + let mut tabs = 0; + let mut chars_utf16 = 0; + + for (ix, &b) in chunk.iter().enumerate() { + chars |= (util::is_utf8_char_boundary(b) as u8) << ix; + newlines |= ((b == b'\n') as u8) << ix; + tabs |= ((b == b'\t') as u8) << ix; + // b >= 240 when we are at the first byte of the 4 byte encoded + // utf-8 code point (U+010000 or greater) it means that it would + // be encoded as two 16-bit code units in utf-16 + chars_utf16 |= ((b >= 240) as u8) << ix; + } + + chars_bytes[chunk_ix] = chars; + newlines_bytes[chunk_ix] = newlines; + tabs_bytes[chunk_ix] = tabs; + chars_utf16_bytes[chunk_ix] = chars_utf16; + + chunk_ix += 1; + } + + let chars = Bitmap::from_le_bytes(chars_bytes); + + Chunk { + text, + chars, + chars_utf16: (Bitmap::from_le_bytes(chars_utf16_bytes) << 1) | chars, + newlines: Bitmap::from_le_bytes(newlines_bytes), + tabs: Bitmap::from_le_bytes(tabs_bytes), + } } #[inline(always)] pub fn push_str(&mut self, text: &str) { - for (char_ix, c) in text.char_indices() { - let ix = self.text.len() + char_ix; - self.chars |= 1 << ix; - self.chars_utf16 |= 1 << ix; - self.chars_utf16 |= (c.len_utf16() as Bitmap) << ix; - self.newlines |= ((c == '\n') as Bitmap) << ix; - self.tabs |= ((c == '\t') as Bitmap) << ix; - } - self.text.push_str(text); + self.append(Chunk::new(text).as_slice()); } #[inline(always)] diff --git a/crates/rope/src/rope.rs b/crates/rope/src/rope.rs index 50f9ba044d90072aa9c6fc2fc4abfd6d0e6b98cb..fba7b96aca83fa05c0d6f3e7992ad7443ec7958a 100644 --- a/crates/rope/src/rope.rs +++ b/crates/rope/src/rope.rs @@ -227,7 +227,7 @@ impl Rope { #[cfg(all(test, not(rust_analyzer)))] const PARALLEL_THRESHOLD: usize = 4; #[cfg(not(all(test, not(rust_analyzer))))] - const PARALLEL_THRESHOLD: usize = 4 * (2 * sum_tree::TREE_BASE); + const PARALLEL_THRESHOLD: usize = 84 * (2 * sum_tree::TREE_BASE); if new_chunks.len() >= PARALLEL_THRESHOLD { self.chunks diff --git a/crates/rules_library/src/rules_library.rs b/crates/rules_library/src/rules_library.rs index 09b7e0b539cde7371b97ef092fbd8f904b241c13..fc6af46782f26615aa0f5faeb7062ca03181ab9b 100644 --- a/crates/rules_library/src/rules_library.rs +++ b/crates/rules_library/src/rules_library.rs @@ -3,9 +3,9 @@ use collections::{HashMap, HashSet}; use editor::{CompletionProvider, SelectionEffects}; use editor::{CurrentLineHighlight, Editor, EditorElement, EditorEvent, EditorStyle, actions::Tab}; use gpui::{ - Action, App, Bounds, DEFAULT_ADDITIONAL_WINDOW_SIZE, Entity, EventEmitter, Focusable, - PromptLevel, Subscription, Task, TextStyle, TitlebarOptions, WindowBounds, WindowHandle, - WindowOptions, actions, point, size, transparent_black, + App, Bounds, DEFAULT_ADDITIONAL_WINDOW_SIZE, Entity, EventEmitter, Focusable, PromptLevel, + Subscription, Task, TextStyle, TitlebarOptions, WindowBounds, WindowHandle, WindowOptions, + actions, point, size, transparent_black, }; use language::{Buffer, LanguageRegistry, language_settings::SoftWrap}; use language_model::{ @@ -21,9 +21,7 @@ use std::sync::atomic::AtomicBool; use std::time::Duration; use theme::ThemeSettings; use title_bar::platform_title_bar::PlatformTitleBar; -use ui::{ - Divider, KeyBinding, ListItem, ListItemSpacing, ListSubHeader, Render, Tooltip, prelude::*, -}; +use ui::{Divider, ListItem, ListItemSpacing, ListSubHeader, Tooltip, prelude::*}; use util::{ResultExt, TryFutureExt}; use workspace::{Workspace, WorkspaceSettings, client_side_decorations}; use zed_actions::assistant::InlineAssist; @@ -44,15 +42,12 @@ actions!( /// Duplicates the selected rule. DuplicateRule, /// Toggles whether the selected rule is a default rule. - ToggleDefaultRule + ToggleDefaultRule, + /// Restores a built-in rule to its default content. + RestoreDefaultContent ] ); -const BUILT_IN_TOOLTIP_TEXT: &str = concat!( - "This rule supports special functionality.\n", - "It's read-only, but you can remove it from your default rules." -); - pub trait InlineAssistDelegate { fn assist( &self, @@ -211,13 +206,8 @@ impl PickerDelegate for RulePickerDelegate { self.filtered_entries.len() } - fn no_matches_text(&self, _window: &mut Window, cx: &mut App) -> Option { - let text = if self.store.read(cx).prompt_count() == 0 { - "No rules.".into() - } else { - "No rules found matching your search.".into() - }; - Some(text) + fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option { + Some("No rules found matching your search.".into()) } fn selected_index(&self) -> usize { @@ -270,23 +260,35 @@ impl PickerDelegate for RulePickerDelegate { .background_spawn(async move { let matches = search.await; - let (default_rules, non_default_rules): (Vec<_>, Vec<_>) = - matches.iter().partition(|rule| rule.default); + let (built_in_rules, user_rules): (Vec<_>, Vec<_>) = + matches.into_iter().partition(|rule| rule.id.is_built_in()); + let (default_rules, other_rules): (Vec<_>, Vec<_>) = + user_rules.into_iter().partition(|rule| rule.default); let mut filtered_entries = Vec::new(); + if !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 +343,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 +384,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 +394,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 +571,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 +659,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(built_in) = prompt_id.as_built_in() else { + return; + }; + + if let Some(rule_editor) = self.rule_editors.get(&prompt_id) { + rule_editor.body_editor.update(cx, |editor, cx| { + editor.set_text(built_in.default_content(), window, cx); + }); + } + } + pub fn toggle_default_for_rule( &mut self, prompt_id: PromptId, @@ -690,7 +715,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 +746,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 +758,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 +934,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 +993,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 +1006,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 +1173,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 +1219,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 +1293,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 +1303,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 +1354,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 +1407,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) @@ -1398,31 +1423,7 @@ impl Render for RulesLibrary { this.border_t_1().border_color(cx.theme().colors().border) }) .child(self.render_rule_list(cx)) - .map(|el| { - if self.store.read(cx).prompt_count() == 0 { - el.child( - v_flex() - .h_full() - .flex_1() - .items_center() - .justify_center() - .border_l_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().editor_background) - .child( - Button::new("create-rule", "New Rule") - .style(ButtonStyle::Outlined) - .key_binding(KeyBinding::for_action(&NewRule, cx)) - .on_click(|_, window, cx| { - window - .dispatch_action(NewRule.boxed_clone(), cx) - }), - ), - ) - } else { - el.child(self.render_active_rule(cx)) - } - }), + .child(self.render_active_rule(cx)), ), window, cx, 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/Cargo.toml b/crates/search/Cargo.toml index ac07e43fb0317bbe4e11fde98fc3ccbe886baf9d..02eb611fc22570c9028c21bc00187c627198475c 100644 --- a/crates/search/Cargo.toml +++ b/crates/search/Cargo.toml @@ -43,6 +43,8 @@ util_macros.workspace = true workspace.workspace = true zed_actions.workspace = true itertools.workspace = true +ztracing.workspace = true +tracing.workspace = true [dev-dependencies] client = { workspace = true, features = ["test-support"] } @@ -53,3 +55,7 @@ lsp.workspace = true pretty_assertions.workspace = true unindent.workspace = true workspace = { workspace = true, features = ["test-support"] } + +[package.metadata.cargo-machete] +ignored = ["tracing"] + diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index a9c26ac9bad0f524acdb47d6f09c2bd67cb8dfc6..12b283ab22937b7952d18d63b1378d2914211f9b 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -7,7 +7,6 @@ use crate::{ search_bar::{ActionButtonState, input_base_styles, render_action_button, render_text_input}, }; use any_vec::AnyVec; -use anyhow::Context as _; use collections::HashMap; use editor::{ DisplayPoint, Editor, EditorSettings, MultiBufferOffset, @@ -518,7 +517,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( @@ -634,15 +633,19 @@ impl BufferSearchBar { .read(cx) .as_singleton() .expect("query editor should be backed by a singleton buffer"); + query_buffer .read(cx) .set_language_registry(languages.clone()); cx.spawn(async move |buffer_search_bar, cx| { + use anyhow::Context as _; + let regex_language = languages .language_for_name("regex") .await .context("loading regex language")?; + buffer_search_bar .update(cx, |buffer_search_bar, cx| { buffer_search_bar.regex_language = Some(regex_language); @@ -706,7 +709,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); @@ -729,12 +732,14 @@ impl BufferSearchBar { self.search_suggested(window, cx); self.smartcase(window, cx); self.sync_select_next_case_sensitivity(cx); - self.replace_enabled = deploy.replace_enabled; - self.selection_search_enabled = if deploy.selection_search_enabled { - Some(FilteredSearchRange::Default) - } else { - None - }; + self.replace_enabled |= deploy.replace_enabled; + self.selection_search_enabled = + self.selection_search_enabled + .or(if deploy.selection_search_enabled { + Some(FilteredSearchRange::Default) + } else { + None + }); if deploy.focus { let mut handle = self.query_editor.focus_handle(cx); let mut select_query = true; @@ -747,7 +752,7 @@ impl BufferSearchBar { self.select_query(window, cx); } - window.focus(&handle); + window.focus(&handle, cx); } return true; } @@ -876,7 +881,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(); } @@ -907,7 +912,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); } } @@ -1382,7 +1387,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(); } @@ -1429,9 +1434,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) { @@ -1442,7 +1447,7 @@ impl BufferSearchBar { } else { self.query_editor.focus_handle(cx) }; - self.focus(&handle, window); + self.focus(&handle, window, cx); cx.notify(); } } @@ -2036,7 +2041,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!( @@ -2054,7 +2059,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!( @@ -2107,7 +2112,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); }); }) @@ -2173,7 +2178,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 41de3246532d6fcfe781f9c5c1d2c250f0cae93e..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); } } }); @@ -1147,7 +1147,7 @@ impl ProjectSearchView { }; search.update(cx, |search, cx| { - search.replace_enabled = action.replace_enabled; + search.replace_enabled |= action.replace_enabled; if let Some(query) = query { search.set_query(&query, 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) { @@ -1545,6 +1545,7 @@ impl ProjectSearchView { } } + #[ztracing::instrument(skip_all)] fn highlight_matches( &self, match_ranges: &[Range], @@ -1749,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); }); } } @@ -1782,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(); }); } @@ -1831,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(); }); } @@ -4351,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/keymap_file.rs b/crates/settings/src/keymap_file.rs index 2ef1dfc5385592b9757eff5ec631af818ae1869c..146fc371b14cb5cba428d3a7beec11cc3008e7dd 100644 --- a/crates/settings/src/keymap_file.rs +++ b/crates/settings/src/keymap_file.rs @@ -303,19 +303,21 @@ impl KeymapFile { if errors.is_empty() { KeymapFileLoadResult::Success { key_bindings } } else { - let mut error_message = "Errors in user keymap file.\n".to_owned(); + let mut error_message = "Errors in user keymap file.".to_owned(); + for (context, section_errors) in errors { if context.is_empty() { - let _ = write!(error_message, "\n\nIn section without context predicate:"); + let _ = write!(error_message, "\nIn section without context predicate:"); } else { let _ = write!( error_message, - "\n\nIn section with {}:", + "\nIn section with {}:", MarkdownInlineCode(&format!("context = \"{}\"", context)) ); } let _ = write!(error_message, "{section_errors}"); } + KeymapFileLoadResult::SomeFailedToLoad { key_bindings, error_message: MarkdownString(error_message), diff --git a/crates/settings/src/settings_content.rs b/crates/settings/src/settings_content.rs index 230e1ffd48b9cc1d58aba59ea0af2c629e36c8e3..a00daaab1b9a93e1ec20b173dd6864849880d55e 100644 --- a/crates/settings/src/settings_content.rs +++ b/crates/settings/src/settings_content.rs @@ -158,6 +158,9 @@ pub struct SettingsContent { /// Default: false pub disable_ai: Option, + /// Settings for the which-key popup. + pub which_key: Option, + /// Settings related to Vim mode in Zed. pub vim: Option, } @@ -286,6 +289,10 @@ pub struct TitleBarSettingsContent { /// /// Default: true pub show_sign_in: Option, + /// Whether to show the user menu button in the title bar. + /// + /// Default: true + pub show_user_menu: Option, /// Whether to show the menus in the title bar. /// /// Default: false @@ -511,6 +518,11 @@ pub struct GitPanelSettingsContent { /// /// Default: false pub collapse_untracked_diff: Option, + + /// Whether to show entries with tree or flat view in the panel + /// + /// Default: false + pub tree_view: Option, } #[derive( @@ -889,9 +901,19 @@ pub enum ImageFileSizeUnit { pub struct RemoteSettingsContent { pub ssh_connections: Option>, pub wsl_connections: Option>, + pub dev_container_connections: Option>, pub read_ssh_config: Option, } +#[with_fallible_options] +#[derive( + Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, MergeFrom, Hash, +)] +pub struct DevContainerConnection { + pub name: SharedString, + pub container_id: SharedString, +} + #[with_fallible_options] #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, JsonSchema, MergeFrom)] pub struct SshConnection { @@ -901,7 +923,7 @@ pub struct SshConnection { #[serde(default)] pub args: Vec, #[serde(default)] - pub projects: collections::BTreeSet, + pub projects: collections::BTreeSet, /// Name to use for this server in UI. pub nickname: Option, // By default Zed will download the binary to the host directly. @@ -911,6 +933,9 @@ pub struct SshConnection { pub upload_binary_over_ssh: Option, pub port_forwards: Option>, + /// Timeout in seconds for SSH connection and downloading the remote server binary. + /// Defaults to 10 seconds if not specified. + pub connection_timeout: Option, } #[derive(Clone, Default, Serialize, Deserialize, PartialEq, JsonSchema, MergeFrom, Debug)] @@ -918,14 +943,14 @@ pub struct WslConnection { pub distro_name: SharedString, pub user: Option, #[serde(default)] - pub projects: BTreeSet, + pub projects: BTreeSet, } #[with_fallible_options] #[derive( Clone, Debug, Default, Serialize, PartialEq, Eq, PartialOrd, Ord, Deserialize, JsonSchema, )] -pub struct SshProject { +pub struct RemoteProject { pub paths: Vec, } @@ -954,6 +979,19 @@ pub struct ReplSettingsContent { pub max_columns: Option, } +/// Settings for configuring the which-key popup behaviour. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)] +pub struct WhichKeySettingsContent { + /// Whether to show the which-key popup when holding down key combinations + /// + /// Default: false + pub enabled: Option, + /// Delay in milliseconds before showing the which-key popup. + /// + /// Default: 700 + pub delay_ms: Option, +} + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] /// An ExtendingVec in the settings can only accumulate new values. /// diff --git a/crates/settings/src/settings_content/agent.rs b/crates/settings/src/settings_content/agent.rs index 2ea9f0cd5788f3312061ec8ffef2a728403463ac..d3a8e40084fc5db7fd348908b1b721617c7c8206 100644 --- a/crates/settings/src/settings_content/agent.rs +++ b/crates/settings/src/settings_content/agent.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use settings_macros::{MergeFrom, with_fallible_options}; use std::{borrow::Cow, path::PathBuf, sync::Arc}; -use crate::DockPosition; +use crate::{DockPosition, DockSide}; #[with_fallible_options] #[derive(Clone, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom, Debug, Default)] @@ -22,6 +22,10 @@ pub struct AgentSettingsContent { /// /// Default: right pub dock: Option, + /// Where to dock the utility pane (the thread view pane). + /// + /// Default: left + pub agents_panel_dock: Option, /// Default width in pixels when the agent panel is docked to the left or right. /// /// Default: 640 @@ -34,9 +38,18 @@ 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 generating git commit messages. Defaults to default_model when not specified. + /// Model to use for the inline assistant when streaming tools are enabled. + /// + /// Default: true + pub inline_assistant_use_streaming_tools: Option, + /// Model to use for generating git commit messages. + /// + /// Default: true pub commit_message_model: Option, /// Model to use for generating thread summaries. Defaults to default_model when not specified. pub thread_summary_model: Option, @@ -129,6 +142,9 @@ impl AgentSettingsContent { model, }); } + pub fn set_inline_assistant_use_streaming_tools(&mut self, use_tools: bool) { + self.inline_assistant_use_streaming_tools = Some(use_tools); + } pub fn set_commit_message_model(&mut self, provider: String, model: String) { self.commit_message_model = Some(LanguageModelSelection { @@ -163,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.rs b/crates/settings/src/settings_content/language.rs index 25ff60e9f46cf797b815227222a3d27a6353c396..f9c85f18f380a7ad82b0d8bc202fe3763ba3a832 100644 --- a/crates/settings/src/settings_content/language.rs +++ b/crates/settings/src/settings_content/language.rs @@ -186,22 +186,20 @@ pub struct CopilotSettingsContent { pub enterprise_uri: Option, } +#[with_fallible_options] #[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq)] pub struct CodestralSettingsContent { /// Model to use for completions. /// /// Default: "codestral-latest" - #[serde(default)] pub model: Option, /// Maximum tokens to generate. /// /// Default: 150 - #[serde(default)] pub max_tokens: Option, /// Api URL to use for completions. /// /// Default: "https://codestral.mistral.ai" - #[serde(default)] pub api_url: Option, } diff --git a/crates/settings/src/settings_content/language_model.rs b/crates/settings/src/settings_content/language_model.rs index 48f5a463a4b8d896885d9ba5b7d804d16ecb5b6b..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, @@ -92,6 +94,7 @@ pub enum BedrockAuthMethodContent { #[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema, MergeFrom)] pub struct OllamaSettingsContent { pub api_url: Option, + pub auto_discover: Option, pub available_models: Option>, } diff --git a/crates/settings/src/settings_content/project.rs b/crates/settings/src/settings_content/project.rs index 5cd708694d0cfd3699fdc822509d0209f9a96fd1..8e2d864149c9ecb6ca38ca73ef58205f588dc07b 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)] @@ -282,6 +288,11 @@ impl std::fmt::Debug for ContextServerCommand { #[with_fallible_options] #[derive(Copy, Clone, Debug, PartialEq, Default, Serialize, Deserialize, JsonSchema, MergeFrom)] pub struct GitSettings { + /// Whether or not to enable git integration. + /// + /// Default: true + #[serde(flatten)] + pub enabled: Option, /// Whether or not to show the git gutter. /// /// Default: tracked_files @@ -311,6 +322,25 @@ pub struct GitSettings { pub path_style: Option, } +#[with_fallible_options] +#[derive(Clone, Copy, Debug, PartialEq, Default, Serialize, Deserialize, JsonSchema, MergeFrom)] +#[serde(rename_all = "snake_case")] +pub struct GitEnabledSettings { + pub disable_git: Option, + pub enable_status: Option, + pub enable_diff: Option, +} + +impl GitEnabledSettings { + pub fn is_git_status_enabled(&self) -> bool { + !self.disable_git.unwrap_or(false) && self.enable_status.unwrap_or(true) + } + + pub fn is_git_diff_enabled(&self) -> bool { + !self.disable_git.unwrap_or(false) && self.enable_diff.unwrap_or(true) + } +} + #[derive( Clone, Copy, diff --git a/crates/settings/src/settings_content/workspace.rs b/crates/settings/src/settings_content/workspace.rs index b809a8fa85a9b27da3f3af5242e99b280466a4bb..832f6ec409c8594c55beab1fd6f327c1215f8bdc 100644 --- a/crates/settings/src/settings_content/workspace.rs +++ b/crates/settings/src/settings_content/workspace.rs @@ -42,7 +42,7 @@ pub struct WorkspaceSettingsContent { /// Default: off pub autosave: Option, /// Controls previous session restoration in freshly launched Zed instance. - /// Values: none, last_workspace, last_session + /// Values: empty_tab, last_workspace, last_session, launchpad /// Default: last_session pub restore_on_startup: Option, /// Whether to attempt to restore previous file's state when opening it again. @@ -382,13 +382,16 @@ impl CloseWindowWhenNoItems { )] #[serde(rename_all = "snake_case")] pub enum RestoreOnStartupBehavior { - /// Always start with an empty editor - None, + /// Always start with an empty editor tab + #[serde(alias = "none")] + EmptyTab, /// Restore the workspace that was closed last. LastWorkspace, /// Restore all workspaces that were open when quitting Zed. #[default] LastSession, + /// Show the launchpad with recent projects (no tabs). + Launchpad, } #[with_fallible_options] diff --git a/crates/settings/src/settings_store.rs b/crates/settings/src/settings_store.rs index 72e2d3ef099659c5ad27e7f1aaafaee24354d4a9..abd45a141647f6ba13708c549188a22988c78069 100644 --- a/crates/settings/src/settings_store.rs +++ b/crates/settings/src/settings_store.rs @@ -247,6 +247,7 @@ pub trait AnySettingValue: 'static + Send + Sync { fn all_local_values(&self) -> Vec<(WorktreeId, Arc, &dyn Any)>; fn set_global_value(&mut self, value: Box); fn set_local_value(&mut self, root_id: WorktreeId, path: Arc, value: Box); + fn clear_local_values(&mut self, root_id: WorktreeId); } /// Parameters that are used when generating some JSON schemas at runtime. @@ -971,6 +972,11 @@ impl SettingsStore { pub fn clear_local_settings(&mut self, root_id: WorktreeId, cx: &mut App) -> Result<()> { self.local_settings .retain(|(worktree_id, _), _| worktree_id != &root_id); + self.raw_editorconfig_settings + .retain(|(worktree_id, _), _| worktree_id != &root_id); + for setting_value in self.setting_values.values_mut() { + setting_value.clear_local_values(root_id); + } self.recompute_values(Some((root_id, RelPath::empty())), cx); Ok(()) } @@ -1338,6 +1344,11 @@ impl AnySettingValue for SettingValue { Err(ix) => self.local_values.insert(ix, (root_id, path, value)), } } + + fn clear_local_values(&mut self, root_id: WorktreeId) { + self.local_values + .retain(|(worktree_id, _, _)| *worktree_id != root_id); + } } #[cfg(test)] diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index 587850303f13649fcc4adf8cf4ddbb8dc7181dcb..d77754f611e8eb1746ee9061ce5b5e1dfdbdafdb 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -215,6 +215,7 @@ impl VsCodeSettings { vim: None, vim_mode: None, workspace: self.workspace_settings_content(), + which_key: None, } } diff --git a/crates/settings_ui/Cargo.toml b/crates/settings_ui/Cargo.toml index b5a259a3b9f901f4885b1cde8ad1e933efb263c0..256ec2de557e903405d1c3431ef44e98d757d3c6 100644 --- a/crates/settings_ui/Cargo.toml +++ b/crates/settings_ui/Cargo.toml @@ -18,6 +18,9 @@ test-support = [] [dependencies] anyhow.workspace = true bm25 = "2.3.2" +copilot.workspace = true +edit_prediction.workspace = true +language_models.workspace = true editor.workspace = true feature_flags.workspace = true fs.workspace = true @@ -38,8 +41,8 @@ strum.workspace = true telemetry.workspace = true theme.workspace = true title_bar.workspace = true -ui.workspace = true ui_input.workspace = true +ui.workspace = true util.workspace = true workspace.workspace = true zed_actions.workspace = true diff --git a/crates/settings_ui/src/components.rs b/crates/settings_ui/src/components.rs index b073372ac9b625036252e0a1722a960c8f6b3c45..f9754b0c749a77423930ef881e5b60ad3535b83d 100644 --- a/crates/settings_ui/src/components.rs +++ b/crates/settings_ui/src/components.rs @@ -2,10 +2,12 @@ mod dropdown; mod font_picker; mod icon_theme_picker; mod input_field; +mod section_items; mod theme_picker; pub use dropdown::*; pub use font_picker::font_picker; pub use icon_theme_picker::icon_theme_picker; pub use input_field::*; +pub use section_items::*; pub use theme_picker::theme_picker; diff --git a/crates/settings_ui/src/components/input_field.rs b/crates/settings_ui/src/components/input_field.rs index 57917c321127baf2e96e3862106461331afaf86f..575da7f7ae13f8a304b23d57dd41607e7b7c512a 100644 --- a/crates/settings_ui/src/components/input_field.rs +++ b/crates/settings_ui/src/components/input_field.rs @@ -13,6 +13,7 @@ pub struct SettingsInputField { tab_index: Option, } +// TODO: Update the `ui_input::InputField` to use `window.use_state` and `RenceOnce` and remove this component impl SettingsInputField { pub fn new() -> Self { Self { diff --git a/crates/settings_ui/src/components/section_items.rs b/crates/settings_ui/src/components/section_items.rs new file mode 100644 index 0000000000000000000000000000000000000000..69559d24f447f3d218b296600ed1ecdd9bf1dc30 --- /dev/null +++ b/crates/settings_ui/src/components/section_items.rs @@ -0,0 +1,56 @@ +use gpui::{IntoElement, ParentElement, Styled}; +use ui::{Divider, DividerColor, prelude::*}; + +#[derive(IntoElement)] +pub struct SettingsSectionHeader { + icon: Option, + label: SharedString, + no_padding: bool, +} + +impl SettingsSectionHeader { + pub fn new(label: impl Into) -> Self { + Self { + label: label.into(), + icon: None, + no_padding: false, + } + } + + pub fn icon(mut self, icon: IconName) -> Self { + self.icon = Some(icon); + self + } + + pub fn no_padding(mut self, no_padding: bool) -> Self { + self.no_padding = no_padding; + self + } +} + +impl RenderOnce for SettingsSectionHeader { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let label = Label::new(self.label) + .size(LabelSize::Small) + .color(Color::Muted) + .buffer_font(cx); + + v_flex() + .w_full() + .when(!self.no_padding, |this| this.px_8()) + .gap_1p5() + .map(|this| { + if self.icon.is_some() { + this.child( + h_flex() + .gap_1p5() + .child(Icon::new(self.icon.unwrap()).color(Color::Muted)) + .child(label), + ) + } else { + this.child(label) + } + }) + .child(Divider::horizontal().color(DividerColor::BorderFaded)) + } +} diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 0c383970c990c3ba19eab7aa5d3b7c699f8a195e..ca2e23252a4483b365c7c42cfd086105d757a097 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -1,12 +1,12 @@ -use gpui::App; +use gpui::{Action as _, App}; use settings::{LanguageSettingsContent, SettingsContent}; use std::sync::Arc; use strum::IntoDiscriminant as _; use ui::{IntoElement, SharedString}; use crate::{ - DynamicItem, PROJECT, SettingField, SettingItem, SettingsFieldMetadata, SettingsPage, - SettingsPageItem, SubPageLink, USER, all_language_names, sub_page_stack, + ActionLink, DynamicItem, PROJECT, SettingField, SettingItem, SettingsFieldMetadata, + SettingsPage, SettingsPageItem, SubPageLink, USER, all_language_names, sub_page_stack, }; const DEFAULT_STRING: String = String::new(); @@ -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", @@ -1054,6 +1076,25 @@ pub(crate) fn settings_data(cx: &App) -> Vec { SettingsPage { title: "Keymap", items: vec![ + SettingsPageItem::SectionHeader("Keybindings"), + SettingsPageItem::ActionLink(ActionLink { + title: "Edit Keybindings".into(), + description: Some("Customize keybindings in the keymap editor.".into()), + button_text: "Open Keymap".into(), + on_click: Arc::new(|settings_window, window, cx| { + let Some(original_window) = settings_window.original_window else { + return; + }; + original_window + .update(cx, |_workspace, original_window, cx| { + original_window + .dispatch_action(zed_actions::OpenKeymap.boxed_clone(), cx); + original_window.activate_window(); + }) + .ok(); + window.remove_window(); + }), + }), SettingsPageItem::SectionHeader("Base Keymap"), SettingsPageItem::SettingItem(SettingItem { title: "Base Keymap", @@ -1192,6 +1233,49 @@ pub(crate) fn settings_data(cx: &App) -> Vec { } }).collect(), }), + SettingsPageItem::SectionHeader("Which-key Menu"), + SettingsPageItem::SettingItem(SettingItem { + title: "Show Which-key Menu", + description: "Display the which-key menu with matching bindings while a multi-stroke binding is pending.", + field: Box::new(SettingField { + json_path: Some("which_key.enabled"), + pick: |settings_content| { + settings_content + .which_key + .as_ref() + .and_then(|settings| settings.enabled.as_ref()) + }, + write: |settings_content, value| { + settings_content + .which_key + .get_or_insert_default() + .enabled = value; + }, + }), + metadata: None, + files: USER, + }), + SettingsPageItem::SettingItem(SettingItem { + title: "Menu Delay", + description: "Delay in milliseconds before the which-key menu appears.", + field: Box::new(SettingField { + json_path: Some("which_key.delay_ms"), + pick: |settings_content| { + settings_content + .which_key + .as_ref() + .and_then(|settings| settings.delay_ms.as_ref()) + }, + write: |settings_content, value| { + settings_content + .which_key + .get_or_insert_default() + .delay_ms = value; + }, + }), + metadata: None, + files: USER, + }), SettingsPageItem::SectionHeader("Multibuffer"), SettingsPageItem::SettingItem(SettingItem { title: "Double Click In Multibuffer", @@ -2330,8 +2414,12 @@ pub(crate) fn settings_data(cx: &App) -> Vec { // Note that `crates/json_schema_store` solves the same problem, there is probably a way to unify the two items.push(SettingsPageItem::SectionHeader(LANGUAGES_SECTION_HEADER)); items.extend(all_language_names(cx).into_iter().map(|language_name| { + let link = format!("languages.{language_name}"); SettingsPageItem::SubPageLink(SubPageLink { title: language_name, + description: None, + json_path: Some(link.leak()), + in_json: true, files: USER | PROJECT, render: Arc::new(|this, window, cx| { this.render_sub_page_items( @@ -2909,40 +2997,58 @@ pub(crate) fn settings_data(cx: &App) -> Vec { files: USER, }), SettingsPageItem::SettingItem(SettingItem { - title: "Show User Picture", - description: "Show user picture in the titlebar.", + title: "Show Sign In", + description: "Show the sign in button in the titlebar.", field: Box::new(SettingField { - json_path: Some("title_bar.show_user_picture"), + json_path: Some("title_bar.show_sign_in"), pick: |settings_content| { + settings_content.title_bar.as_ref()?.show_sign_in.as_ref() + }, + write: |settings_content, value| { settings_content .title_bar - .as_ref()? - .show_user_picture - .as_ref() + .get_or_insert_default() + .show_sign_in = value; + }, + }), + metadata: None, + files: USER, + }), + SettingsPageItem::SettingItem(SettingItem { + title: "Show User Menu", + description: "Show the user menu button in the titlebar.", + field: Box::new(SettingField { + json_path: Some("title_bar.show_user_menu"), + pick: |settings_content| { + settings_content.title_bar.as_ref()?.show_user_menu.as_ref() }, write: |settings_content, value| { settings_content .title_bar .get_or_insert_default() - .show_user_picture = value; + .show_user_menu = value; }, }), metadata: None, files: USER, }), SettingsPageItem::SettingItem(SettingItem { - title: "Show Sign In", - description: "Show the sign in button in the titlebar.", + title: "Show User Picture", + description: "Show user picture in the titlebar.", field: Box::new(SettingField { - json_path: Some("title_bar.show_sign_in"), + json_path: Some("title_bar.show_user_picture"), pick: |settings_content| { - settings_content.title_bar.as_ref()?.show_sign_in.as_ref() + settings_content + .title_bar + .as_ref()? + .show_user_picture + .as_ref() }, write: |settings_content, value| { settings_content .title_bar .get_or_insert_default() - .show_sign_in = value; + .show_user_picture = value; }, }), metadata: None, @@ -4314,6 +4420,24 @@ pub(crate) fn settings_data(cx: &App) -> Vec { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Tree View", + description: "Enable to show entries in tree view list, disable to show in flat view list.", + field: Box::new(SettingField { + json_path: Some("git_panel.tree_view"), + pick: |settings_content| { + settings_content.git_panel.as_ref()?.tree_view.as_ref() + }, + write: |settings_content, value| { + settings_content + .git_panel + .get_or_insert_default() + .tree_view = value; + }, + }), + metadata: None, + files: USER, + }), SettingsPageItem::SettingItem(SettingItem { title: "Scroll Bar", description: "How and when the scrollbar should be displayed.", @@ -5395,6 +5519,102 @@ pub(crate) fn settings_data(cx: &App) -> Vec { SettingsPage { title: "Version Control", items: vec![ + SettingsPageItem::SectionHeader("Git Integration"), + SettingsPageItem::DynamicItem(DynamicItem { + discriminant: SettingItem { + files: USER, + title: "Disable Git Integration", + description: "Disable all Git integration features in Zed.", + field: Box::new(SettingField:: { + json_path: Some("git.disable_git"), + pick: |settings_content| { + settings_content + .git + .as_ref()? + .enabled + .as_ref()? + .disable_git + .as_ref() + }, + write: |settings_content, value| { + settings_content + .git + .get_or_insert_default() + .enabled + .get_or_insert_default() + .disable_git = value; + }, + }), + metadata: None, + }, + pick_discriminant: |settings_content| { + let disabled = settings_content + .git + .as_ref()? + .enabled + .as_ref()? + .disable_git + .unwrap_or(false); + Some(if disabled { 0 } else { 1 }) + }, + fields: vec![ + vec![], + vec![ + SettingItem { + files: USER, + title: "Enable Git Status", + description: "Show Git status information in the editor.", + field: Box::new(SettingField:: { + json_path: Some("git.enable_status"), + pick: |settings_content| { + settings_content + .git + .as_ref()? + .enabled + .as_ref()? + .enable_status + .as_ref() + }, + write: |settings_content, value| { + settings_content + .git + .get_or_insert_default() + .enabled + .get_or_insert_default() + .enable_status = value; + }, + }), + metadata: None, + }, + SettingItem { + files: USER, + title: "Enable Git Diff", + description: "Show Git diff information in the editor.", + field: Box::new(SettingField:: { + json_path: Some("git.enable_diff"), + pick: |settings_content| { + settings_content + .git + .as_ref()? + .enabled + .as_ref()? + .enable_diff + .as_ref() + }, + write: |settings_content, value| { + settings_content + .git + .get_or_insert_default() + .enabled + .get_or_insert_default() + .enable_diff = value; + }, + }), + metadata: None, + }, + ], + ], + }), SettingsPageItem::SectionHeader("Git Gutter"), SettingsPageItem::SettingItem(SettingItem { title: "Visibility", @@ -5995,7 +6215,7 @@ pub(crate) fn settings_data(cx: &App) -> Vec { files: USER, }), SettingsPageItem::SettingItem(SettingItem { - title: "In Text Threads", + title: "Display In Text Threads", description: "Whether edit predictions are enabled when editing text threads in the agent panel.", field: Box::new(SettingField { json_path: Some("edit_prediction.in_text_threads"), @@ -6009,42 +6229,6 @@ pub(crate) fn settings_data(cx: &App) -> Vec { metadata: None, files: USER, }), - SettingsPageItem::SettingItem(SettingItem { - title: "Copilot Provider", - description: "Use GitHub Copilot as your edit prediction provider.", - field: Box::new( - SettingField { - json_path: Some("edit_prediction.copilot_provider"), - pick: |settings_content| { - settings_content.project.all_languages.edit_predictions.as_ref()?.copilot.as_ref() - }, - write: |settings_content, value| { - settings_content.project.all_languages.edit_predictions.get_or_insert_default().copilot = value; - }, - } - .unimplemented(), - ), - metadata: None, - files: USER | PROJECT, - }), - SettingsPageItem::SettingItem(SettingItem { - title: "Codestral Provider", - description: "Use Mistral's Codestral as your edit prediction provider.", - field: Box::new( - SettingField { - json_path: Some("edit_prediction.codestral_provider"), - pick: |settings_content| { - settings_content.project.all_languages.edit_predictions.as_ref()?.codestral.as_ref() - }, - write: |settings_content, value| { - settings_content.project.all_languages.edit_predictions.get_or_insert_default().codestral = value; - }, - } - .unimplemented(), - ), - metadata: None, - files: USER | PROJECT, - }), ] ); items @@ -7467,9 +7651,23 @@ fn non_editor_language_settings_data() -> Vec { fn edit_prediction_language_settings_section() -> Vec { vec![ SettingsPageItem::SectionHeader("Edit Predictions"), + SettingsPageItem::SubPageLink(SubPageLink { + title: "Configure Providers".into(), + json_path: Some("edit_predictions.providers"), + description: Some("Set up different edit prediction providers in complement to Zed's built-in Zeta model.".into()), + in_json: false, + files: USER, + render: Arc::new(|_, window, cx| { + let settings_window = cx.entity(); + let page = window.use_state(cx, |_, _| { + crate::pages::EditPredictionSetupPage::new(settings_window) + }); + page.into_any_element() + }), + }), SettingsPageItem::SettingItem(SettingItem { title: "Show Edit Predictions", - description: "Controls whether edit predictions are shown immediately or manually by triggering `editor::showeditprediction` (false).", + description: "Controls whether edit predictions are shown immediately or manually.", field: Box::new(SettingField { json_path: Some("languages.$(language).show_edit_predictions"), pick: |settings_content| { @@ -7487,7 +7685,7 @@ fn edit_prediction_language_settings_section() -> Vec { files: USER | PROJECT, }), SettingsPageItem::SettingItem(SettingItem { - title: "Edit Predictions Disabled In", + title: "Disable in Language Scopes", description: "Controls whether edit predictions are shown in the given language scopes.", field: Box::new( SettingField { diff --git a/crates/settings_ui/src/pages.rs b/crates/settings_ui/src/pages.rs new file mode 100644 index 0000000000000000000000000000000000000000..2b2c4818c1322216707f38bf93cefffeb14add03 --- /dev/null +++ b/crates/settings_ui/src/pages.rs @@ -0,0 +1,2 @@ +mod edit_prediction_provider_setup; +pub use edit_prediction_provider_setup::EditPredictionSetupPage; diff --git a/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs b/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs new file mode 100644 index 0000000000000000000000000000000000000000..fb8f967613fa195080f62c5ab2ce76a43f3d1e22 --- /dev/null +++ b/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs @@ -0,0 +1,365 @@ +use edit_prediction::{ + ApiKeyState, Zeta2FeatureFlag, + mercury::{MERCURY_CREDENTIALS_URL, mercury_api_token}, + sweep_ai::{SWEEP_CREDENTIALS_URL, sweep_api_token}, +}; +use feature_flags::FeatureFlagAppExt as _; +use gpui::{Entity, ScrollHandle, prelude::*}; +use language_models::provider::mistral::{CODESTRAL_API_URL, codestral_api_key}; +use ui::{ButtonLink, ConfiguredApiCard, WithScrollbar, prelude::*}; + +use crate::{ + SettingField, SettingItem, SettingsFieldMetadata, SettingsPageItem, SettingsWindow, USER, + components::{SettingsInputField, SettingsSectionHeader}, +}; + +pub struct EditPredictionSetupPage { + settings_window: Entity, + scroll_handle: ScrollHandle, +} + +impl EditPredictionSetupPage { + pub fn new(settings_window: Entity) -> Self { + Self { + settings_window, + scroll_handle: ScrollHandle::new(), + } + } +} + +impl Render for EditPredictionSetupPage { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let settings_window = self.settings_window.clone(); + + let providers = [ + Some(render_github_copilot_provider(window, cx).into_any_element()), + cx.has_flag::().then(|| { + render_api_key_provider( + IconName::Inception, + "Mercury", + "https://platform.inceptionlabs.ai/dashboard/api-keys".into(), + mercury_api_token(cx), + |_cx| MERCURY_CREDENTIALS_URL, + None, + window, + cx, + ) + .into_any_element() + }), + cx.has_flag::().then(|| { + render_api_key_provider( + IconName::SweepAi, + "Sweep", + "https://app.sweep.dev/".into(), + sweep_api_token(cx), + |_cx| SWEEP_CREDENTIALS_URL, + None, + window, + cx, + ) + .into_any_element() + }), + Some( + render_api_key_provider( + IconName::AiMistral, + "Codestral", + "https://console.mistral.ai/codestral".into(), + codestral_api_key(cx), + |cx| language_models::MistralLanguageModelProvider::api_url(cx), + Some(settings_window.update(cx, |settings_window, cx| { + let codestral_settings = codestral_settings(); + settings_window + .render_sub_page_items_section( + codestral_settings.iter().enumerate(), + None, + window, + cx, + ) + .into_any_element() + })), + window, + cx, + ) + .into_any_element(), + ), + ]; + + div() + .size_full() + .vertical_scrollbar_for(&self.scroll_handle, window, cx) + .child( + v_flex() + .id("ep-setup-page") + .min_w_0() + .size_full() + .px_8() + .pb_16() + .overflow_y_scroll() + .track_scroll(&self.scroll_handle) + .children(providers.into_iter().flatten()), + ) + } +} + +fn render_api_key_provider( + icon: IconName, + title: &'static str, + link: SharedString, + api_key_state: Entity, + current_url: fn(&mut App) -> SharedString, + additional_fields: Option, + window: &mut Window, + cx: &mut Context, +) -> impl IntoElement { + let weak_page = cx.weak_entity(); + _ = window.use_keyed_state(title, cx, |_, cx| { + let task = api_key_state.update(cx, |key_state, cx| { + key_state.load_if_needed(current_url(cx), |state| state, cx) + }); + cx.spawn(async move |_, cx| { + task.await.ok(); + weak_page + .update(cx, |_, cx| { + cx.notify(); + }) + .ok(); + }) + }); + + let (has_key, env_var_name, is_from_env_var) = api_key_state.read_with(cx, |state, _| { + ( + state.has_key(), + Some(state.env_var_name().clone()), + state.is_from_env_var(), + ) + }); + + let write_key = move |api_key: Option, cx: &mut App| { + api_key_state + .update(cx, |key_state, cx| { + let url = current_url(cx); + key_state.store(url, api_key, |key_state| key_state, cx) + }) + .detach_and_log_err(cx); + }; + + let base_container = v_flex().id(title).min_w_0().pt_8().gap_1p5(); + let header = SettingsSectionHeader::new(title) + .icon(icon) + .no_padding(true); + let button_link_label = format!("{} dashboard", title); + let description = h_flex() + .min_w_0() + .gap_0p5() + .child( + Label::new("Visit the") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child( + ButtonLink::new(button_link_label, link) + .no_icon(true) + .label_size(LabelSize::Small) + .label_color(Color::Muted), + ) + .child( + Label::new("to generate an API key.") + .size(LabelSize::Small) + .color(Color::Muted), + ); + let configured_card_label = if is_from_env_var { + "API Key Set in Environment Variable" + } else { + "API Key Configured" + }; + + let container = if has_key { + base_container.child(header).child( + ConfiguredApiCard::new(configured_card_label) + .button_label("Reset Key") + .button_tab_index(0) + .disabled(is_from_env_var) + .when_some(env_var_name, |this, env_var_name| { + this.when(is_from_env_var, |this| { + this.tooltip_label(format!( + "To reset your API key, unset the {} environment variable.", + env_var_name + )) + }) + }) + .on_click(move |_, _, cx| { + write_key(None, cx); + }), + ) + } else { + base_container.child(header).child( + h_flex() + .pt_2p5() + .w_full() + .justify_between() + .child( + v_flex() + .w_full() + .max_w_1_2() + .child(Label::new("API Key")) + .child(description) + .when_some(env_var_name, |this, env_var_name| { + this.child({ + let label = format!( + "Or set the {} env var and restart Zed.", + env_var_name.as_ref() + ); + Label::new(label).size(LabelSize::Small).color(Color::Muted) + }) + }), + ) + .child( + SettingsInputField::new() + .tab_index(0) + .with_placeholder("xxxxxxxxxxxxxxxxxxxx") + .on_confirm(move |api_key, cx| { + write_key(api_key.filter(|key| !key.is_empty()), cx); + }), + ), + ) + }; + + container.when_some(additional_fields, |this, additional_fields| { + this.child( + div() + .map(|this| if has_key { this.mt_1() } else { this.mt_4() }) + .px_neg_8() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child(additional_fields), + ) + }) +} + +fn codestral_settings() -> Box<[SettingsPageItem]> { + Box::new([ + SettingsPageItem::SettingItem(SettingItem { + title: "API URL", + description: "The API URL to use for Codestral.", + field: Box::new(SettingField { + pick: |settings| { + settings + .project + .all_languages + .edit_predictions + .as_ref()? + .codestral + .as_ref()? + .api_url + .as_ref() + }, + write: |settings, value| { + settings + .project + .all_languages + .edit_predictions + .get_or_insert_default() + .codestral + .get_or_insert_default() + .api_url = value; + }, + json_path: Some("edit_predictions.codestral.api_url"), + }), + metadata: Some(Box::new(SettingsFieldMetadata { + placeholder: Some(CODESTRAL_API_URL), + ..Default::default() + })), + files: USER, + }), + SettingsPageItem::SettingItem(SettingItem { + title: "Max Tokens", + description: "The maximum number of tokens to generate.", + field: Box::new(SettingField { + pick: |settings| { + settings + .project + .all_languages + .edit_predictions + .as_ref()? + .codestral + .as_ref()? + .max_tokens + .as_ref() + }, + write: |settings, value| { + settings + .project + .all_languages + .edit_predictions + .get_or_insert_default() + .codestral + .get_or_insert_default() + .max_tokens = value; + }, + json_path: Some("edit_predictions.codestral.max_tokens"), + }), + metadata: None, + files: USER, + }), + SettingsPageItem::SettingItem(SettingItem { + title: "Model", + description: "The Codestral model id to use.", + field: Box::new(SettingField { + pick: |settings| { + settings + .project + .all_languages + .edit_predictions + .as_ref()? + .codestral + .as_ref()? + .model + .as_ref() + }, + write: |settings, value| { + settings + .project + .all_languages + .edit_predictions + .get_or_insert_default() + .codestral + .get_or_insert_default() + .model = value; + }, + json_path: Some("edit_predictions.codestral.model"), + }), + metadata: Some(Box::new(SettingsFieldMetadata { + placeholder: Some("codestral-latest"), + ..Default::default() + })), + files: USER, + }), + ]) +} + +pub(crate) fn render_github_copilot_provider( + window: &mut Window, + cx: &mut App, +) -> impl IntoElement { + let configuration_view = window.use_state(cx, |_, cx| { + copilot::ConfigurationView::new( + |cx| { + copilot::Copilot::global(cx) + .is_some_and(|copilot| copilot.read(cx).is_authenticated()) + }, + copilot::ConfigurationMode::EditPrediction, + cx, + ) + }); + + v_flex() + .id("github-copilot") + .min_w_0() + .gap_1p5() + .child( + SettingsSectionHeader::new("GitHub Copilot") + .icon(IconName::Copilot) + .no_padding(true), + ) + .child(configuration_view) +} diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index 4464d3bdd951d4b7bf2511cfd718b0f297b8fc78..0ec6d0aee308ce3c20b67a5db9c6a6d9224bf229 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -1,9 +1,9 @@ mod components; mod page_data; +mod pages; use anyhow::Result; use editor::{Editor, EditorEvent}; -use feature_flags::FeatureFlag; use fuzzy::StringMatchCandidate; use gpui::{ Action, App, ClipboardItem, DEFAULT_ADDITIONAL_WINDOW_SIZE, Div, Entity, FocusHandle, @@ -28,9 +28,8 @@ use std::{ }; use title_bar::platform_title_bar::PlatformTitleBar; use ui::{ - Banner, ContextMenu, Divider, DividerColor, DropdownMenu, DropdownStyle, IconButtonShape, - KeyBinding, KeybindingHint, PopoverMenu, Switch, Tooltip, TreeViewItem, WithScrollbar, - prelude::*, + Banner, ContextMenu, Divider, DropdownMenu, DropdownStyle, IconButtonShape, KeyBinding, + KeybindingHint, PopoverMenu, Switch, Tooltip, TreeViewItem, WithScrollbar, prelude::*, }; use ui_input::{NumberField, NumberFieldType}; use util::{ResultExt as _, paths::PathStyle, rel_path::RelPath}; @@ -38,7 +37,8 @@ use workspace::{AppState, OpenOptions, OpenVisible, Workspace, client_side_decor use zed_actions::{OpenProjectSettings, OpenSettings, OpenSettingsAt}; use crate::components::{ - EnumVariantDropdown, SettingsInputField, font_picker, icon_theme_picker, theme_picker, + EnumVariantDropdown, SettingsInputField, SettingsSectionHeader, font_picker, icon_theme_picker, + theme_picker, }; const NAVBAR_CONTAINER_TAB_INDEX: isize = 0; @@ -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 { @@ -369,12 +369,6 @@ struct SettingsFieldMetadata { should_do_titlecase: Option, } -pub struct SettingsUiFeatureFlag; - -impl FeatureFlag for SettingsUiFeatureFlag { - const NAME: &'static str = "settings-ui"; -} - pub fn init(cx: &mut App) { init_renderers(cx); @@ -613,7 +607,10 @@ pub fn open_settings_editor( app_id: Some(app_id.to_owned()), window_decorations: Some(window_decorations), window_min_size: Some(gpui::Size { - width: px(360.0), + // Don't make the settings window thinner than this, + // otherwise, it gets unusable. Users with smaller res monitors + // can customize the height, but not the width. + width: px(900.0), height: px(240.0), }), window_bounds: Some(WindowBounds::centered(scaled_bounds, cx)), @@ -734,6 +731,7 @@ enum SettingsPageItem { SettingItem(SettingItem), SubPageLink(SubPageLink), DynamicItem(DynamicItem), + ActionLink(ActionLink), } impl std::fmt::Debug for SettingsPageItem { @@ -749,6 +747,9 @@ impl std::fmt::Debug for SettingsPageItem { SettingsPageItem::DynamicItem(dynamic_item) => { write!(f, "DynamicItem({})", dynamic_item.discriminant.title) } + SettingsPageItem::ActionLink(action_link) => { + write!(f, "ActionLink({})", action_link.title) + } } } } @@ -834,18 +835,9 @@ impl SettingsPageItem { }; match self { - SettingsPageItem::SectionHeader(header) => v_flex() - .w_full() - .px_8() - .gap_1p5() - .child( - Label::new(SharedString::new_static(header)) - .size(LabelSize::Small) - .color(Color::Muted) - .buffer_font(cx), - ) - .child(Divider::horizontal().color(DividerColor::BorderFaded)) - .into_any_element(), + SettingsPageItem::SectionHeader(header) => { + SettingsSectionHeader::new(SharedString::new_static(header)).into_any_element() + } SettingsPageItem::SettingItem(setting_item) => { let (field_with_padding, _) = render_setting_item_inner(setting_item, true, false, cx); @@ -869,9 +861,20 @@ impl SettingsPageItem { .map(apply_padding) .child( v_flex() + .relative() .w_full() .max_w_1_2() - .child(Label::new(sub_page_link.title.clone())), + .child(Label::new(sub_page_link.title.clone())) + .when_some( + sub_page_link.description.as_ref(), + |this, description| { + this.child( + Label::new(description.clone()) + .size(LabelSize::Small) + .color(Color::Muted), + ) + }, + ), ) .child( Button::new( @@ -887,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(); @@ -906,10 +909,16 @@ impl SettingsPageItem { ) }; - this.push_sub_page(sub_page_link.clone(), header, cx) + this.push_sub_page(sub_page_link.clone(), header, window, cx) }) }), - ), + ) + .child(render_settings_item_link( + sub_page_link.title.clone(), + sub_page_link.json_path, + false, + cx, + )), ) .when(!is_last, |this| this.child(Divider::horizontal())) .into_any_element(), @@ -968,6 +977,55 @@ impl SettingsPageItem { return content.into_any_element(); } + SettingsPageItem::ActionLink(action_link) => v_flex() + .group("setting-item") + .px_8() + .child( + h_flex() + .id(action_link.title.clone()) + .w_full() + .min_w_0() + .justify_between() + .map(apply_padding) + .child( + v_flex() + .relative() + .w_full() + .max_w_1_2() + .child(Label::new(action_link.title.clone())) + .when_some( + action_link.description.as_ref(), + |this, description| { + this.child( + Label::new(description.clone()) + .size(LabelSize::Small) + .color(Color::Muted), + ) + }, + ), + ) + .child( + Button::new( + ("action-link".into(), action_link.title.clone()), + action_link.button_text.clone(), + ) + .icon(IconName::ArrowUpRight) + .tab_index(0_isize) + .icon_position(IconPosition::End) + .icon_color(Color::Muted) + .icon_size(IconSize::Small) + .style(ButtonStyle::OutlinedGhost) + .size(ButtonSize::Medium) + .on_click({ + let on_click = action_link.on_click.clone(); + cx.listener(move |this, _, window, cx| { + on_click(this, window, cx); + }) + }), + ), + ) + .when(!is_last, |this| this.child(Divider::horizontal())) + .into_any_element(), } } } @@ -983,20 +1041,6 @@ fn render_settings_item( let (found_in_file, _) = setting_item.field.file_set_in(file.clone(), cx); let file_set_in = SettingsUiFile::from_settings(found_in_file.clone()); - let clipboard_has_link = cx - .read_from_clipboard() - .and_then(|entry| entry.text()) - .map_or(false, |maybe_url| { - setting_item.field.json_path().is_some() - && maybe_url.strip_prefix("zed://settings/") == setting_item.field.json_path() - }); - - let (link_icon, link_icon_color) = if clipboard_has_link { - (IconName::Check, Color::Success) - } else { - (IconName::Link, Color::Muted) - }; - h_flex() .id(setting_item.title) .min_w_0() @@ -1056,40 +1100,60 @@ fn render_settings_item( ) .child(control) .when(sub_page_stack().is_empty(), |this| { - // Intentionally using the description to make the icon button - // unique because some items share the same title (e.g., "Font Size") - let icon_button_id = - SharedString::new(format!("copy-link-btn-{}", setting_item.description)); + this.child(render_settings_item_link( + setting_item.description, + setting_item.field.json_path(), + sub_field, + cx, + )) + }) +} - this.child( - div() - .absolute() - .top(rems_from_px(18.)) - .map(|this| { - if sub_field { - this.visible_on_hover("setting-sub-item") - .left(rems_from_px(-8.5)) - } else { - this.visible_on_hover("setting-item") - .left(rems_from_px(-22.)) - } - }) - .child({ - IconButton::new(icon_button_id, link_icon) - .icon_color(link_icon_color) - .icon_size(IconSize::Small) - .shape(IconButtonShape::Square) - .tooltip(Tooltip::text("Copy Link")) - .when_some(setting_item.field.json_path(), |this, path| { - this.on_click(cx.listener(move |_, _, _, cx| { - let link = format!("zed://settings/{}", path); - cx.write_to_clipboard(ClipboardItem::new_string(link)); - cx.notify(); - })) - }) - }), - ) +fn render_settings_item_link( + id: impl Into, + json_path: Option<&'static str>, + sub_field: bool, + cx: &mut Context<'_, SettingsWindow>, +) -> impl IntoElement { + let clipboard_has_link = cx + .read_from_clipboard() + .and_then(|entry| entry.text()) + .map_or(false, |maybe_url| { + json_path.is_some() && maybe_url.strip_prefix("zed://settings/") == json_path + }); + + let (link_icon, link_icon_color) = if clipboard_has_link { + (IconName::Check, Color::Success) + } else { + (IconName::Link, Color::Muted) + }; + + div() + .absolute() + .top(rems_from_px(18.)) + .map(|this| { + if sub_field { + this.visible_on_hover("setting-sub-item") + .left(rems_from_px(-8.5)) + } else { + this.visible_on_hover("setting-item") + .left(rems_from_px(-22.)) + } }) + .child( + IconButton::new((id.into(), "copy-link-btn"), link_icon) + .icon_color(link_icon_color) + .icon_size(IconSize::Small) + .shape(IconButtonShape::Square) + .tooltip(Tooltip::text("Copy Link")) + .when_some(json_path, |this, path| { + this.on_click(cx.listener(move |_, _, _, cx| { + let link = format!("zed://settings/{}", path); + cx.write_to_clipboard(ClipboardItem::new_string(link)); + cx.notify(); + })) + }), + ) } struct SettingItem { @@ -1175,6 +1239,12 @@ impl PartialEq for SettingItem { #[derive(Clone)] struct SubPageLink { title: SharedString, + description: Option, + /// See [`SettingField.json_path`] + json_path: Option<&'static str>, + /// Whether or not the settings in this sub page are configurable in settings.json + /// Removes the "Edit in settings.json" button from the page. + in_json: bool, files: FileMask, render: Arc< dyn Fn(&mut SettingsWindow, &mut Window, &mut Context) -> AnyElement @@ -1190,6 +1260,20 @@ impl PartialEq for SubPageLink { } } +#[derive(Clone)] +struct ActionLink { + title: SharedString, + description: Option, + button_text: SharedString, + on_click: Arc, +} + +impl PartialEq for ActionLink { + fn eq(&self, other: &Self) -> bool { + self.title == other.title + } +} + fn all_language_names(cx: &App) -> Vec { workspace::AppState::global(cx) .upgrade() @@ -1453,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 @@ -1609,6 +1693,9 @@ impl SettingsWindow { any_found_since_last_header = true; } } + SettingsPageItem::ActionLink(_) => { + any_found_since_last_header = true; + } } } if let Some(last_header) = page_filter.get_mut(header_index) @@ -1835,6 +1922,7 @@ impl SettingsWindow { header_str = *header; } SettingsPageItem::SubPageLink(sub_page_link) => { + json_path = sub_page_link.json_path; documents.push(bm25::Document { id: key_index, contents: [page.title, header_str, sub_page_link.title.as_ref()] @@ -1846,6 +1934,18 @@ impl SettingsWindow { sub_page_link.title.as_ref(), ); } + SettingsPageItem::ActionLink(action_link) => { + documents.push(bm25::Document { + id: key_index, + contents: [page.title, header_str, action_link.title.as_ref()] + .join("\n"), + }); + push_candidates( + &mut fuzzy_match_candidates, + key_index, + action_link.title.as_ref(), + ); + } } push_candidates(&mut fuzzy_match_candidates, key_index, page.title); push_candidates(&mut fuzzy_match_candidates, key_index, header_str); @@ -2074,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); } })) }; @@ -2151,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); } }, ); @@ -2285,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(); })) @@ -2434,6 +2534,7 @@ impl SettingsWindow { window.focus( &this.navbar_entries[entry_index] .focus_handle, + cx, ); cx.notify(); }, @@ -2558,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| { @@ -2625,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(); } @@ -2758,19 +2859,49 @@ impl SettingsWindow { page_content } - fn render_sub_page_items<'a, Items: Iterator>( + fn render_sub_page_items<'a, Items>( &self, items: Items, page_index: Option, window: &mut Window, cx: &mut Context, - ) -> impl IntoElement { - let mut page_content = v_flex() + ) -> impl IntoElement + where + Items: Iterator, + { + let page_content = v_flex() .id("settings-ui-page") .size_full() .overflow_y_scroll() .track_scroll(&self.sub_page_scroll_handle); + self.render_sub_page_items_in(page_content, items, page_index, window, cx) + } + + fn render_sub_page_items_section<'a, Items>( + &self, + items: Items, + page_index: Option, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement + where + Items: Iterator, + { + let page_content = v_flex().id("settings-ui-sub-page-section").size_full(); + self.render_sub_page_items_in(page_content, items, page_index, window, cx) + } + fn render_sub_page_items_in<'a, Items>( + &self, + mut page_content: Stateful
, + items: Items, + page_index: Option, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement + where + Items: Iterator, + { let items: Vec<_> = items.collect(); let items_len = items.len(); let mut section_header = None; @@ -2865,24 +2996,31 @@ 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()), ) - .child( - Button::new("open-in-settings-file", "Edit in settings.json") - .tab_index(0_isize) - .style(ButtonStyle::OutlinedGhost) - .tooltip(Tooltip::for_action_title_in( - "Edit in settings.json", - &OpenCurrentFile, - &self.focus_handle, - )) - .on_click(cx.listener(|this, _, window, cx| { - this.open_current_settings_file(window, cx); - })), + .when( + sub_page_stack() + .last() + .is_none_or(|sub_page| sub_page.link.in_json), + |this| { + this.child( + Button::new("open-in-settings-file", "Edit in settings.json") + .tab_index(0_isize) + .style(ButtonStyle::OutlinedGhost) + .tooltip(Tooltip::for_action_title_in( + "Edit in settings.json", + &OpenCurrentFile, + &self.focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| { + this.open_current_settings_file(window, cx); + })), + ) + }, ) .into_any_element(); @@ -2963,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() { @@ -2983,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(); }); }); @@ -2991,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; @@ -3015,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(); }); }); @@ -3024,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) @@ -3218,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); } } @@ -3314,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 @@ -3334,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| { @@ -3343,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 @@ -3357,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/sum_tree/src/sum_tree.rs b/crates/sum_tree/src/sum_tree.rs index bfc4587969ec67bbda2fb90d34550c7d464317c9..6a76b73c3bbfb922e1b46fc1e228209ddf05b4a5 100644 --- a/crates/sum_tree/src/sum_tree.rs +++ b/crates/sum_tree/src/sum_tree.rs @@ -250,11 +250,11 @@ impl SumTree { ::add_summary(&mut summary, item_summary, cx); } - nodes.push(Node::Leaf { + nodes.push(SumTree(Arc::new(Node::Leaf { summary, items, item_summaries, - }); + }))); } let mut parent_nodes = Vec::new(); @@ -263,25 +263,27 @@ impl SumTree { height += 1; let mut current_parent_node = None; for child_node in nodes.drain(..) { - let parent_node = current_parent_node.get_or_insert_with(|| Node::Internal { - summary: ::zero(cx), - height, - child_summaries: ArrayVec::new(), - child_trees: ArrayVec::new(), + let parent_node = current_parent_node.get_or_insert_with(|| { + SumTree(Arc::new(Node::Internal { + summary: ::zero(cx), + height, + child_summaries: ArrayVec::new(), + child_trees: ArrayVec::new(), + })) }); let Node::Internal { summary, child_summaries, child_trees, .. - } = parent_node + } = Arc::get_mut(&mut parent_node.0).unwrap() else { unreachable!() }; let child_summary = child_node.summary(); ::add_summary(summary, child_summary, cx); child_summaries.push(child_summary.clone()); - child_trees.push(Self(Arc::new(child_node))); + child_trees.push(child_node); if child_trees.len() == 2 * TREE_BASE { parent_nodes.extend(current_parent_node.take()); @@ -295,7 +297,7 @@ impl SumTree { Self::new(cx) } else { debug_assert_eq!(nodes.len(), 1); - Self(Arc::new(nodes.pop().unwrap())) + nodes.pop().unwrap() } } diff --git a/crates/supermaven/src/supermaven_edit_prediction_delegate.rs b/crates/supermaven/src/supermaven_edit_prediction_delegate.rs index 578bc894f223fd458f510694194aebe633d7a6db..9563a0aa99f1760b5af214be28f25dbf1734c371 100644 --- a/crates/supermaven/src/supermaven_edit_prediction_delegate.rs +++ b/crates/supermaven/src/supermaven_edit_prediction_delegate.rs @@ -1,6 +1,6 @@ use crate::{Supermaven, SupermavenCompletionStateId}; use anyhow::Result; -use edit_prediction_types::{Direction, EditPrediction, EditPredictionDelegate}; +use edit_prediction_types::{EditPrediction, EditPredictionDelegate}; use futures::StreamExt as _; use gpui::{App, Context, Entity, EntityId, Task}; use language::{Anchor, Buffer, BufferSnapshot}; @@ -189,15 +189,6 @@ impl EditPredictionDelegate for SupermavenEditPredictionDelegate { })); } - fn cycle( - &mut self, - _buffer: Entity, - _cursor_position: Anchor, - _direction: Direction, - _cx: &mut Context, - ) { - } - fn accept(&mut self, _cx: &mut Context) { reset_completion_cache(self, _cx); } diff --git a/crates/tab_switcher/src/tab_switcher.rs b/crates/tab_switcher/src/tab_switcher.rs index 85186ad504eb098264aae64ba3c2354d20d011a4..85bb5fbba6ad49f556ecca9a4863972adb8666ce 100644 --- a/crates/tab_switcher/src/tab_switcher.rs +++ b/crates/tab_switcher/src/tab_switcher.rs @@ -529,7 +529,9 @@ impl TabSwitcherDelegate { } if self.select_last { - return self.matches.len() - 1; + let item_index = self.matches.len() - 1; + self.set_selected_index(item_index, window, cx); + return item_index; } // This only runs when initially opening the picker diff --git a/crates/terminal/Cargo.toml b/crates/terminal/Cargo.toml index dac9db190dbd0864142a1d429b69db17b4ae25e9..9b4302d02fc0a101cc609274b0abc42105402174 100644 --- a/crates/terminal/Cargo.toml +++ b/crates/terminal/Cargo.toml @@ -28,6 +28,7 @@ gpui.workspace = true itertools.workspace = true libc.workspace = true log.workspace = true +regex.workspace = true release_channel.workspace = true schemars.workspace = true serde.workspace = true @@ -37,8 +38,8 @@ smol.workspace = true task.workspace = true theme.workspace = true thiserror.workspace = true +url.workspace = true util.workspace = true -fancy-regex.workspace = true urlencoding.workspace = true [target.'cfg(windows)'.dependencies] @@ -49,5 +50,4 @@ gpui = { workspace = true, features = ["test-support"] } rand.workspace = true serde_json.workspace = true settings = { workspace = true, features = ["test-support"] } -url.workspace = true util_macros.workspace = true diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index caca93eac5b862450cdaa2aede0fd5491eaaf58f..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 @@ -369,6 +369,7 @@ impl TerminalBuilder { last_content: Default::default(), last_mouse: None, matches: Vec::new(), + selection_head: None, breadcrumb_text: String::new(), scroll_px: px(0.), @@ -379,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(), @@ -420,6 +422,10 @@ impl TerminalBuilder { ) -> Task> { let version = release_channel::AppVersion::global(cx); let fut = async move { + // Remove SHLVL so the spawned shell initializes it to 1, matching + // the behavior of standalone terminal emulators like iTerm2/Kitty/Alacritty. + env.remove("SHLVL"); + // If the parent environment doesn't have a locale set // (As is the case when launched from a .app on MacOS), // and the Project doesn't have a locale set, then @@ -591,6 +597,7 @@ impl TerminalBuilder { last_content: Default::default(), last_mouse: None, matches: Vec::new(), + selection_head: None, breadcrumb_text: String::new(), scroll_px: px(0.), @@ -604,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(), @@ -822,6 +830,7 @@ pub struct Terminal { pub matches: Vec>, pub last_content: TerminalContent, pub selection_head: Option, + pub breadcrumb_text: String, title_override: Option, scroll_px: Pixels, @@ -833,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, @@ -885,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 { @@ -935,7 +947,7 @@ impl Terminal { AlacTermEvent::Bell => { cx.emit(Event::Bell); } - AlacTermEvent::Exit => self.register_task_finished(None, cx), + AlacTermEvent::Exit => self.register_task_finished(Some(9), cx), AlacTermEvent::MouseCursorDirty => { //NOOP, Handled in render } @@ -1143,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, @@ -1157,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); } } @@ -1711,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, + )); } } @@ -1766,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 @@ -1812,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) @@ -1882,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 = @@ -1934,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( @@ -2398,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(); @@ -2851,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/src/terminal_hyperlinks.rs b/crates/terminal/src/terminal_hyperlinks.rs index 0d108ade3f9a5916aef21092bba98239b76e0131..4fe2baa2dc27f3a589efd9b7739262a6fec3fcb4 100644 --- a/crates/terminal/src/terminal_hyperlinks.rs +++ b/crates/terminal/src/terminal_hyperlinks.rs @@ -8,12 +8,14 @@ use alacritty_terminal::{ search::{Match, RegexIter, RegexSearch}, }, }; -use fancy_regex::Regex; use log::{info, warn}; +use regex::Regex; use std::{ + iter::{once, once_with}, ops::{Index, Range}, time::{Duration, Instant}, }; +use url::Url; const URL_REGEX: &str = r#"(ipfs:|ipns:|magnet:|mailto:|gemini://|gopher://|https://|http://|news:|file://|git://|ssh:|ftp://)[^\u{0000}-\u{001F}\u{007F}-\u{009F}<>"\s{-}\^⟨⟩`']+"#; const WIDE_CHAR_SPACERS: Flags = @@ -128,8 +130,19 @@ pub(super) fn find_from_grid_point( if is_url { // Treat "file://" IRIs like file paths to ensure // that line numbers at the end of the path are - // handled correctly - if let Some(path) = maybe_url_or_path.strip_prefix("file://") { + // handled correctly. + // Use Url::to_file_path() to properly handle Windows drive letters + // (e.g., file:///C:/path -> C:\path) + if maybe_url_or_path.starts_with("file://") { + if let Ok(url) = Url::parse(&maybe_url_or_path) { + if let Ok(path) = url.to_file_path() { + return (path.to_string_lossy().into_owned(), false, word_match); + } + } + // Fallback: strip file:// prefix if URL parsing fails + let path = maybe_url_or_path + .strip_prefix("file://") + .unwrap_or(&maybe_url_or_path); (path.to_string(), false, word_match) } else { (maybe_url_or_path, true, word_match) @@ -148,8 +161,8 @@ fn sanitize_url_punctuation( let mut sanitized_url = url; let mut chars_trimmed = 0; - // First, handle parentheses balancing using single traversal - let (open_parens, close_parens) = + // Count parentheses in the URL + let (open_parens, mut close_parens) = sanitized_url .chars() .fold((0, 0), |(opens, closes), c| match c { @@ -158,33 +171,27 @@ fn sanitize_url_punctuation( _ => (opens, closes), }); - // Trim unbalanced closing parentheses - if close_parens > open_parens { - let mut remaining_close = close_parens; - while sanitized_url.ends_with(')') && remaining_close > open_parens { - sanitized_url.pop(); - chars_trimmed += 1; - remaining_close -= 1; - } - } + // Remove trailing characters that shouldn't be at the end of URLs + while let Some(last_char) = sanitized_url.chars().last() { + let should_remove = match last_char { + // These may be part of a URL but not at the end. It's not that the spec + // doesn't allow them, but they are frequently used in plain text as delimiters + // where they're not meant to be part of the URL. + '.' | ',' | ':' | ';' => true, + '(' => true, + ')' if close_parens > open_parens => { + close_parens -= 1; + + true + } + _ => false, + }; - // Handle trailing periods - if sanitized_url.ends_with('.') { - let trailing_periods = sanitized_url - .chars() - .rev() - .take_while(|&c| c == '.') - .count(); - - if trailing_periods > 1 { - sanitized_url.truncate(sanitized_url.len() - trailing_periods); - chars_trimmed += trailing_periods; - } else if trailing_periods == 1 - && let Some(second_last_char) = sanitized_url.chars().rev().nth(1) - && (second_last_char.is_alphanumeric() || second_last_char == '/') - { + if should_remove { sanitized_url.pop(); chars_trimmed += 1; + } else { + break; } } @@ -226,14 +233,17 @@ fn path_match( (line_end.line.0 - line_start.line.0 + 1) as usize * term.grid().columns(), ); let first_cell = &term.grid()[line_start]; + let mut prev_len = 0; line.push(first_cell.c); - let mut start_offset = 0; + let mut prev_char_is_space = first_cell.c == ' '; let mut hovered_point_byte_offset = None; + let mut hovered_word_start_offset = None; + let mut hovered_word_end_offset = None; - if !first_cell.flags.intersects(WIDE_CHAR_SPACERS) { - start_offset += first_cell.c.len_utf8(); - if line_start == hovered { - hovered_point_byte_offset = Some(0); + if line_start == hovered { + hovered_point_byte_offset = Some(0); + if first_cell.c != ' ' { + hovered_word_start_offset = Some(0); } } @@ -241,27 +251,44 @@ fn path_match( if cell.point > line_end { break; } - let is_spacer = cell.flags.intersects(WIDE_CHAR_SPACERS); - if cell.point == hovered { - debug_assert!(hovered_point_byte_offset.is_none()); - if start_offset > 0 && cell.flags.contains(Flags::WIDE_CHAR_SPACER) { - // If we hovered on a trailing spacer, back up to the end of the previous char's bytes. - start_offset -= 1; + + if !cell.flags.intersects(WIDE_CHAR_SPACERS) { + prev_len = line.len(); + match cell.c { + ' ' | '\t' => { + if hovered_point_byte_offset.is_some() && !prev_char_is_space { + if hovered_word_end_offset.is_none() { + hovered_word_end_offset = Some(line.len()); + } + } + line.push(' '); + prev_char_is_space = true; + } + c @ _ => { + if hovered_point_byte_offset.is_none() && prev_char_is_space { + hovered_word_start_offset = Some(line.len()); + } + line.push(c); + prev_char_is_space = false; + } } - hovered_point_byte_offset = Some(start_offset); - } else if cell.point < hovered && !is_spacer { - start_offset += cell.c.len_utf8(); } - if !is_spacer { - line.push(match cell.c { - '\t' => ' ', - c @ _ => c, - }); + if cell.point == hovered { + debug_assert!(hovered_point_byte_offset.is_none()); + hovered_point_byte_offset = Some(prev_len); } } let line = line.trim_ascii_end(); let hovered_point_byte_offset = hovered_point_byte_offset?; + let hovered_word_range = { + let word_start_offset = hovered_word_start_offset.unwrap_or(0); + (word_start_offset != 0) + .then_some(word_start_offset..hovered_word_end_offset.unwrap_or(line.len())) + }; + if line.len() <= hovered_point_byte_offset { + return None; + } let found_from_range = |path_range: Range, link_range: Range, position: Option<(u32, Option)>| { @@ -307,21 +334,27 @@ fn path_match( for regex in path_hyperlink_regexes { let mut path_found = false; - for captures in regex.captures_iter(&line) { - let captures = match captures { - Ok(captures) => captures, - Err(error) => { - warn!("Error '{error}' searching for path hyperlinks in line: {line}"); - info!( - "Skipping match from path hyperlinks with regex: {}", - regex.as_str() - ); - continue; - } - }; + for (line_start_offset, captures) in once( + regex + .captures_iter(&line) + .next() + .map(|captures| (0, captures)), + ) + .chain(once_with(|| { + if let Some(hovered_word_range) = &hovered_word_range { + regex + .captures_iter(&line[hovered_word_range.clone()]) + .next() + .map(|captures| (hovered_word_range.start, captures)) + } else { + None + } + })) + .flatten() + { path_found = true; let match_range = captures.get(0).unwrap().range(); - let (path_range, line_column) = if let Some(path) = captures.name("path") { + let (mut path_range, line_column) = if let Some(path) = captures.name("path") { let parse = |name: &str| { captures .name(name) @@ -335,10 +368,15 @@ fn path_match( } else { (match_range.clone(), None) }; - let link_range = captures + let mut link_range = captures .name("link") .map_or_else(|| match_range.clone(), |link| link.range()); + path_range.start += line_start_offset; + path_range.end += line_start_offset; + link_range.start += line_start_offset; + link_range.end += line_start_offset; + if !link_range.contains(&hovered_point_byte_offset) { // No match, just skip. continue; @@ -376,7 +414,7 @@ mod tests { term::{Config, cell::Flags, test::TermSize}, vte::ansi::Handler, }; - use fancy_regex::Regex; + use regex::Regex; use settings::{self, Settings, SettingsContent}; use std::{cell::RefCell, ops::RangeInclusive, path::PathBuf, rc::Rc}; use url::Url; @@ -386,7 +424,7 @@ mod tests { let results: Vec<_> = Regex::new(re) .unwrap() .find_iter(hay) - .map(|m| m.unwrap().as_str()) + .map(|m| m.as_str()) .collect(); assert_eq!(results, expected); } @@ -412,6 +450,8 @@ mod tests { ("https://www.google.com/)", "https://www.google.com/"), ("https://example.com/path)", "https://example.com/path"), ("https://test.com/))", "https://test.com/"), + ("https://test.com/(((", "https://test.com/"), + ("https://test.com/(test)(", "https://test.com/(test)"), // Cases that should NOT be sanitized (balanced parentheses) ( "https://en.wikipedia.org/wiki/Example_(disambiguation)", @@ -442,10 +482,10 @@ mod tests { } #[test] - fn test_url_periods_sanitization() { - // Test URLs with trailing periods (sentence punctuation) + fn test_url_punctuation_sanitization() { + // Test URLs with trailing punctuation (sentence/text punctuation) + // The sanitize_url_punctuation function removes ., ,, :, ;, from the end let test_cases = vec![ - // Cases that should be sanitized (trailing periods likely punctuation) ("https://example.com.", "https://example.com"), ( "https://github.com/zed-industries/zed.", @@ -465,13 +505,36 @@ mod tests { "https://en.wikipedia.org/wiki/C.E.O.", "https://en.wikipedia.org/wiki/C.E.O", ), - // Cases that should NOT be sanitized (periods are part of URL structure) + ("https://example.com,", "https://example.com"), + ("https://example.com/path,", "https://example.com/path"), + ("https://example.com,,", "https://example.com"), + ("https://example.com:", "https://example.com"), + ("https://example.com/path:", "https://example.com/path"), + ("https://example.com::", "https://example.com"), + ("https://example.com;", "https://example.com"), + ("https://example.com/path;", "https://example.com/path"), + ("https://example.com;;", "https://example.com"), + ("https://example.com.,", "https://example.com"), + ("https://example.com.:;", "https://example.com"), + ("https://example.com!.", "https://example.com!"), + ("https://example.com/).", "https://example.com/"), + ("https://example.com/);", "https://example.com/"), + ("https://example.com/;)", "https://example.com/"), ( "https://example.com/v1.0/api", "https://example.com/v1.0/api", ), ("https://192.168.1.1", "https://192.168.1.1"), ("https://sub.domain.com", "https://sub.domain.com"), + ( + "https://example.com?query=value", + "https://example.com?query=value", + ), + ("https://example.com?a=1&b=2", "https://example.com?a=1&b=2"), + ( + "https://example.com/path:8080", + "https://example.com/path:8080", + ), ]; for (input, expected) in test_cases { @@ -483,7 +546,6 @@ mod tests { let end_point = AlacPoint::new(Line(0), Column(input.len())); let dummy_match = Match::new(start_point, end_point); - // This test should initially fail since we haven't implemented period sanitization yet let (result, _) = sanitize_url_punctuation(input.to_string(), dummy_match, &term); assert_eq!(result, expected, "Failed for input: {}", input); } @@ -578,8 +640,6 @@ mod tests { test_path!("/test/cool.rs(4,2)👉:", "What is this?"); // path, line, column, and description - test_path!("/test/cool.rs:4:2👉:Error!"); - test_path!("/test/cool.rs:4:2:👉Error!"); test_path!("‹«/test/co👉ol.rs»:«4»:«2»›:Error!"); test_path!("‹«/test/co👉ol.rs»(«4»,«2»)›:Error!"); @@ -590,6 +650,7 @@ mod tests { // Python test_path!("‹«awe👉some.py»›"); + test_path!("‹«👉a»› "); test_path!(" ‹F👉ile \"«/awesome.py»\", line «42»›: Wat?"); test_path!(" ‹File \"«/awe👉some.py»\", line «42»›"); @@ -602,18 +663,14 @@ mod tests { // path, line, column and description test_path!("‹«/👉test/cool.rs»:«4»:«2»›:例Desc例例例"); test_path!("‹«/test/cool.rs»:«4»:«👉2»›:例Desc例例例"); - test_path!("/test/cool.rs:4:2:例Desc例👉例例"); test_path!("‹«/👉test/cool.rs»(«4»,«2»)›:例Desc例例例"); test_path!("‹«/test/cool.rs»(«4»👉,«2»)›:例Desc例例例"); - test_path!("/test/cool.rs(4,2):例Desc例👉例例"); // path, line, column and description w/extra colons test_path!("‹«/👉test/cool.rs»:«4»:«2»›::例Desc例例例"); test_path!("‹«/test/cool.rs»:«4»:«👉2»›::例Desc例例例"); - test_path!("/test/cool.rs:4:2::例Desc例👉例例"); test_path!("‹«/👉test/cool.rs»(«4»,«2»)›::例Desc例例例"); test_path!("‹«/test/cool.rs»(«4»,«2»👉)›::例Desc例例例"); - test_path!("/test/cool.rs(4,2)::例Desc例👉例例"); } #[test] @@ -624,9 +681,6 @@ mod tests { test_path!( "‹«🦀 multiple_👉same_line 🦀» 🚣«4» 🏛️«2»›: 🦀 multiple_same_line 🦀 🚣4 🏛️2:" ); - test_path!( - "🦀 multiple_same_line 🦀 🚣4 🏛️2 ‹«🦀 multiple_👉same_line 🦀» 🚣«4» 🏛️«2»›:" - ); // ls output (tab separated) test_path!( @@ -658,8 +712,6 @@ mod tests { test_path!("‹«/test/co👉ol.rs»(«1»,«618»)›:"); test_path!("‹«/test/co👉ol.rs»::«42»›"); test_path!("‹«/test/co👉ol.rs»::«42»›:"); - test_path!("‹«/test/co👉ol.rs:4:2»(«1»,«618»)›"); - test_path!("‹«/test/co👉ol.rs:4:2»(«1»,«618»)›:"); test_path!("‹«/test/co👉ol.rs»(«1»,«618»)›::"); } @@ -675,7 +727,7 @@ mod tests { test_path!("<‹«/test/co👉ol.rs»:«4»›>"); test_path!("[\"‹«/test/co👉ol.rs»:«4»›\"]"); - test_path!("'‹«(/test/co👉ol.rs:4)»›'"); + test_path!("'(‹«/test/co👉ol.rs»:«4»›)'"); test_path!("\"‹«/test/co👉ol.rs»:«4»:«2»›\""); test_path!("'‹«/test/co👉ol.rs»:«4»:«2»›'"); @@ -724,7 +776,7 @@ mod tests { test_path!("‹«/test/co👉ol.rs»:«4»›:,"); test_path!("/test/cool.rs:4:👉,"); test_path!("[\"‹«/test/co👉ol.rs»:«4»›\"]:,"); - test_path!("'‹«(/test/co👉ol.rs:4),,»›'.."); + test_path!("'(‹«/test/co👉ol.rs»:«4»›),,'..."); test_path!("('‹«/test/co👉ol.rs»:«4»›'::: was here...)"); test_path!("[Here's <‹«/test/co👉ol.rs»:«4»›>]::: "); } @@ -849,9 +901,6 @@ mod tests { test_path!( "‹«test/c👉ontrollers/template_items_controller_test.rb»:«20»›:in 'block (2 levels) in '" ); - test_path!( - "test/controllers/template_items_controller_test.rb:19:i👉n 'block in '" - ); } #[test] @@ -968,7 +1017,7 @@ mod tests { use crate::TerminalSettings; use alacritty_terminal::{ event::VoidListener, - grid::Dimensions, + grid::Scroll, index::{Column, Point as AlacPoint}, term::test::mock_term, term::{Term, search::Match}, @@ -977,14 +1026,20 @@ mod tests { use std::{cell::RefCell, rc::Rc}; use util_macros::perf; - fn build_test_term(line: &str) -> (Term, AlacPoint) { - let content = line.repeat(500); - let term = mock_term(&content); - let point = AlacPoint::new( - term.grid().bottommost_line() - 1, - Column(term.grid().last_column().0 / 2), - ); - + fn build_test_term( + line: &str, + repeat: usize, + hover_offset_column: usize, + ) -> (Term, AlacPoint) { + let content = line.repeat(repeat); + let mut term = mock_term(&content); + term.resize(TermSize { + columns: 1024, + screen_lines: 10, + }); + term.scroll_display(Scroll::Top); + let point = + AlacPoint::new(Line(term.topmost_line().0 + 3), Column(hover_offset_column)); (term, point) } @@ -993,11 +1048,14 @@ mod tests { const LINE: &str = " Compiling terminal v0.1.0 (/Hyperlinks/Bench/Source/zed-hyperlinks/crates/terminal)\r\n"; thread_local! { static TEST_TERM_AND_POINT: (Term, AlacPoint) = - build_test_term(LINE); + build_test_term(LINE, 500, 50); } TEST_TERM_AND_POINT.with(|(term, point)| { - assert!( - find_from_grid_point_bench(term, *point).is_some(), + assert_eq!( + find_from_grid_point_bench(term, *point) + .map(|(path, ..)| path) + .unwrap_or_default(), + "/Hyperlinks/Bench/Source/zed-hyperlinks/crates/terminal", "Hyperlink should have been found" ); }); @@ -1008,11 +1066,14 @@ mod tests { const LINE: &str = " --> /Hyperlinks/Bench/Source/zed-hyperlinks/crates/terminal/terminal.rs:1000:42\r\n"; thread_local! { static TEST_TERM_AND_POINT: (Term, AlacPoint) = - build_test_term(LINE); + build_test_term(LINE, 500, 50); } TEST_TERM_AND_POINT.with(|(term, point)| { - assert!( - find_from_grid_point_bench(term, *point).is_some(), + assert_eq!( + find_from_grid_point_bench(term, *point) + .map(|(path, ..)| path) + .unwrap_or_default(), + "/Hyperlinks/Bench/Source/zed-hyperlinks/crates/terminal/terminal.rs:1000:42", "Hyperlink should have been found" ); }); @@ -1023,11 +1084,111 @@ mod tests { const LINE: &str = "Cargo.toml experiments notebooks rust-toolchain.toml tooling\r\n"; thread_local! { static TEST_TERM_AND_POINT: (Term, AlacPoint) = - build_test_term(LINE); + build_test_term(LINE, 500, 60); } TEST_TERM_AND_POINT.with(|(term, point)| { - assert!( - find_from_grid_point_bench(term, *point).is_some(), + assert_eq!( + find_from_grid_point_bench(term, *point) + .map(|(path, ..)| path) + .unwrap_or_default(), + "rust-toolchain.toml", + "Hyperlink should have been found" + ); + }); + } + + #[perf] + // https://github.com/zed-industries/zed/pull/44407 + pub fn pr_44407_hyperlink_benchmark() { + const LINE: &str = "-748, 706, 163, 222, -980, 949, 381, -568, 199, 501, 760, -821, 90, -451, 183, 867, -351, -810, -762, -109, 423, 84, 14, -77, -820, -345, 74, -791, 930, -618, -900, 862, -959, 289, -19, 471, -757, 793, 155, -554, 249, 830, 402, 732, -731, -866, -720, -703, -257, -439, 731, 872, -489, 676, -167, 613, -698, 415, -80, -453, -896, 333, -511, 621, -450, 624, -309, -575, 177, 141, 891, -104, -97, -367, -599, -675, 607, -225, -760, 552, -465, 804, 55, 282, 104, -929, -252,\ +-311, 900, 550, 599, -80, 774, 553, 837, -395, 541, 953, 154, -396, -596, -111, -802, -221, -337, -633, -73, -527, -82, -658, -264, 222, 375, 434, 204, -756, -703, 303, 239, -257, -365, -351, 904, 364, -743, -484, 655, -542, 446, 888, 632, -167, -260, 716, 150, 806, 723, 513, -118, -323, -683, 983, -564, 358, -16, -287, 277, -607, 87, 365, -1, 164, 401, 257, 369, -893, 145, -969, 375, -53, 541, -408, -865, 753, 258, 337, -886, 593, -378, -528, 191, 204, 566, -61, -621, 769, 524, -628, 6,\ +249, 896, -785, -776, 321, -681, 604, -740, 886, 426, -480, -983, 23, -247, 125, -666, 913, 842, -460, -797, -483, -58, -565, -587, -206, 197, 715, 764, -97, 457, -149, -226, 261, 194, -390, 431, 180, -778, 829, -657, -668, 397, 859, 152, -178, 677, -18, 687, -247, 96, 466, -572, 478, 622, -143, -25, -471, 265, 335, 957, 152, -951, -647, 670, 57, 152, -115, 206, 87, 629, -798, -125, -725, -31, 844, 398, -876, 44, 963, -211, 518, -8, -103, -999, 948, 823, 149, -803, 769, -236, -683, 527,\ +-108, -36, 18, -437, 687, -305, -526, 972, -965, 276, 420, -259, -379, -142, -747, 600, -578, 197, 673, 890, 324, -931, 755, -765, -422, 785, -369, -110, -505, 532, -208, -438, 713, 110, 853, 996, -360, 823, 289, -699, 629, -661, 560, -329, -323, 439, 571, -537, 644, -84, 25, -536, -161, 112, 169, -922, -537, -734, -423, 37, 451, -149, 408, 18, -672, 206, -784, 444, 593, -241, 502, -259, -798, -352, -658, 712, -675, -734, 627, -620, 64, -554, 999, -537, -160, -641, 464, 894, 29, 322, 566,\ +-510, -749, 982, 204, 967, -261, -986, -136, 251, -598, 995, -831, 891, 22, 761, -783, -415, 125, 470, -919, -97, -668, 85, 205, -175, -550, 502, 652, -468, 798, 775, -216, 89, -433, -24, -621, 877, -126, 951, 809, 782, 156, -618, -841, -463, 19, -723, -904, 550, 263, 991, -758, -114, 446, -731, -623, -634, 462, 48, 851, 333, -846, 480, 892, -966, -910, -436, 317, -711, -341, -294, 124, 238, -214, -281, 467, -950, -342, 913, -90, -388, -573, 740, -883, -451, 493, -500, 863, 930, 127, 530,\ +-810, 540, 541, -664, -951, -227, -420, -476, -581, -534, 549, 253, 984, -985, -84, -521, 538, 484, -440, 371, 784, -306, -850, 530, -133, 251, -799, 446, -170, -243, -674, 769, 646, 778, -680, -714, -442, 804, 901, -774, 69, 307, -293, 755, 443, 224, -918, -771, 723, 40, 132, 568, -847, -47, 844, 69, 986, -293, -459, 313, 155, 331, 69, 280, -637, 569, 104, -119, -988, 252, 857, -590, 810, -891, 484, 566, -934, -587, -290, 566, 587, 489, 870, 280, 454, -252, 613, -701, -278, 195, -198,\ +683, 533, -372, 707, -152, 371, 866, 609, -5, -372, -30, -694, 552, 192, 452, -663, 350, -985, 10, 884, 813, -592, -331, -470, 711, -941, 928, 379, -339, 220, 999, 376, 507, 179, 916, 84, 104, 392, 192, 299, -860, 218, -698, -919, -452, 37, 850, 5, -874, 287, 123, -746, -575, 776, -909, 118, 903, -275, 450, -996, -591, -920, -850, 453, -896, 73, 83, -535, -20, 287, -765, 442, 808, 45, 445, 202, 917, -208, 783, 790, -534, 373, -129, 556, -757, -69, 459, -163, -59, 265, -563, -889, 635,\ +-583, -261, -790, 799, 826, 953, 85, 619, 334, 842, 672, -869, -4, -833, 315, 942, -524, 579, 926, 628, -404, 128, -629, 161, 568, -117, -526, 223, -876, 906, 176, -549, -317, 381, 375, -801, -416, 647, 335, 253, -386, -375, -254, 635, 352, 317, 398, -422, 111, 201, 220, 554, -972, 853, 378, 956, 942, -857, -289, -333, -180, 488, -814, -42, -595, 721, 39, 644, 721, -242, -44, 643, -457, -419, 560, -863, 974, 458, 222, -882, 526, -243, -318, -343, -707, -401, 117, 677, -489, 546, -903,\ +-960, -881, -684, 125, -928, -995, -692, -773, 647, -718, -862, -814, 671, 664, -130, -856, -674, 653, 711, 194, -685, -160, 138, -27, -128, -671, -242, 526, 494, -674, 424, -921, -778, 313, -237, 332, 913, 252, 808, -936, 289, 755, 52, -139, 57, -19, -827, -775, -561, -14, 107, -84, 622, -303, -747, 258, -942, 290, 211, -919, -207, 797, 95, 794, -830, -181, -788, 757, 75, -946, -949, -988, 152, 340, 732, 886, -891, -642, -666, 321, -910, 841, 632, 298, 55, -349, 498, 287, -711, 97, 305,\ +-974, -987, 790, -64, 605, -583, -821, 345, 887, -861, 548, 894, 288, 452, 556, -448, 813, 420, 545, 967, 127, -947, 19, -314, -607, -513, -851, 254, -290, -938, -783, -93, 474, 368, -485, -935, -539, 81, 404, -283, 779, 345, -164, 53, 563, -771, 911, -323, 522, -998, 315, 415, 460, 58, -541, -878, -152, -886, 201, -446, -810, 549, -142, -575, -632, 521, 549, 209, -681, 998, 798, -611, -919, -708, -4, 677, -172, 588, 750, -435, 508, 609, 498, -535, -691, -738, 85, 615, 705, 169, 425,\ +-669, -491, -783, 73, -847, 228, -981, -812, -229, 950, -904, 175, -438, 632, -556, 910, 173, 576, -751, -53, -169, 635, 607, -944, -13, -84, 105, -644, 984, 935, 259, -445, 620, -405, 832, 167, 114, 209, -181, -944, -496, 693, -473, 137, 38, -873, -334, -353, -57, 397, 944, 698, 811, -401, 712, -667, 905, 276, -653, 368, -543, -349, 414, 287, 894, 935, 461, 55, 741, -623, -660, -773, 617, 834, 278, -121, 52, 495, -855, -440, -210, -99, 279, -661, 540, 934, 540, 784, 895, 268, -503, 513,\ +-484, -352, 528, 341, -451, 885, -71, 799, -195, -885, -585, -233, 92, 453, 994, 464, 694, 190, -561, -116, 675, -775, -236, 556, -110, -465, 77, -781, 507, -960, -410, 229, -632, 717, 597, 429, 358, -430, -692, -825, 576, 571, 758, -891, 528, -267, 190, -869, 132, -811, 796, 750, -596, -681, 870, 360, 969, 860, -412, -567, 694, -86, -498, 38, -178, -583, -778, 412, 842, -586, 722, -192, 350, 363, 81, -677, -163, 564, 543, 671, 110, 314, 739, -552, -224, -644, 922, 685, 134, 613, 793,\ +-363, -244, -284, -257, -561, 418, 988, 333, 110, -966, 790, 927, 536, -620, -309, -358, 895, -867, -796, -357, 308, -740, 287, -732, -363, -969, 658, 711, 511, 256, 590, -574, 815, -845, -84, 546, -581, -71, -334, -890, 652, -959, 320, -236, 445, -851, 825, -756, -4, 877, 308, 573, -117, 293, 686, -483, 391, 342, -550, -982, 713, 886, 552, 474, -673, 283, -591, -383, 988, 435, -131, 708, -326, -884, 87, 680, -818, -408, -486, 813, -307, -799, 23, -497, 802, -146, -100, 541, 7, -493, 577,\ +50, -270, 672, 834, 111, -788, 247, 337, 628, -33, -964, -519, 683, 54, -703, 633, -127, -448, 759, -975, 696, 2, -870, -760, 67, 696, 306, 750, 615, 155, -933, -568, 399, 795, 164, -460, 205, 439, -526, -691, 35, -136, -481, -63, 73, -598, 748, 133, 874, -29, 4, -73, 472, 389, 962, 231, -328, 240, 149, 959, 46, -207, 72, -514, -608, 0, -14, 32, 374, -478, -806, 919, -729, -286, 652, 109, 509, -879, -979, -865, 584, -92, -346, -992, 781, 401, 575, 993, -746, -33, 684, -683, 750, -105,\ +-425, -508, -627, 27, 770, -45, 338, 921, -139, -392, -933, 634, 563, 224, -780, 921, 991, 737, 22, 64, 414, -249, -687, 869, 50, 759, -97, 515, 20, -775, -332, 957, 138, -542, -835, 591, -819, 363, -715, -146, -950, -641, -35, -435, -407, -548, -984, 383, -216, -559, 853, 4, -410, -319, -831, -459, -628, -819, -324, 755, 696, -192, 238, -234, -724, -445, 915, 302, -708, 484, 224, -641, 25, -771, 528, -106, -744, -588, 913, -554, -515, -239, -843, -812, -171, 721, 543, -269, 440, 151,\ +996, -723, -557, -522, -280, -514, -593, 208, 715, 404, 353, 270, -483, -785, 318, -313, 798, 638, 764, 748, -929, -827, -318, -56, 389, -546, -958, -398, 463, -700, 461, 311, -787, -488, 877, 456, 166, 535, -995, -189, -715, 244, 40, 484, 212, -329, -351, 638, -69, -446, -292, 801, -822, 490, -486, -185, 790, 370, -340, 401, -656, 584, 561, -749, 269, -19, -294, -111, 975, 874, -73, 851, 231, -331, -684, 460, 765, -654, -76, 10, 733, 520, 521, 416, -958, -202, -186, -167, 175, 343, -50,\ +673, -763, -854, -977, -17, -853, -122, -25, 180, 149, 268, 874, -816, -745, 747, -303, -959, 390, 509, 18, -66, 275, -277, 9, 837, -124, 989, -542, -649, -845, 894, 926, 997, -847, -809, -579, -96, -372, 766, 238, -251, 503, 559, 276, -281, -102, -735, 815, 109, 175, -10, 128, 543, -558, -707, 949, 996, -422, -506, 252, 702, -930, 552, -961, 584, -79, -177, 341, -275, 503, -21, 677, -545, 8, -956, -795, -870, -254, 170, -502, -880, 106, 174, 459, 603, -600, -963, 164, -136, -641, -309,\ +-380, -707, -727, -10, 727, 952, 997, -731, -133, 269, 287, 855, 716, -650, 479, 299, -839, -308, -782, 769, 545, 663, -536, -115, 904, -986, -258, -562, 582, 664, 408, -525, -889, 471, -370, -534, -220, 310, 766, 931, -193, -897, -192, -74, -365, -256, -359, -328, 658, -691, -431, 406, 699, 425, 713, -584, -45, -588, 289, 658, -290, -880, -987, -444, 371, 904, -155, 81, -278, -708, -189, -78, 655, 342, -998, -647, -734, -218, 726, 619, 663, 744, 518, 60, -409, 561, -727, -961, -306,\ +-147, -550, 240, -218, -393, 267, 724, 791, -548, 480, 180, -631, 825, -170, 107, 227, -691, 905, -909, 359, 227, 287, 909, 632, -89, -522, 80, -429, 37, 561, -732, -474, 565, -798, -460, 188, 507, -511, -654, 212, -314, -376, -997, -114, -708, 512, -848, 781, 126, -956, -298, 354, -400, -121, 510, 445, 926, 27, -708, 676, 248, 834, 542, 236, -105, -153, 102, 128, 96, -348, -626, 598, 8, 978, -589, -461, -38, 381, -232, -817, 467, 356, -151, -460, 429, -408, 425, 618, -611, -247, 819,\ +963, -160, 1000, 141, -647, -875, 108, 790, -127, 463, -37, -195, -542, 12, 845, -384, 770, -129, 315, 826, -942, 430, 146, -170, -583, -903, -489, 497, -559, -401, -29, -129, -411, 166, 942, -646, -862, -404, 785, 777, -111, -481, -738, 490, 741, -398, 846, -178, -509, -661, 748, 297, -658, -567, 531, 427, -201, -41, -808, -668, 782, -860, -324, 249, 835, -234, 116, 542, -201, 328, 675, 480, -906, 188, 445, 63, -525, 811, 277, 133, 779, -680, 950, -477, -306, -64, 552, -890, -956, 169,\ +442, 44, -169, -243, -242, 423, -884, -757, -403, 739, -350, 383, 429, 153, -702, -725, 51, 310, 857, -56, 538, 46, -311, 132, -620, -297, -124, 534, 884, -629, -117, 506, -837, -100, -27, -381, -735, 262, 843, 703, 260, -457, 834, 469, 9, 950, 59, 127, -820, 518, 64, -783, 659, -608, -676, 802, 30, 589, 246, -369, 361, 347, 534, -376, 68, 941, 709, 264, 384, 481, 628, 199, -568, -342, -337, 853, -804, -858, -169, -270, 641, -344, 112, 530, -773, -349, -135, -367, -350, -756, -911, 180,\ +-660, 116, -478, -265, -581, 510, 520, -986, 935, 219, 522, 744, 47, -145, 917, 638, 301, 296, 858, -721, 511, -816, 328, 473, 441, 697, -260, -673, -379, 893, 458, 154, 86, 905, 590, 231, -717, -179, 79, 272, -439, -192, 178, -200, 51, 717, -256, -358, -626, -518, -314, -825, -325, 588, 675, -892, -798, 448, -518, 603, -23, 668, -655, 845, -314, 783, -347, -496, 921, 893, -163, -748, -906, 11, -143, -64, 300, 336, 882, 646, 533, 676, -98, -148, -607, -952, -481, -959, -874, 764, 537,\ +736, -347, 646, -843, 966, -916, -718, -391, -648, 740, 755, 919, -608, 388, -655, 68, 201, 675, -855, 7, -503, 881, 760, 669, 831, 721, -564, -445, 217, 331, 970, 521, 486, -254, 25, -259, 336, -831, 252, -995, 908, -412, -240, 123, -478, 366, 264, -504, -843, 632, -288, 896, 301, 423, 185, 318, 380, 457, -450, -162, -313, 673, -963, 570, 433, -548, 107, -39, -142, -98, -884, -3, 599, -486, -926, 923, -82, 686, 290, 99, -382, -789, 16, 495, 570, 284, 474, -504, -201, -178, -1, 592, 52,\ +827, -540, -151, -991, 130, 353, -420, -467, -661, 417, -690, 942, 936, 814, -566, -251, -298, 341, -139, 786, 129, 525, -861, 680, 955, -245, -50, 331, 412, -38, -66, 611, -558, 392, -629, -471, -68, -535, 744, 495, 87, 558, 695, 260, -308, 215, -464, 239, -50, 193, -540, 184, -8, -194, 148, 898, -557, -21, 884, 644, -785, -689, -281, -737, 267, 50, 206, 292, 265, 380, -511, 310, 53, 375, -497, -40, 312, -606, -395, 142, 422, 662, -584, 72, 144, 40, -679, -593, 581, 689, -829, 442, 822,\ +977, -832, -134, -248, -207, 248, 29, 259, 189, 592, -834, -866, 102, 0, 340, 25, -354, -239, 420, -730, -992, -925, -314, 420, 914, 607, -296, -415, -30, 813, 866, 153, -90, 150, -81, 636, -392, -222, -835, 482, -631, -962, -413, -727, 280, 686, -382, 157, -404, -511, -432, 455, 58, 108, -408, 290, -829, -252, 113, 550, -935, 925, 422, 38, 789, 361, 487, -460, -769, -963, -285, 206, -799, -488, -233, 416, 143, -456, 753, 520, 599, 621, -168, 178, -841, 51, 952, 374, 166, -300, -576, 844,\ +-656, 90, 780, 371, 730, -896, -895, -386, -662, 467, -61, 130, -362, -675, -113, 135, -761, -55, 408, 822, 675, -347, 725, 114, 952, -510, -972, 390, -413, -277, -52, 315, -80, 401, -712, 147, -202, 84, 214, -178, 970, -571, -210, 525, -887, -863, 504, 192, 837, -594, 203, -876, -209, 305, -826, 377, 103, -928, -803, -956, 949, -868, -547, 824, -994, 516, 93, -524, -866, -890, -988, -501, 15, -6, 413, -825, 304, -818, -223, 525, 176, 610, 828, 391, 940, 540, -831, 650, 438, 589, 941, 57,\ +523, 126, 221, 860, -282, -262, -226, 764, 743, -640, 390, 384, -434, 608, -983, 566, -446, 618, 456, -176, -278, 215, 871, -180, 444, -931, -200, -781, 404, 881, 780, -782, 517, -739, -548, -811, 201, -95, -249, -228, 491, -299, 700, 964, -550, 108, 334, -653, 245, -293, -552, 350, -685, -415, -818, 216, -194, -255, 295, 249, 408, 351, 287, 379, 682, 231, -693, 902, -902, 574, 937, -708, -402, -460, 827, -268, 791, 343, -780, -150, -738, 920, -430, -88, -361, -588, -727, -47, -297, 662,\ +-840, -637, -635, 916, -857, 938, 132, -553, 391, -522, 640, 626, 690, 833, 867, -555, 577, 226, 686, -44, 0, -965, 651, -1, 909, 595, -646, 740, -821, -648, -962, 927, -193, 159, 490, 594, -189, 707, -884, 759, -278, -160, -566, -340, 19, 862, -440, 445, -598, 341, 664, -311, 309, -159, 19, -672, 705, -646, 976, 247, 686, -830, -27, -667, 81, 399, -423, -567, 945, 38, 51, 740, 621, 204, -199, -908, -593, 424, 250, -561, 695, 9, 520, 878, 120, -109, 42, -375, -635, -711, -687, 383, -278,\ +36, 970, 925, 864, 836, 309, 117, 89, 654, -387, 346, -53, 617, -164, -624, 184, -45, 852, 498, -513, 794, -682, -576, 13, -147, 285, -776, -886, -96, 483, 994, -188, 346, -629, -848, 738, 51, 128, -898, -753, -906, 270, -203, -577, 48, -243, -210, 666, 353, 636, -954, 862, 560, -944, -877, -137, 440, -945, -316, 274, -211, -435, 615, -635, -468, 744, 948, -589, 525, 757, -191, -431, 42, 451, -160, -827, -991, 324, 697, 342, -610, 894, -787, -384, 872, 734, 878, 70, -260, 57, 397, -518,\ +629, -510, -94, 207, 214, -625, 106, -882, -575, 908, -650, 723, -154, 45, 108, -69, -565, 927, -68, -351, 707, -282, 429, -889, -596, 848, 578, -492, 41, -822, -992, 168, -286, -780, 970, 597, -293, -12, 367, 708, -415, 194, -86, -390, 224, 69, -368, -674, 1000, -672, 356, -202, -169, 826, 476, -285, 29, -448, 545, 186, 319, 67, 705, 412, 225, -212, -351, -391, -783, -9, 875, -59, -159, -123, -151, -296, 871, -638, 359, 909, -945, 345, -16, -562, -363, -183, -625, -115, -571, -329, 514,\ +99, 263, 463, -39, 597, -652, -349, 246, 77, -127, -563, -879, -30, 756, 777, -865, 675, -813, -501, 871, -406, -627, 834, -609, -205, -812, 643, -204, 291, -251, -184, -584, -541, 410, -573, -600, 908, -871, -687, 296, -713, -139, -778, -790, 347, -52, -400, 407, -653, 670, 39, -856, 904, 433, 392, 590, -271, -144, -863, 443, 353, 468, -544, 486, -930, 458, -596, -890, 163, 822, 768, 980, -783, -792, 126, 386, 367, -264, 603, -61, 728, 160, -4, -837, 832, 591, 436, 518, 796, -622, -867,\ +-669, -947, 253, 100, -792, 841, 413, 833, -249, -550, 282, -825, 936, -348, 898, -451, -283, 818, -237, 630, 216, -499, -637, -511, 767, -396, 221, 958, -586, -920, 401, -313, -580, -145, -270, 118, 497, 426, -975, 480, -445, -150, -721, -929, 439, -893, 902, 960, -525, -793, 924, 563, 683, -727, -86, 309, 432, -762, -345, 371, -617, 149, -215, -228, 505, 593, -20, -292, 704, -999, 149, -104, 819, -414, -443, 517, -599, -5, 145, -24, -993, -283, 904, 174, -112, -276, -860, 44, -257,\ +-931, -821, -667, 540, 421, 485, 531, 407, 833, 431, -415, 878, 503, -901, 639, -608, 896, 860, 927, 424, 113, -808, -323, 729, 382, -922, 548, -791, -379, 207, 203, 559, 537, 137, 999, -913, -240, 942, 249, 616, 775, -4, 915, 855, -987, -234, -384, 948, -310, -542, 125, -289, -599, 967, -492, -349, -552, 562, -926, 632, -164, 217, -165, -496, 847, 684, -884, 457, -748, -745, -38, 93, 961, 934, 588, 366, -130, 851, -803, -811, -211, 428, 183, -469, 888, 596, -475, -899, -681, 508, 184,\ +921, 863, -610, -416, -119, -966, -686, 210, 733, 715, -889, -925, -434, -566, -455, 596, -514, 983, 755, -194, -802, -313, 91, -541, 808, -834, 243, -377, 256, 966, -402, -773, -308, -605, 266, 866, 118, -425, -531, 498, 666, 813, -267, 830, 69, -869, -496, 735, 28, 488, -645, -493, -689, 170, -940, 532, 844, -658, -617, 408, -200, 764, -665, 568, 342, 621, 908, 471, 280, 859, 709, 898, 81, -547, 406, 514, -595, 43, -824, -696, -746, -429, -59, -263, -813, 233, 279, -125, 687, -418,\ +-530, 409, 614, 803, -407, 78, -676, -39, -887, -141, -292, 270, -343, 400, 907, 588, 668, 899, 973, 103, -101, -11, 397, -16, 165, 705, -410, -585, 316, 391, -346, -336, 957, -118, -538, -441, -845, 121, 591, -359, -188, -362, -208, 27, -925, -157, -495, -177, -580, 9, 531, -752, 94, 107, 820, 769, -500, 852, 617, 145, 355, 34, -463, -265, -709, -111, -855, -405, 560, 470, 3, -177, -164, -249, 450, 662, 841, -689, -509, 987, -33, 769, 234, -2, 203, 780, 744, -895, 497, -432, -406, -264,\ +-71, 124, 778, -897, 495, 127, -76, 52, -768, 205, 464, -992, 801, -83, -806, 545, -316, 146, 772, 786, 289, -936, 145, -30, -722, -455, 270, 444, 427, -482, 383, -861, 36, 630, -404, 83, 864, 743, -351, -846, 315, -837, 357, -195, 450, -715, 227, -942, 740, -519, 476, 716, 713, 169, 492, -112, -49, -931, 866, 95, -725, 198, -50, -17, -660, 356, -142, -781, 53, 431, 720, 143, -416, 446, -497, 490, -96, 157, 239, 487, -337, -224, -445, 813, 92, -22, 603, 424, 952, -632, -367, 898, -927,\ +884, -277, -187, -777, 537, -575, -313, 347, -33, 800, 672, -919, -541, 5, -270, -94, -265, -793, -183, -761, -516, -608, -218, 57, -889, -912, 508, 93, -90, 34, 530, 201, 999, -37, -186, -62, -980, 239, 902, 983, -287, -634, 524, -772, 470, -961, 32, 162, 315, -411, 400, -235, -283, -787, -703, 869, 792, 543, -274, 239, 733, -439, 306, 349, 579, -200, -201, -824, 384, -246, 133, -508, 770, -102, 957, -825, 740, 748, -376, 183, -426, 46, 668, -886, -43, -174, 672, -419, 390, 927, 1000,\ +318, 886, 47, 908, -540, -825, -5, 314, -999, 354, -603, 966, -633, -689, 985, 534, -290, 167, -652, -797, -612, -79, 488, 622, -464, -950, 595, 897, 704, -238, -395, 125, 831, -180, 226, -379, 310, 564, 56, -978, 895, -61, 686, -251, 434, -417, 161, -512, 752, 528, -589, -425, 66, -925, -157, 1000, 96, 256, -239, -784, -882, -464, -909, 663, -177, -678, -441, 669, -564, -201, -121, -743, 187, -107, -768, -682, 355, 161, 411, 984, -954, 166, -842, -755, 267, -709, 372, -699, -272, -850,\ +403, -839, 949, 622, -62, 51, 917, 70, 528, -558, -632, 832, 276, 61, -445, -195, 960, 846, -474, 764, 879, -411, 948, -62, -592, -123, -96, -551, -555, -724, 849, 250, -808, -732, 797, -839, -554, 306, -919, 888, 484, -728, 152, -122, -287, 16, -345, -396, -268, -963, -500, 433, 343, 418, -480, 828, 594, 821, -9, 933, -230, 707, -847, -610, -748, -234, 688, 935, 713, 865, -743, 293, -143, -20, 928, -906, -762, 528, 722, 412, -70, 622, -245, 539, -686, 730, -866, -705, 28, -916, -623,\ +-768, -614, -915, -123, -183, 680, -223, 515, -37, -235, -5, 260, 347, -239, -322, -861, -848, -936, 945, 721, -580, -639, 780, -153, -26, 685, 177, 587, 307, -915, 435, 658, 539, -229, -719, -171, -858, 162, 734, -539, -437, 246, 639, 765, -477, -342, -209, -284, -779, -414, -452, 914, 338, -83, 759, 567, 266, -485, 14, 225, 347, -432, -242, 997, -365, -764, 119, -641, -416, -388, -436, -388, -54, -649, -571, -920, -477, 714, -363, 836, 369, 702, 869, 503, -287, -679, 46, -666, -202,\ +-602, 71, -259, 967, 601, -571, -830, -993, -271, 281, -494, 482, -180, 572, 587, -651, -566, -448, -228, 511, -924, 832, -52, -712, 402, -644, -533, -865, 269, 965, 56, 675, 179, -338, -272, 614, 602, -283, 303, -70, 909, -942, 117, 839, 468, 813, -765, 884, -697, -813, 352, 374, -705, -295, 633, 211, -754, 597, -941, -142, -393, -469, -653, 688, 996, 911, 214, 431, 453, -141, 874, -81, -258, -735, -3, -110, -338, -929, -182, -306, -104, -840, -588, -759, -157, -801, 848, -698, 627, 914,\ +-33, -353, 425, 150, -798, 553, 934, -778, -196, -132, 808, 745, -894, 144, 213, 662, 273, -79, 454, -60, -467, 48, -15, -807, 69, -930, 749, 559, -867, -103, 258, -677, 750, -303, 846, -227, -936, 744, -770, 770, -434, 594, -477, 589, -612, 535, 357, -623, 683, 369, 905, 980, -410, -663, 762, -888, -563, -845, 843, 353, -491, 996, -255, -336, -132, 695, -823, 289, -143, 365, 916, 877, 245, -530, -848, -804, -118, -108, 847, 620, -355, 499, 881, 92, -640, 542, 38, 626, -260, -34, -378,\ +598, 890, 305, -118, 711, -385, 600, -570, 27, -129, -893, 354, 459, 374, 816, 470, 356, 661, 877, 735, -286, -780, 620, 943, -169, -888, 978, 441, -667, -399, 662, 249, 137, 598, -863, -453, 722, -815, -251, -995, -294, -707, 901, 763, 977, 137, 431, -994, 905, 593, 694, 444, -626, -816, 252, 282, 616, 841, 360, -932, 817, -908, 50, 394, -120, -786, -338, 499, -982, -95, -454, 838, -312, 320, -127, -653, 53, 16, 988, -968, -151, -369, -836, 293, -271, 483, 18, 724, -204, -965, 245, 310,\ +987, 552, -835, -912, -861, 254, 560, 124, 145, 798, 178, 476, 138, -311, 151, -907, -886, -592, 728, -43, -489, 873, -422, -439, -489, 375, -703, -459, 338, 418, -25, 332, -454, 730, -604, -800, 37, -172, -197, -568, -563, -332, 228, -182, 994, -123, 444, -567, 98, 78, 0, -504, -150, 88, -936, 199, -651, -776, 192, 46, 526, -727, -991, 534, -659, -738, 256, -894, 965, -76, 816, 435, -418, 800, 838, 67, -733, 570, 112, -514, -416\r\ +"; + thread_local! { + static TEST_TERM_AND_POINT: (Term, AlacPoint) = + build_test_term(&LINE, 5, 50); + } + TEST_TERM_AND_POINT.with(|(term, point)| { + assert_eq!( + find_from_grid_point_bench(term, *point) + .map(|(path, ..)| path) + .unwrap_or_default(), + "392", + "Hyperlink should have been found" + ); + }); + } + + #[perf] + // https://github.com/zed-industries/zed/issues/44510 + pub fn issue_44510_hyperlink_benchmark() { + const LINE: &str = "..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\ +..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\ +..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\ +..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\ +..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\ +..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\ +..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\ +..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\ +..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\ +..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\ +..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\ +..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\ +..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\ +..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\ +...............................................E.\r\ +"; + thread_local! { + static TEST_TERM_AND_POINT: (Term, AlacPoint) = + build_test_term(&LINE, 5, 50); + } + TEST_TERM_AND_POINT.with(|(term, point)| { + assert_eq!( + find_from_grid_point_bench(term, *point) + .map(|(path, ..)| path) + .unwrap_or_default(), + LINE.trim_end_matches(['.', '\r', '\n']), "Hyperlink should have been found" ); }); @@ -1063,8 +1224,9 @@ mod tests { } mod file_iri { - // File IRIs have a ton of use cases, most of which we currently do not support. A few of - // those cases are documented here as tests which are expected to fail. + // File IRIs have a ton of use cases. Absolute file URIs are supported on all platforms, + // including Windows drive letters (e.g., file:///C:/path) and percent-encoded characters. + // Some cases like relative file IRIs are not supported. // See https://en.wikipedia.org/wiki/File_URI_scheme /// [**`c₀, c₁, …, cₙ;`**]ₒₚₜ := use specified terminal widths of `c₀, c₁, …, cₙ` **columns** @@ -1084,7 +1246,6 @@ mod tests { mod issues { #[cfg(not(target_os = "windows"))] #[test] - #[should_panic(expected = "Path = «/test/Ῥόδος/», at grid cells (0, 0)..=(15, 1)")] fn issue_file_iri_with_percent_encoded_characters() { // Non-space characters // file:///test/Ῥόδος/ @@ -1113,18 +1274,12 @@ mod tests { // See https://en.wikipedia.org/wiki/File_URI_scheme // https://github.com/zed-industries/zed/issues/39189 #[test] - #[should_panic( - expected = r#"Path = «C:\\test\\cool\\index.rs», at grid cells (0, 0)..=(9, 1)"# - )] fn issue_39189() { test_file_iri!("file:///C:/test/cool/index.rs"); test_file_iri!("file:///C:/test/cool/"); } #[test] - #[should_panic( - expected = r#"Path = «C:\\test\\Ῥόδος\\», at grid cells (0, 0)..=(16, 1)"# - )] fn issue_file_iri_with_percent_encoded_characters() { // Non-space characters // file:///test/Ῥόδος/ 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 4ccf639507cd53ba9779ce18c1550fdc4c50556e..ed43d94e9d3d7c08c1ff4570e08726310360cd93 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -342,7 +342,7 @@ impl TerminalPanel { pane::Event::RemovedItem { .. } => self.serialize(cx), pane::Event::Remove { focus_on_pane } => { let pane_count_before_removal = self.center.panes().len(); - let _removal_result = self.center.remove(pane); + let _removal_result = self.center.remove(pane, cx); if pane_count_before_removal == 1 { self.center.first_pane().update(cx, |pane, cx| { pane.set_zoomed(false, cx); @@ -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 => { @@ -393,8 +393,11 @@ impl TerminalPanel { }; panel .update_in(cx, |panel, window, cx| { - panel.center.split(&pane, &new_pane, direction).log_err(); - window.focus(&new_pane.focus_handle(cx)); + panel + .center + .split(&pane, &new_pane, direction, cx) + .log_err(); + window.focus(&new_pane.focus_handle(cx), cx); }) .ok(); }) @@ -415,8 +418,8 @@ impl TerminalPanel { new_pane.update(cx, |pane, cx| { pane.add_item(item, true, true, None, window, cx); }); - self.center.split(&pane, &new_pane, direction).log_err(); - window.focus(&new_pane.focus_handle(cx)); + self.center.split(&pane, &new_pane, direction, cx).log_err(); + window.focus(&new_pane.focus_handle(cx), cx); } } pane::Event::Focus => { @@ -550,7 +553,7 @@ impl TerminalPanel { let builder = ShellBuilder::new(&shell, is_windows); let command_label = builder.command_label(task.command.as_deref().unwrap_or("")); - let (command, args) = builder.build(task.command.clone(), &task.args); + let (command, args) = builder.build_no_quote(task.command.clone(), &task.args); let task = SpawnInTerminal { command_label, @@ -787,8 +790,7 @@ impl TerminalPanel { } pane.update(cx, |pane, cx| { - let focus = pane.has_focus(window, cx) - || matches!(reveal_strategy, RevealStrategy::Always); + let focus = matches!(reveal_strategy, RevealStrategy::Always); pane.add_item(terminal_view, true, focus, None, window, cx); }); @@ -850,8 +852,7 @@ impl TerminalPanel { } pane.update(cx, |pane, cx| { - let focus = pane.has_focus(window, cx) - || matches!(reveal_strategy, RevealStrategy::Always); + let focus = matches!(reveal_strategy, RevealStrategy::Always); pane.add_item(terminal_view, true, focus, None, window, cx); }); @@ -995,7 +996,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 => { @@ -1050,7 +1051,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| { @@ -1066,7 +1067,7 @@ impl TerminalPanel { .find_pane_in_direction(&self.active_pane, direction, cx) .cloned() { - self.center.swap(&self.active_pane, &to); + self.center.swap(&self.active_pane, &to, cx); cx.notify(); } } @@ -1074,7 +1075,7 @@ impl TerminalPanel { fn move_pane_to_border(&mut self, direction: SplitDirection, cx: &mut Context) { if self .center - .move_to_border(&self.active_pane, direction) + .move_to_border(&self.active_pane, direction, cx) .unwrap() { cx.notify(); @@ -1168,63 +1169,67 @@ pub fn new_terminal_pane( let source = tab.pane.clone(); let item_id_to_move = item.item_id(); - let Ok(new_split_pane) = pane - .drag_split_direction() - .map(|split_direction| { - drop_closure_terminal_panel.update(cx, |terminal_panel, cx| { - let is_zoomed = if terminal_panel.active_pane == this_pane { - pane.is_zoomed() - } else { - terminal_panel.active_pane.read(cx).is_zoomed() - }; - let new_pane = new_terminal_pane( - workspace.clone(), - project.clone(), - is_zoomed, - window, - cx, - ); - terminal_panel.apply_tab_bar_buttons(&new_pane, cx); - terminal_panel.center.split( - &this_pane, - &new_pane, - split_direction, - )?; - anyhow::Ok(new_pane) - }) - }) - .transpose() - else { - return ControlFlow::Break(()); + // If no split direction, let the regular pane drop handler take care of it + let Some(split_direction) = pane.drag_split_direction() else { + return ControlFlow::Continue(()); }; - match new_split_pane.transpose() { - // Source pane may be the one currently updated, so defer the move. - Ok(Some(new_pane)) => cx - .spawn_in(window, async move |_, cx| { - cx.update(|window, cx| { - move_item( - &source, + // Gather data synchronously before deferring + let is_zoomed = drop_closure_terminal_panel + .upgrade() + .map(|terminal_panel| { + let terminal_panel = terminal_panel.read(cx); + if terminal_panel.active_pane == this_pane { + pane.is_zoomed() + } else { + terminal_panel.active_pane.read(cx).is_zoomed() + } + }) + .unwrap_or(false); + + let workspace = workspace.clone(); + let terminal_panel = drop_closure_terminal_panel.clone(); + + // Defer the split operation to avoid re-entrancy panic. + // The pane may be the one currently being updated, so we cannot + // call mark_positions (via split) synchronously. + cx.spawn_in(window, async move |_, cx| { + cx.update(|window, cx| { + let Ok(new_pane) = + terminal_panel.update(cx, |terminal_panel, cx| { + let new_pane = new_terminal_pane( + workspace, project, is_zoomed, window, cx, + ); + terminal_panel.apply_tab_bar_buttons(&new_pane, cx); + terminal_panel.center.split( + &this_pane, &new_pane, - item_id_to_move, - new_pane.read(cx).active_item_index(), - true, - window, + split_direction, cx, - ); + )?; + anyhow::Ok(new_pane) }) - .ok(); - }) - .detach(), - // If we drop into existing pane or current pane, - // regular pane drop handler will take care of it, - // using the right tab index for the operation. - Ok(None) => return ControlFlow::Continue(()), - err @ Err(_) => { - err.log_err(); - return ControlFlow::Break(()); - } - }; + else { + return; + }; + + let Some(new_pane) = new_pane.log_err() else { + return; + }; + + move_item( + &source, + &new_pane, + item_id_to_move, + new_pane.read(cx).active_item_index(), + true, + window, + cx, + ); + }) + .ok(); + }) + .detach(); } else if let Some(project_path) = item.project_path(cx) && let Some(entry_path) = project.read(cx).absolute_path(&project_path, cx) { @@ -1293,7 +1298,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| { @@ -1447,7 +1452,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); } }), ) @@ -1459,7 +1464,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); } }, )) @@ -1467,7 +1472,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); @@ -1482,10 +1487,11 @@ impl Render for TerminalPanel { &terminal_panel.active_pane, &new_pane, SplitDirection::Right, + cx, ) .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..e7e60ff4b31dfbdd16b7de8841285d81fc311fc5 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -8,8 +8,8 @@ mod terminal_slash_command; use assistant_slash_command::SlashCommandRegistry; use editor::{EditorSettings, actions::SelectAll, blink_manager::BlinkManager}; use gpui::{ - Action, AnyElement, App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, - KeyContext, KeyDownEvent, Keystroke, MouseButton, MouseDownEvent, Pixels, Render, + Action, AnyElement, App, ClipboardEntry, DismissEvent, Entity, EventEmitter, FocusHandle, + Focusable, KeyContext, KeyDownEvent, Keystroke, MouseButton, MouseDownEvent, Pixels, Render, ScrollWheelEvent, Styled, Subscription, Task, WeakEntity, actions, anchored, deferred, div, }; use persistence::TERMINAL_DB; @@ -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, @@ -687,12 +687,32 @@ impl TerminalView { ///Attempt to paste the clipboard into the terminal fn paste(&mut self, _: &Paste, _: &mut Window, cx: &mut Context) { - if let Some(clipboard_string) = cx.read_from_clipboard().and_then(|item| item.text()) { + let Some(clipboard) = cx.read_from_clipboard() else { + return; + }; + + if clipboard.entries().iter().any(|entry| match entry { + ClipboardEntry::Image(image) => !image.bytes.is_empty(), + _ => false, + }) { + self.forward_ctrl_v(cx); + return; + } + + if let Some(text) = clipboard.text() { self.terminal - .update(cx, |terminal, _cx| terminal.paste(&clipboard_string)); + .update(cx, |terminal, _cx| terminal.paste(&text)); } } + /// Emits a raw Ctrl+V so TUI agents can read the OS clipboard directly + /// and attach images using their native workflows. + fn forward_ctrl_v(&self, cx: &mut Context) { + self.terminal.update(cx, |term, _| { + term.input(vec![0x16]); + }); + } + fn send_text(&mut self, text: &SendText, _: &mut Window, cx: &mut Context) { self.clear_bell(cx); self.terminal.update(cx, |term, _| { diff --git a/crates/text/src/text.rs b/crates/text/src/text.rs index acd712f40da23af4c364649b14860e41a346389c..866552e4e5d9039a9517a556323a4ba7a89fcee1 100644 --- a/crates/text/src/text.rs +++ b/crates/text/src/text.rs @@ -39,6 +39,7 @@ pub use subscription::*; pub use sum_tree::Bias; use sum_tree::{Dimensions, FilterCursor, SumTree, TreeMap, TreeSet}; use undo_map::UndoMap; +use util::debug_panic; #[cfg(any(test, feature = "test-support"))] use util::RandomCharIter; @@ -2320,8 +2321,13 @@ impl BufferSnapshot { } else if anchor.is_max() { self.visible_text.len() } else { - debug_assert!(anchor.buffer_id == Some(self.remote_id)); - debug_assert!(self.version.observed(anchor.timestamp)); + debug_assert_eq!(anchor.buffer_id, Some(self.remote_id)); + debug_assert!( + self.version.observed(anchor.timestamp), + "Anchor timestamp {:?} not observed by buffer {:?}", + anchor.timestamp, + self.version + ); let anchor_key = InsertionFragmentKey { timestamp: anchor.timestamp, split_offset: anchor.offset, @@ -2439,7 +2445,7 @@ impl BufferSnapshot { if bias == Bias::Left && offset == 0 { Anchor::min_for_buffer(self.remote_id) } else if bias == Bias::Right - && ((cfg!(debug_assertions) && offset >= self.len()) || offset == self.len()) + && ((!cfg!(debug_assertions) && offset >= self.len()) || offset == self.len()) { Anchor::max_for_buffer(self.remote_id) } else { @@ -2453,7 +2459,15 @@ impl BufferSnapshot { }; } let (start, _, item) = self.fragments.find::(&None, &offset, bias); - let fragment = item.unwrap(); + let Some(fragment) = item else { + // We got a bad offset, likely out of bounds + debug_panic!( + "Failed to find fragment at offset {} (len: {})", + offset, + self.len() + ); + return Anchor::max_for_buffer(self.remote_id); + }; let overshoot = offset - start; Anchor { timestamp: fragment.timestamp, @@ -3373,6 +3387,25 @@ impl LineEnding { } } +pub fn chunks_with_line_ending(rope: &Rope, line_ending: LineEnding) -> impl Iterator { + rope.chunks().flat_map(move |chunk| { + let mut newline = false; + let end_with_newline = chunk.ends_with('\n').then_some(line_ending.as_str()); + chunk + .lines() + .flat_map(move |line| { + let ending = if newline { + Some(line_ending.as_str()) + } else { + None + }; + newline = true; + ending.into_iter().chain([line]) + }) + .chain(end_with_newline) + }) +} + #[cfg(debug_assertions)] pub mod debug { use super::*; diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 945b28a02d1e3f7d6e358c2dad0107d7404aa84b..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,11 +165,12 @@ 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)) }) .when(title_bar_settings.show_branch_name, |title_bar| { - title_bar.children(self.render_project_branch(cx)) + title_bar.children(self.render_project_repo(cx)) }) }) }) @@ -202,9 +205,11 @@ impl Render for TitleBar { .children(self.render_connection_status(status, cx)) .when( user.is_none() && TitleBarSettings::get_global(cx).show_sign_in, - |el| el.child(self.render_sign_in_button(cx)), + |this| this.child(self.render_sign_in_button(cx)), ) - .child(self.render_app_menu_button(cx)) + .when(TitleBarSettings::get_global(cx).show_user_menu, |this| { + this.child(self.render_user_menu_button(cx)) + }) .into_any_element(), ); @@ -289,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( @@ -315,20 +325,47 @@ impl TitleBar { client, _subscriptions: subscriptions, banner, - screen_share_popover_handle: Default::default(), + screen_share_popover_handle: PopoverMenuHandle::default(), } } + fn project_name(&self, cx: &Context) -> Option { + self.project + .read(cx) + .visible_worktrees(cx) + .map(|worktree| { + let worktree = worktree.read(cx); + let settings_location = SettingsLocation { + worktree_id: worktree.id(), + path: RelPath::empty(), + }; + + let settings = WorktreeSettings::get(Some(settings_location), cx); + let name = match &settings.project_name { + Some(name) => name.as_str(), + None => worktree.root_name_str(), + }; + SharedString::new(name) + }) + .next() + } + fn render_remote_project_connection(&self, cx: &mut Context) -> Option { let options = self.project.read(cx).remote_connection_options(cx)?; let host: SharedString = options.display_name().into(); - let (nickname, icon) = match options { - RemoteConnectionOptions::Ssh(options) => { - (options.nickname.map(|nick| nick.into()), IconName::Server) + let (nickname, tooltip_title, icon) = match options { + RemoteConnectionOptions::Ssh(options) => ( + options.nickname.map(|nick| nick.into()), + "Remote Project", + IconName::Server, + ), + RemoteConnectionOptions::Wsl(_) => (None, "Remote Project", IconName::Linux), + RemoteConnectionOptions::Docker(_dev_container_connection) => { + (None, "Dev Container", IconName::Box) } - RemoteConnectionOptions::Wsl(_) => (None, IconName::Linux), }; + let nickname = nickname.unwrap_or_else(|| host.clone()); let (indicator_color, meta) = match self.project.read(cx).remote_connection_state(cx)? { @@ -375,7 +412,7 @@ impl TitleBar { ) .tooltip(move |_window, cx| { Tooltip::with_meta( - "Remote Project", + tooltip_title, Some(&OpenRemote { from_existing_connection: false, create_new_window: false, @@ -398,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); @@ -445,29 +524,12 @@ impl TitleBar { } pub fn render_project_name(&self, cx: &mut Context) -> impl IntoElement { - let name = self - .project - .read(cx) - .visible_worktrees(cx) - .map(|worktree| { - let worktree = worktree.read(cx); - let settings_location = SettingsLocation { - worktree_id: worktree.id(), - path: RelPath::empty(), - }; - - let settings = WorktreeSettings::get(Some(settings_location), cx); - match &settings.project_name { - Some(name) => name.as_str(), - None => worktree.root_name_str(), - } - }) - .next(); + let name = self.project_name(cx); let is_project_selected = name.is_some(); let name = if let Some(name) = name { - util::truncate_and_trailoff(name, MAX_PROJECT_NAME_LENGTH) + util::truncate_and_trailoff(&name, MAX_PROJECT_NAME_LENGTH) } else { - "Open recent project".to_string() + "Open Recent Project".to_string() }; Button::new("project_name_trigger", name) @@ -494,9 +556,10 @@ impl TitleBar { })) } - pub fn render_project_branch(&self, cx: &mut Context) -> Option { + pub fn render_project_repo(&self, cx: &mut Context) -> Option { let settings = TitleBarSettings::get_global(cx); let repository = self.project.read(cx).active_repository(cx)?; + let repository_count = self.project.read(cx).repositories(cx).len(); let workspace = self.workspace.upgrade()?; let repo = repository.read(cx); let branch_name = repo @@ -513,6 +576,19 @@ impl TitleBar { .collect::() }) })?; + let project_name = self.project_name(cx); + let repo_name = repo + .work_directory_abs_path + .file_name() + .and_then(|name| name.to_str()) + .map(SharedString::new); + let show_repo_name = + repository_count > 1 && repo.branch.is_some() && repo_name != project_name; + let branch_name = if let Some(repo_name) = repo_name.filter(|_| show_repo_name) { + format!("{repo_name}/{branch_name}") + } else { + branch_name + }; Some( Button::new("project_branch_trigger", branch_name) @@ -529,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); }); }) @@ -661,7 +737,7 @@ impl TitleBar { }) } - pub fn render_app_menu_button(&mut self, cx: &mut Context) -> impl Element { + pub fn render_user_menu_button(&mut self, cx: &mut Context) -> impl Element { let user_store = self.user_store.read(cx); let user = user_store.current_user(); diff --git a/crates/title_bar/src/title_bar_settings.rs b/crates/title_bar/src/title_bar_settings.rs index 29fae4d31eb33ac70a22c21010f09350847439c2..155b7b7bc797567927a70b12c677372cb92c9453 100644 --- a/crates/title_bar/src/title_bar_settings.rs +++ b/crates/title_bar/src/title_bar_settings.rs @@ -8,6 +8,7 @@ pub struct TitleBarSettings { pub show_branch_name: bool, pub show_project_items: bool, pub show_sign_in: bool, + pub show_user_menu: bool, pub show_menus: bool, } @@ -21,6 +22,7 @@ impl Settings for TitleBarSettings { show_branch_name: content.show_branch_name.unwrap(), show_project_items: content.show_project_items.unwrap(), show_sign_in: content.show_sign_in.unwrap(), + show_user_menu: content.show_user_menu.unwrap(), show_menus: content.show_menus.unwrap(), } } diff --git a/crates/toolchain_selector/src/toolchain_selector.rs b/crates/toolchain_selector/src/toolchain_selector.rs index 138f99066f0a80188837de49f6afc67d91d9eeb5..f7262c248f15f0f68fcd7a903ee01cac6b22d0af 100644 --- a/crates/toolchain_selector/src/toolchain_selector.rs +++ b/crates/toolchain_selector/src/toolchain_selector.rs @@ -128,67 +128,61 @@ impl AddToolchainState { ) -> (OpenPathDelegate, oneshot::Receiver>>) { let (tx, rx) = oneshot::channel(); let weak = cx.weak_entity(); - let path_style = project.read(cx).path_style(cx); - let lister = - OpenPathDelegate::new(tx, DirectoryLister::Project(project), false, path_style) - .show_hidden() - .with_footer(Arc::new(move |_, cx| { - let error = weak - .read_with(cx, |this, _| { - if let AddState::Path { error, .. } = &this.state { - error.clone() - } else { - None + let lister = OpenPathDelegate::new(tx, DirectoryLister::Project(project), false, cx) + .show_hidden() + .with_footer(Arc::new(move |_, cx| { + let error = weak + .read_with(cx, |this, _| { + if let AddState::Path { error, .. } = &this.state { + error.clone() + } else { + None + } + }) + .ok() + .flatten(); + let is_loading = weak + .read_with(cx, |this, _| { + matches!( + this.state, + AddState::Path { + input_state: PathInputState::Resolving(_), + .. } - }) - .ok() - .flatten(); - let is_loading = weak - .read_with(cx, |this, _| { - matches!( - this.state, - AddState::Path { - input_state: PathInputState::Resolving(_), - .. - } - ) - }) - .unwrap_or_default(); - Some( - v_flex() - .child(Divider::horizontal()) - .child( - h_flex() - .p_1() - .justify_between() - .gap_2() - .child( - Label::new("Select Toolchain Path") - .color(Color::Muted) - .map(|this| { - if is_loading { - this.with_animation( - "select-toolchain-label", - Animation::new(Duration::from_secs(2)) - .repeat() - .with_easing(pulsating_between( - 0.4, 0.8, - )), - |label, delta| label.alpha(delta), - ) - .into_any() - } else { - this.into_any_element() - } - }), - ) - .when_some(error, |this, error| { - this.child(Label::new(error).color(Color::Error)) - }), - ) - .into_any(), - ) - })); + ) + }) + .unwrap_or_default(); + Some( + v_flex() + .child(Divider::horizontal()) + .child( + h_flex() + .p_1() + .justify_between() + .gap_2() + .child(Label::new("Select Toolchain Path").color(Color::Muted).map( + |this| { + if is_loading { + this.with_animation( + "select-toolchain-label", + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(pulsating_between(0.4, 0.8)), + |label, delta| label.alpha(delta), + ) + .into_any() + } else { + this.into_any_element() + } + }, + )) + .when_some(error, |this, error| { + this.child(Label::new(error).color(Color::Error)) + }), + ) + .into_any(), + ) + })); (lister, rx) } @@ -231,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")); @@ -266,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(()) @@ -339,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(); }); } @@ -389,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(); }, )) @@ -709,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 b6318f18c973ca5ca7eefa1ba39517ef65cad6df..c08e46c5882cf3c9e0a8e205c8b23224d3a7a8e1 100644 --- a/crates/ui/src/components.rs +++ b/crates/ui/src/components.rs @@ -1,3 +1,4 @@ +mod ai; mod avatar; mod banner; mod button; @@ -43,6 +44,7 @@ mod tree_view_item; #[cfg(feature = "stories")] mod stories; +pub use ai::*; pub use avatar::*; pub use banner::*; pub use button::*; diff --git a/crates/ui/src/components/ai.rs b/crates/ui/src/components/ai.rs new file mode 100644 index 0000000000000000000000000000000000000000..e36361b7b06559c1442b86acf26b6694bb950d82 --- /dev/null +++ b/crates/ui/src/components/ai.rs @@ -0,0 +1,3 @@ +mod configured_api_card; + +pub use configured_api_card::*; diff --git a/crates/language_models/src/ui/configured_api_card.rs b/crates/ui/src/components/ai/configured_api_card.rs similarity index 84% rename from crates/language_models/src/ui/configured_api_card.rs rename to crates/ui/src/components/ai/configured_api_card.rs index 063ac1717f3aa5de1a448e26c94df7530fec588f..37f9ac7602d676906565a911f1bbca6d2b40f755 100644 --- a/crates/language_models/src/ui/configured_api_card.rs +++ b/crates/ui/src/components/ai/configured_api_card.rs @@ -1,10 +1,11 @@ +use crate::{Tooltip, prelude::*}; use gpui::{ClickEvent, IntoElement, ParentElement, SharedString}; -use ui::{Tooltip, prelude::*}; #[derive(IntoElement)] pub struct ConfiguredApiCard { label: SharedString, button_label: Option, + button_tab_index: Option, tooltip_label: Option, disabled: bool, on_click: Option>, @@ -15,6 +16,7 @@ impl ConfiguredApiCard { Self { label: label.into(), button_label: None, + button_tab_index: None, tooltip_label: None, disabled: false, on_click: None, @@ -43,6 +45,11 @@ impl ConfiguredApiCard { self.disabled = disabled; self } + + pub fn button_tab_index(mut self, tab_index: isize) -> Self { + self.button_tab_index = Some(tab_index); + self + } } impl RenderOnce for ConfiguredApiCard { @@ -51,23 +58,27 @@ impl RenderOnce for ConfiguredApiCard { let button_id = SharedString::new(format!("id-{}", button_label)); h_flex() + .min_w_0() .mt_0p5() .p_1() .justify_between() .rounded_md() + .flex_wrap() .border_1() .border_color(cx.theme().colors().border) .bg(cx.theme().colors().background) .child( h_flex() - .flex_1() .min_w_0() .gap_1() .child(Icon::new(IconName::Check).color(Color::Success)) - .child(Label::new(self.label).truncate()), + .child(Label::new(self.label)), ) .child( Button::new(button_id, button_label) + .when_some(self.button_tab_index, |elem, tab_index| { + elem.tab_index(tab_index) + }) .label_size(LabelSize::Small) .icon(IconName::Undo) .icon_size(IconSize::Small) diff --git a/crates/languages/src/tsx/highlights-jsx.scm b/crates/ui/src/components/ai/copilot_configuration_callout.rs similarity index 100% rename from crates/languages/src/tsx/highlights-jsx.scm rename to crates/ui/src/components/ai/copilot_configuration_callout.rs diff --git a/crates/ui/src/components/button.rs b/crates/ui/src/components/button.rs index 23e7702f6241b6ca0d4074936ee20da26531fbed..d56a9c09d3b57ba607b6837b16af31d240e58663 100644 --- a/crates/ui/src/components/button.rs +++ b/crates/ui/src/components/button.rs @@ -1,12 +1,14 @@ mod button; mod button_icon; mod button_like; +mod button_link; mod icon_button; mod split_button; mod toggle_button; pub use button::*; pub use button_like::*; +pub use button_link::*; pub use icon_button::*; pub use split_button::*; pub use toggle_button::*; diff --git a/crates/ui/src/components/button/button_link.rs b/crates/ui/src/components/button/button_link.rs new file mode 100644 index 0000000000000000000000000000000000000000..caffe2772bce394be6899b1f9b3b686c3927a530 --- /dev/null +++ b/crates/ui/src/components/button/button_link.rs @@ -0,0 +1,102 @@ +use gpui::{IntoElement, Window, prelude::*}; + +use crate::{ButtonLike, prelude::*}; + +/// A button that takes an underline to look like a regular web link. +/// It also contains an arrow icon to communicate the link takes you out of Zed. +/// +/// # Usage Example +/// +/// ``` +/// use ui::ButtonLink; +/// +/// let button_link = ButtonLink::new("Click me", "https://example.com"); +/// ``` +#[derive(IntoElement, RegisterComponent)] +pub struct ButtonLink { + label: SharedString, + label_size: LabelSize, + label_color: Color, + link: String, + no_icon: bool, +} + +impl ButtonLink { + pub fn new(label: impl Into, link: impl Into) -> Self { + Self { + link: link.into(), + label: label.into(), + label_size: LabelSize::Default, + label_color: Color::Default, + no_icon: false, + } + } + + pub fn no_icon(mut self, no_icon: bool) -> Self { + self.no_icon = no_icon; + self + } + + pub fn label_size(mut self, label_size: LabelSize) -> Self { + self.label_size = label_size; + self + } + + pub fn label_color(mut self, label_color: Color) -> Self { + self.label_color = label_color; + self + } +} + +impl RenderOnce for ButtonLink { + fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { + let id = format!("{}-{}", self.label, self.link); + + ButtonLike::new(id) + .size(ButtonSize::None) + .child( + h_flex() + .gap_0p5() + .child( + Label::new(self.label) + .size(self.label_size) + .color(self.label_color) + .underline(), + ) + .when(!self.no_icon, |this| { + this.child( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::Small) + .color(Color::Muted), + ) + }), + ) + .on_click(move |_, _, cx| cx.open_url(&self.link)) + .into_any_element() + } +} + +impl Component for ButtonLink { + fn scope() -> ComponentScope { + ComponentScope::Navigation + } + + fn description() -> Option<&'static str> { + Some("A button that opens a URL.") + } + + fn preview(_window: &mut Window, _cx: &mut App) -> Option { + Some( + v_flex() + .gap_6() + .child( + example_group(vec![single_example( + "Simple", + ButtonLink::new("zed.dev", "https://zed.dev").into_any_element(), + )]) + .vertical(), + ) + .into_any_element(), + ) + } +} diff --git a/crates/ui/src/components/callout.rs b/crates/ui/src/components/callout.rs index 4eb849d7f640aca78b70645f5f93301281ca6627..de95e5db2bcee2e7acbadf5570de09d9cdedbf4d 100644 --- a/crates/ui/src/components/callout.rs +++ b/crates/ui/src/components/callout.rs @@ -121,7 +121,7 @@ impl RenderOnce for Callout { Severity::Info => ( IconName::Info, Color::Muted, - cx.theme().colors().panel_background.opacity(0.), + cx.theme().status().info_background.opacity(0.1), ), Severity::Success => ( IconName::Check, 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/divider.rs b/crates/ui/src/components/divider.rs index d6101f23203072a27febd0f8b8391af75b41d7f3..5ad2187cfae36f3cc45cbecb42f115f0742abed4 100644 --- a/crates/ui/src/components/divider.rs +++ b/crates/ui/src/components/divider.rs @@ -144,12 +144,16 @@ impl Divider { impl RenderOnce for Divider { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let base = match self.direction { - DividerDirection::Horizontal => { - div().h_px().w_full().when(self.inset, |this| this.mx_1p5()) - } - DividerDirection::Vertical => { - div().w_px().h_full().when(self.inset, |this| this.my_1p5()) - } + DividerDirection::Horizontal => div() + .min_w_0() + .h_px() + .w_full() + .when(self.inset, |this| this.mx_1p5()), + DividerDirection::Vertical => div() + .min_w_0() + .w_px() + .h_full() + .when(self.inset, |this| this.my_1p5()), }; match self.style { diff --git a/crates/ui/src/components/keybinding_hint.rs b/crates/ui/src/components/keybinding_hint.rs index c998e29f0ed6f5bccab976b11080320d4d65a7dd..7c19953ca43c907070829f7140f97a4fde495b57 100644 --- a/crates/ui/src/components/keybinding_hint.rs +++ b/crates/ui/src/components/keybinding_hint.rs @@ -234,9 +234,7 @@ impl RenderOnce for KeybindingHint { let mut base = h_flex(); - base.text_style() - .get_or_insert_with(Default::default) - .font_style = Some(FontStyle::Italic); + base.text_style().font_style = Some(FontStyle::Italic); base.gap_1() .font_buffer(cx) diff --git a/crates/ui/src/components/label/label_like.rs b/crates/ui/src/components/label/label_like.rs index 1fa6b14c83d8359df234f33ecb9318c88e3a2714..31fb7bfd88f1343ac6145c86f228bdcbd6a22e10 100644 --- a/crates/ui/src/components/label/label_like.rs +++ b/crates/ui/src/components/label/label_like.rs @@ -223,11 +223,9 @@ impl RenderOnce for LabelLike { }) .when(self.italic, |this| this.italic()) .when(self.underline, |mut this| { - this.text_style() - .get_or_insert_with(Default::default) - .underline = Some(UnderlineStyle { + this.text_style().underline = Some(UnderlineStyle { thickness: px(1.), - color: None, + color: Some(cx.theme().colors().text_muted.opacity(0.4)), wavy: false, }); this diff --git a/crates/ui/src/components/list/list_bullet_item.rs b/crates/ui/src/components/list/list_bullet_item.rs index 17731488f7139522bf19aeaab18fb395d1eb68b0..934f0853dbe18b8231e15073766b6c84c1896546 100644 --- a/crates/ui/src/components/list/list_bullet_item.rs +++ b/crates/ui/src/components/list/list_bullet_item.rs @@ -1,18 +1,33 @@ -use crate::{ListItem, prelude::*}; -use component::{Component, ComponentScope, example_group_with_title, single_example}; +use crate::{ButtonLink, ListItem, prelude::*}; +use component::{Component, ComponentScope, example_group, single_example}; use gpui::{IntoElement, ParentElement, SharedString}; #[derive(IntoElement, RegisterComponent)] pub struct ListBulletItem { label: SharedString, + label_color: Option, + children: Vec, } impl ListBulletItem { pub fn new(label: impl Into) -> Self { Self { label: label.into(), + label_color: None, + children: Vec::new(), } } + + pub fn label_color(mut self, color: Color) -> Self { + self.label_color = Some(color); + self + } +} + +impl ParentElement for ListBulletItem { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements) + } } impl RenderOnce for ListBulletItem { @@ -34,7 +49,18 @@ impl RenderOnce for ListBulletItem { .color(Color::Hidden), ), ) - .child(div().w_full().min_w_0().child(Label::new(self.label))), + .map(|this| { + if !self.children.is_empty() { + this.child(h_flex().gap_0p5().flex_wrap().children(self.children)) + } else { + this.child( + div().w_full().min_w_0().child( + Label::new(self.label) + .color(self.label_color.unwrap_or(Color::Default)), + ), + ) + } + }), ) .into_any_element() } @@ -46,37 +72,43 @@ impl Component for ListBulletItem { } fn description() -> Option<&'static str> { - Some("A list item with a bullet point indicator for unordered lists.") + Some("A list item with a dash indicator for unordered lists.") } fn preview(_window: &mut Window, _cx: &mut App) -> Option { + let basic_examples = vec![ + single_example( + "Simple", + ListBulletItem::new("First bullet item").into_any_element(), + ), + single_example( + "Multiple Lines", + v_flex() + .child(ListBulletItem::new("First item")) + .child(ListBulletItem::new("Second item")) + .child(ListBulletItem::new("Third item")) + .into_any_element(), + ), + single_example( + "Long Text", + ListBulletItem::new( + "A longer bullet item that demonstrates text wrapping behavior", + ) + .into_any_element(), + ), + single_example( + "With Link", + ListBulletItem::new("") + .child(Label::new("Create a Zed account by")) + .child(ButtonLink::new("visiting the website", "https://zed.dev")) + .into_any_element(), + ), + ]; + Some( v_flex() .gap_6() - .child(example_group_with_title( - "Bullet Items", - vec![ - single_example( - "Simple", - ListBulletItem::new("First bullet item").into_any_element(), - ), - single_example( - "Multiple Lines", - v_flex() - .child(ListBulletItem::new("First item")) - .child(ListBulletItem::new("Second item")) - .child(ListBulletItem::new("Third item")) - .into_any_element(), - ), - single_example( - "Long Text", - ListBulletItem::new( - "A longer bullet item that demonstrates text wrapping behavior", - ) - .into_any_element(), - ), - ], - )) + .child(example_group(basic_examples).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/src/components/tab_bar.rs b/crates/ui/src/components/tab_bar.rs index 5d41466e3caadf6697b3c1681a405dafa2fb3101..86598b8c6f1ab3a479313c7775405863e9e3b49b 100644 --- a/crates/ui/src/components/tab_bar.rs +++ b/crates/ui/src/components/tab_bar.rs @@ -10,6 +10,7 @@ pub struct TabBar { start_children: SmallVec<[AnyElement; 2]>, children: SmallVec<[AnyElement; 2]>, end_children: SmallVec<[AnyElement; 2]>, + pre_end_children: SmallVec<[AnyElement; 2]>, scroll_handle: Option, } @@ -20,6 +21,7 @@ impl TabBar { start_children: SmallVec::new(), children: SmallVec::new(), end_children: SmallVec::new(), + pre_end_children: SmallVec::new(), scroll_handle: None, } } @@ -70,6 +72,15 @@ impl TabBar { self } + pub fn pre_end_child(mut self, end_child: impl IntoElement) -> Self + where + Self: Sized, + { + self.pre_end_children + .push(end_child.into_element().into_any()); + self + } + pub fn end_children(mut self, end_children: impl IntoIterator) -> Self where Self: Sized, @@ -137,18 +148,32 @@ impl RenderOnce for TabBar { .children(self.children), ), ) - .when(!self.end_children.is_empty(), |this| { - this.child( - h_flex() - .flex_none() - .gap(DynamicSpacing::Base04.rems(cx)) - .px(DynamicSpacing::Base06.rems(cx)) - .border_b_1() - .border_l_1() - .border_color(cx.theme().colors().border) - .children(self.end_children), - ) - }) + .when( + !self.end_children.is_empty() || !self.pre_end_children.is_empty(), + |this| { + this.child( + h_flex() + .flex_none() + .gap(DynamicSpacing::Base04.rems(cx)) + .px(DynamicSpacing::Base06.rems(cx)) + .children(self.pre_end_children) + .border_color(cx.theme().colors().border) + .border_b_1() + .when(!self.end_children.is_empty(), |div| { + div.child( + h_flex() + .h_full() + .flex_none() + .pl(DynamicSpacing::Base04.rems(cx)) + .gap(DynamicSpacing::Base04.rems(cx)) + .border_l_1() + .border_color(cx.theme().colors().border) + .children(self.end_children), + ) + }), + ) + }, + ) } } 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/util/src/paths.rs b/crates/util/src/paths.rs index f8e3e557152a24a6be8bb4cdad3a86d2256a764e..a54f91c7a0392748cb64c984559cf1ce25c2a7d8 100644 --- a/crates/util/src/paths.rs +++ b/crates/util/src/paths.rs @@ -227,9 +227,16 @@ impl SanitizedPath { #[cfg(not(target_os = "windows"))] return unsafe { mem::transmute::, Arc>(path) }; - // TODO: could avoid allocating here if dunce::simplified results in the same path #[cfg(target_os = "windows")] - return Self::new(&path).into(); + { + let simplified = dunce::simplified(path.as_ref()); + if simplified == path.as_ref() { + // safe because `Path` and `SanitizedPath` have the same repr and Drop impl + unsafe { mem::transmute::, Arc>(path) } + } else { + Self::unchecked_new(simplified).into() + } + } } pub fn new_arc + ?Sized>(path: &T) -> Arc { diff --git a/crates/util/src/shell.rs b/crates/util/src/shell.rs index d6cf5e1d380109aa4fcfc4e55a4c469ba1903add..d51cb39aedd89908db9608f5961688d4b30afc9b 100644 --- a/crates/util/src/shell.rs +++ b/crates/util/src/shell.rs @@ -56,7 +56,10 @@ pub enum ShellKind { Tcsh, Rc, Fish, + /// Pre-installed "legacy" powershell for windows PowerShell, + /// PowerShell 7.x + Pwsh, Nushell, Cmd, Xonsh, @@ -238,6 +241,7 @@ impl fmt::Display for ShellKind { ShellKind::Tcsh => write!(f, "tcsh"), ShellKind::Fish => write!(f, "fish"), ShellKind::PowerShell => write!(f, "powershell"), + ShellKind::Pwsh => write!(f, "pwsh"), ShellKind::Nushell => write!(f, "nu"), ShellKind::Cmd => write!(f, "cmd"), ShellKind::Rc => write!(f, "rc"), @@ -260,7 +264,8 @@ impl ShellKind { .to_string_lossy(); match &*program { - "powershell" | "pwsh" => ShellKind::PowerShell, + "powershell" => ShellKind::PowerShell, + "pwsh" => ShellKind::Pwsh, "cmd" => ShellKind::Cmd, "nu" => ShellKind::Nushell, "fish" => ShellKind::Fish, @@ -279,7 +284,7 @@ impl ShellKind { pub fn to_shell_variable(self, input: &str) -> String { match self { - Self::PowerShell => Self::to_powershell_variable(input), + Self::PowerShell | Self::Pwsh => Self::to_powershell_variable(input), Self::Cmd => Self::to_cmd_variable(input), Self::Posix => input.to_owned(), Self::Fish => input.to_owned(), @@ -407,8 +412,12 @@ impl ShellKind { pub fn args_for_shell(&self, interactive: bool, combined_command: String) -> Vec { match self { - ShellKind::PowerShell => vec!["-C".to_owned(), combined_command], - ShellKind::Cmd => vec!["/C".to_owned(), combined_command], + ShellKind::PowerShell | ShellKind::Pwsh => vec!["-C".to_owned(), combined_command], + ShellKind::Cmd => vec![ + "/S".to_owned(), + "/C".to_owned(), + format!("\"{combined_command}\""), + ], ShellKind::Posix | ShellKind::Nushell | ShellKind::Fish @@ -426,7 +435,7 @@ impl ShellKind { pub const fn command_prefix(&self) -> Option { match self { - ShellKind::PowerShell => Some('&'), + ShellKind::PowerShell | ShellKind::Pwsh => Some('&'), ShellKind::Nushell => Some('^'), ShellKind::Posix | ShellKind::Csh @@ -457,6 +466,7 @@ impl ShellKind { | ShellKind::Rc | ShellKind::Fish | ShellKind::PowerShell + | ShellKind::Pwsh | ShellKind::Nushell | ShellKind::Xonsh | ShellKind::Elvish => ';', @@ -471,6 +481,7 @@ impl ShellKind { | ShellKind::Tcsh | ShellKind::Rc | ShellKind::Fish + | ShellKind::Pwsh | ShellKind::PowerShell | ShellKind::Xonsh => "&&", ShellKind::Nushell | ShellKind::Elvish => ";", @@ -478,11 +489,10 @@ impl ShellKind { } pub fn try_quote<'a>(&self, arg: &'a str) -> Option> { - shlex::try_quote(arg).ok().map(|arg| match self { - // If we are running in PowerShell, we want to take extra care when escaping strings. - // In particular, we want to escape strings with a backtick (`) rather than a backslash (\). - ShellKind::PowerShell => Cow::Owned(arg.replace("\\\"", "`\"").replace("\\\\", "\\")), - ShellKind::Cmd => Cow::Owned(arg.replace("\\\\", "\\")), + match self { + ShellKind::PowerShell => Some(Self::quote_powershell(arg)), + ShellKind::Pwsh => Some(Self::quote_pwsh(arg)), + ShellKind::Cmd => Some(Self::quote_cmd(arg)), ShellKind::Posix | ShellKind::Csh | ShellKind::Tcsh @@ -490,8 +500,173 @@ impl ShellKind { | ShellKind::Fish | ShellKind::Nushell | ShellKind::Xonsh - | ShellKind::Elvish => arg, - }) + | ShellKind::Elvish => shlex::try_quote(arg).ok(), + } + } + + fn quote_windows(arg: &str, enclose: bool) -> Cow<'_, str> { + if arg.is_empty() { + return Cow::Borrowed("\"\""); + } + + let needs_quoting = arg.chars().any(|c| c == ' ' || c == '\t' || c == '"'); + if !needs_quoting { + return Cow::Borrowed(arg); + } + + let mut result = String::with_capacity(arg.len() + 2); + + if enclose { + result.push('"'); + } + + let chars: Vec = arg.chars().collect(); + let mut i = 0; + + while i < chars.len() { + if chars[i] == '\\' { + let mut num_backslashes = 0; + while i < chars.len() && chars[i] == '\\' { + num_backslashes += 1; + i += 1; + } + + if i < chars.len() && chars[i] == '"' { + // Backslashes followed by quote: double the backslashes and escape the quote + for _ in 0..(num_backslashes * 2 + 1) { + result.push('\\'); + } + result.push('"'); + i += 1; + } else if i >= chars.len() { + // Trailing backslashes: double them (they precede the closing quote) + for _ in 0..(num_backslashes * 2) { + result.push('\\'); + } + } else { + // Backslashes not followed by quote: output as-is + for _ in 0..num_backslashes { + result.push('\\'); + } + } + } else if chars[i] == '"' { + // Quote not preceded by backslash: escape it + result.push('\\'); + result.push('"'); + i += 1; + } else { + result.push(chars[i]); + i += 1; + } + } + + if enclose { + result.push('"'); + } + Cow::Owned(result) + } + + fn needs_quoting_powershell(s: &str) -> bool { + s.is_empty() + || s.chars().any(|c| { + c.is_whitespace() + || matches!( + c, + '"' | '`' + | '$' + | '&' + | '|' + | '<' + | '>' + | ';' + | '(' + | ')' + | '[' + | ']' + | '{' + | '}' + | ',' + | '\'' + | '@' + ) + }) + } + + fn need_quotes_powershell(arg: &str) -> bool { + let mut quote_count = 0; + for c in arg.chars() { + if c == '"' { + quote_count += 1; + } else if c.is_whitespace() && (quote_count % 2 == 0) { + return true; + } + } + false + } + + fn escape_powershell_quotes(s: &str) -> String { + let mut result = String::with_capacity(s.len() + 4); + result.push('\''); + for c in s.chars() { + if c == '\'' { + result.push('\''); + } + result.push(c); + } + result.push('\''); + result + } + + pub fn quote_powershell(arg: &str) -> Cow<'_, str> { + let ps_will_quote = Self::need_quotes_powershell(arg); + let crt_quoted = Self::quote_windows(arg, !ps_will_quote); + + if !Self::needs_quoting_powershell(arg) { + return crt_quoted; + } + + Cow::Owned(Self::escape_powershell_quotes(&crt_quoted)) + } + + pub fn quote_pwsh(arg: &str) -> Cow<'_, str> { + if arg.is_empty() { + return Cow::Borrowed("''"); + } + + if !Self::needs_quoting_powershell(arg) { + return Cow::Borrowed(arg); + } + + Cow::Owned(Self::escape_powershell_quotes(arg)) + } + + pub fn quote_cmd(arg: &str) -> Cow<'_, str> { + let crt_quoted = Self::quote_windows(arg, true); + + let needs_cmd_escaping = crt_quoted.contains('"') + || crt_quoted.contains('%') + || crt_quoted + .chars() + .any(|c| matches!(c, '^' | '<' | '>' | '&' | '|' | '(' | ')')); + + if !needs_cmd_escaping { + return crt_quoted; + } + + let mut result = String::with_capacity(crt_quoted.len() * 2); + for c in crt_quoted.chars() { + match c { + '^' | '"' | '<' | '>' | '&' | '|' | '(' | ')' => { + result.push('^'); + result.push(c); + } + '%' => { + result.push_str("%%cd:~,%"); + } + _ => result.push(c), + } + } + Cow::Owned(result) } /// Quotes the given argument if necessary, taking into account the command prefix. @@ -527,7 +702,10 @@ impl ShellKind { .map(|quoted| Cow::Owned(self.prepend_command_prefix("ed).into_owned())); } } - self.try_quote(arg) + self.try_quote(arg).map(|quoted| match quoted { + unquoted @ Cow::Borrowed(_) => unquoted, + Cow::Owned(quoted) => Cow::Owned(self.prepend_command_prefix("ed).into_owned()), + }) } pub fn split(&self, input: &str) -> Option> { @@ -538,7 +716,7 @@ impl ShellKind { match self { ShellKind::Cmd => "", ShellKind::Nushell => "overlay use", - ShellKind::PowerShell => ".", + ShellKind::PowerShell | ShellKind::Pwsh => ".", ShellKind::Fish | ShellKind::Csh | ShellKind::Tcsh @@ -558,6 +736,7 @@ impl ShellKind { | ShellKind::Rc | ShellKind::Fish | ShellKind::PowerShell + | ShellKind::Pwsh | ShellKind::Nushell | ShellKind::Xonsh | ShellKind::Elvish => "clear", @@ -576,6 +755,7 @@ impl ShellKind { | ShellKind::Rc | ShellKind::Fish | ShellKind::PowerShell + | ShellKind::Pwsh | ShellKind::Nushell | ShellKind::Xonsh | ShellKind::Elvish => true, @@ -605,7 +785,7 @@ mod tests { .try_quote("C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \"test_foo.py::test_foo\"") .unwrap() .into_owned(), - "\"C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest `\"test_foo.py::test_foo`\"\"".to_string() + "'C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \\\"test_foo.py::test_foo\\\"'".to_string() ); } @@ -617,7 +797,113 @@ mod tests { .try_quote("C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \"test_foo.py::test_foo\"") .unwrap() .into_owned(), - "\"C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \\\"test_foo.py::test_foo\\\"\"".to_string() + "^\"C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \\^\"test_foo.py::test_foo\\^\"^\"".to_string() + ); + } + + #[test] + fn test_try_quote_powershell_edge_cases() { + let shell_kind = ShellKind::PowerShell; + + // Empty string + assert_eq!( + shell_kind.try_quote("").unwrap().into_owned(), + "'\"\"'".to_string() + ); + + // String without special characters (no quoting needed) + assert_eq!(shell_kind.try_quote("simple").unwrap(), "simple"); + + // String with spaces + assert_eq!( + shell_kind.try_quote("hello world").unwrap().into_owned(), + "'hello world'".to_string() + ); + + // String with dollar signs + assert_eq!( + shell_kind.try_quote("$variable").unwrap().into_owned(), + "'$variable'".to_string() + ); + + // String with backticks + assert_eq!( + shell_kind.try_quote("test`command").unwrap().into_owned(), + "'test`command'".to_string() + ); + + // String with multiple special characters + assert_eq!( + shell_kind + .try_quote("test `\"$var`\" end") + .unwrap() + .into_owned(), + "'test `\\\"$var`\\\" end'".to_string() + ); + + // String with backslashes and colon (path without spaces doesn't need quoting) + assert_eq!( + shell_kind.try_quote("C:\\path\\to\\file").unwrap(), + "C:\\path\\to\\file" + ); + } + + #[test] + fn test_try_quote_cmd_edge_cases() { + let shell_kind = ShellKind::Cmd; + + // Empty string + assert_eq!( + shell_kind.try_quote("").unwrap().into_owned(), + "^\"^\"".to_string() + ); + + // String without special characters (no quoting needed) + assert_eq!(shell_kind.try_quote("simple").unwrap(), "simple"); + + // String with spaces + assert_eq!( + shell_kind.try_quote("hello world").unwrap().into_owned(), + "^\"hello world^\"".to_string() + ); + + // String with space and backslash (backslash not at end, so not doubled) + assert_eq!( + shell_kind.try_quote("path\\ test").unwrap().into_owned(), + "^\"path\\ test^\"".to_string() + ); + + // String ending with backslash (must be doubled before closing quote) + assert_eq!( + shell_kind.try_quote("test path\\").unwrap().into_owned(), + "^\"test path\\\\^\"".to_string() + ); + + // String ending with multiple backslashes (all doubled before closing quote) + assert_eq!( + shell_kind.try_quote("test path\\\\").unwrap().into_owned(), + "^\"test path\\\\\\\\^\"".to_string() + ); + + // String with embedded quote (quote is escaped, backslash before it is doubled) + assert_eq!( + shell_kind.try_quote("test\\\"quote").unwrap().into_owned(), + "^\"test\\\\\\^\"quote^\"".to_string() + ); + + // String with multiple backslashes before embedded quote (all doubled) + assert_eq!( + shell_kind + .try_quote("test\\\\\"quote") + .unwrap() + .into_owned(), + "^\"test\\\\\\\\\\^\"quote^\"".to_string() + ); + + // String with backslashes not before quotes (path without spaces doesn't need quoting) + assert_eq!( + shell_kind.try_quote("C:\\path\\to\\file").unwrap(), + "C:\\path\\to\\file" ); } @@ -633,7 +919,7 @@ mod tests { .try_quote_prefix_aware("'uname'") .unwrap() .into_owned(), - "\"'uname'\"".to_string() + "^\"'uname'\"".to_string() ); assert_eq!( shell_kind.try_quote("^uname").unwrap().into_owned(), @@ -666,7 +952,7 @@ mod tests { .try_quote_prefix_aware("'uname a'") .unwrap() .into_owned(), - "\"'uname a'\"".to_string() + "^\"'uname a'\"".to_string() ); assert_eq!( shell_kind.try_quote("^'uname a'").unwrap().into_owned(), diff --git a/crates/util/src/shell_builder.rs b/crates/util/src/shell_builder.rs index a4a0d21018447d229a6a95c4bf897804b5d6eaf9..436c07172368793e685d1ba4b1014ac38be13b73 100644 --- a/crates/util/src/shell_builder.rs +++ b/crates/util/src/shell_builder.rs @@ -1,3 +1,5 @@ +use std::borrow::Cow; + use crate::shell::get_system_shell; use crate::shell::{Shell, ShellKind}; @@ -42,7 +44,7 @@ impl ShellBuilder { self.program.clone() } else { match self.kind { - ShellKind::PowerShell => { + ShellKind::PowerShell | ShellKind::Pwsh => { format!("{} -C '{}'", self.program, command_to_use_in_label) } ShellKind::Cmd => { @@ -76,6 +78,64 @@ impl ShellBuilder { mut self, task_command: Option, task_args: &[String], + ) -> (String, Vec) { + if let Some(task_command) = task_command { + let task_command = if !task_args.is_empty() { + match self.kind.try_quote_prefix_aware(&task_command) { + Some(task_command) => task_command.into_owned(), + None => task_command, + } + } else { + task_command + }; + let mut combined_command = task_args.iter().fold(task_command, |mut command, arg| { + command.push(' '); + let shell_variable = self.kind.to_shell_variable(arg); + command.push_str(&match self.kind.try_quote(&shell_variable) { + Some(shell_variable) => shell_variable, + None => Cow::Owned(shell_variable), + }); + command + }); + if self.redirect_stdin { + match self.kind { + ShellKind::Fish => { + combined_command.insert_str(0, "begin; "); + combined_command.push_str("; end { + combined_command.insert(0, '('); + combined_command.push_str(") { + combined_command.insert_str(0, "$null | & {"); + combined_command.push_str("}"); + } + ShellKind::Cmd => { + combined_command.push_str("< NUL"); + } + } + } + + self.args + .extend(self.kind.args_for_shell(self.interactive, combined_command)); + } + + (self.program, self.args) + } + + // This should not exist, but our task infra is broken beyond repair right now + #[doc(hidden)] + pub fn build_no_quote( + mut self, + task_command: Option, + task_args: &[String], ) -> (String, Vec) { if let Some(task_command) = task_command { let mut combined_command = task_args.iter().fold(task_command, |mut command, arg| { @@ -99,7 +159,7 @@ impl ShellBuilder { combined_command.insert(0, '('); combined_command.push_str(") { + ShellKind::PowerShell | ShellKind::Pwsh => { combined_command.insert_str(0, "$null | & {"); combined_command.push_str("}"); } @@ -115,6 +175,48 @@ impl ShellBuilder { (self.program, self.args) } + + /// Builds a command with the given task command and arguments. + /// + /// Prefer this over manually constructing a command with the output of `Self::build`, + /// as this method handles `cmd` weirdness on windows correctly. + pub fn build_command( + self, + mut task_command: Option, + task_args: &[String], + ) -> smol::process::Command { + #[cfg(windows)] + let kind = self.kind; + if task_args.is_empty() { + task_command = task_command + .as_ref() + .map(|cmd| self.kind.try_quote_prefix_aware(&cmd).map(Cow::into_owned)) + .unwrap_or(task_command); + } + let (program, args) = self.build(task_command, task_args); + + let mut child = crate::command::new_smol_command(program); + + #[cfg(windows)] + if kind == ShellKind::Cmd { + use smol::process::windows::CommandExt; + + for arg in args { + child.raw_arg(arg); + } + } else { + child.args(args); + } + + #[cfg(not(windows))] + child.args(args); + + child + } + + pub fn kind(&self) -> ShellKind { + self.kind + } } #[cfg(test)] @@ -144,7 +246,7 @@ mod test { vec![ "-i", "-c", - "echo $env.hello $env.world nothing --($env.something) $ ${test" + "echo '$env.hello' '$env.world' nothing '--($env.something)' '$' '${test'" ] ); } @@ -174,4 +276,23 @@ mod test { assert_eq!(program, "fish"); assert_eq!(args, vec!["-i", "-c", "begin; echo test; end Result> { use std::process::Stdio; @@ -141,17 +141,17 @@ async fn capture_windows( std::env::current_exe().context("Failed to determine current zed executable path.")?; let shell_kind = ShellKind::new(shell_path, true); - if let ShellKind::Csh | ShellKind::Tcsh | ShellKind::Rc | ShellKind::Fish | ShellKind::Xonsh = - shell_kind - { - return Err(anyhow::anyhow!("unsupported shell kind")); - } let mut cmd = crate::command::new_smol_command(shell_path); + cmd.args(args); let cmd = match shell_kind { - ShellKind::Csh | ShellKind::Tcsh | ShellKind::Rc | ShellKind::Fish | ShellKind::Xonsh => { - unreachable!() - } - ShellKind::Posix => cmd.args([ + ShellKind::Csh + | ShellKind::Tcsh + | ShellKind::Rc + | ShellKind::Fish + | ShellKind::Xonsh + | ShellKind::Posix => cmd.args([ + "-l", + "-i", "-c", &format!( "cd '{}'; '{}' --printenv", @@ -159,7 +159,7 @@ async fn capture_windows( zed_path.display() ), ]), - ShellKind::PowerShell => cmd.args([ + ShellKind::PowerShell | ShellKind::Pwsh => cmd.args([ "-NonInteractive", "-NoProfile", "-Command", diff --git a/crates/vim/Cargo.toml b/crates/vim/Cargo.toml index a5834f855034ef97b7d0d34b01a7d13f50369a1e..2db1b51e72fcd862ccb1c35ff920fec7dbd47995 100644 --- a/crates/vim/Cargo.toml +++ b/crates/vim/Cargo.toml @@ -63,8 +63,10 @@ indoc.workspace = true language = { workspace = true, features = ["test-support"] } project = { workspace = true, features = ["test-support"] } lsp = { workspace = true, features = ["test-support"] } +markdown_preview.workspace = true parking_lot.workspace = true project_panel.workspace = true +outline_panel.workspace = true release_channel.workspace = true semver.workspace = true settings_ui.workspace = true diff --git a/crates/vim/src/command.rs b/crates/vim/src/command.rs index 5bf0fca041cf274f38c84031e35903c9e339cc24..205097130d152fe255feb02a449956124586d8e6 100644 --- a/crates/vim/src/command.rs +++ b/crates/vim/src/command.rs @@ -330,10 +330,12 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { let Some(range) = range.buffer_range(vim, editor, window, cx).ok() else { return; }; - let Some((line_ending, text, whole_buffer)) = editor.buffer().update(cx, |multi, cx| { + let Some((line_ending, encoding, has_bom, text, whole_buffer)) = editor.buffer().update(cx, |multi, cx| { Some(multi.as_singleton()?.update(cx, |buffer, _| { ( buffer.line_ending(), + buffer.encoding(), + buffer.has_bom(), buffer.as_rope().slice_rows(range.start.0..range.end.0 + 1), range.start.0 == 0 && range.end.0 + 1 >= buffer.row_count(), ) @@ -429,7 +431,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { return; }; worktree - .write_file(path.into_arc(), text.clone(), line_ending, cx) + .write_file(path.into_arc(), text.clone(), line_ending, encoding, has_bom, cx) .detach_and_prompt_err("Failed to write lines", window, cx, |_, _, _| None); }); }) diff --git a/crates/vim/src/helix.rs b/crates/vim/src/helix.rs index fae2bda578c6844c33290d059248b895ebde4c3d..f902a8ff6e9f08475fb6ce8323a924730d3621d1 100644 --- a/crates/vim/src/helix.rs +++ b/crates/vim/src/helix.rs @@ -1389,11 +1389,12 @@ mod test { Mode::HelixNormal, ); cx.simulate_keystrokes("x"); + // Adjacent line selections stay separate (not merged) cx.assert_state( indoc! {" «line one line two - line three + ˇ»«line three line four ˇ»line five"}, Mode::HelixNormal, 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/normal/mark.rs b/crates/vim/src/normal/mark.rs index 3bb040511fdd7fa53dd97198ae02b492b0e7359d..a4d85e87b24fa6e2753f0dbcfcbb43be9488f41a 100644 --- a/crates/vim/src/normal/mark.rs +++ b/crates/vim/src/normal/mark.rs @@ -372,9 +372,12 @@ pub fn jump_motion( #[cfg(test)] mod test { + use crate::test::{NeovimBackedTestContext, VimTestContext}; + use editor::Editor; use gpui::TestAppContext; - - use crate::test::NeovimBackedTestContext; + use std::path::Path; + use util::path; + use workspace::{CloseActiveItem, OpenOptions}; #[gpui::test] async fn test_quote_mark(cx: &mut TestAppContext) { @@ -394,4 +397,69 @@ mod test { cx.simulate_shared_keystrokes("^ ` `").await; cx.shared_state().await.assert_eq("Hello, worldˇ!"); } + + #[gpui::test] + async fn test_global_mark_overwrite(cx: &mut TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + let path = Path::new(path!("/first.rs")); + let fs = cx.workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone()); + fs.as_fake().insert_file(path, "one".into()).await; + let path = Path::new(path!("/second.rs")); + fs.as_fake().insert_file(path, "two".into()).await; + + let _ = cx + .workspace(|workspace, window, cx| { + workspace.open_abs_path( + path!("/first.rs").into(), + OpenOptions::default(), + window, + cx, + ) + }) + .await; + + cx.simulate_keystrokes("m A"); + + let _ = cx + .workspace(|workspace, window, cx| { + workspace.open_abs_path( + path!("/second.rs").into(), + OpenOptions::default(), + window, + cx, + ) + }) + .await; + + cx.simulate_keystrokes("m A"); + + let _ = cx + .workspace(|workspace, window, cx| { + workspace.active_pane().update(cx, |pane, cx| { + pane.close_active_item(&CloseActiveItem::default(), window, cx) + }) + }) + .await; + + cx.simulate_keystrokes("m B"); + + cx.simulate_keystrokes("' A"); + + cx.workspace(|workspace, _, cx| { + let active_editor = workspace.active_item_as::(cx).unwrap(); + + let buffer = active_editor + .read(cx) + .buffer() + .read(cx) + .as_singleton() + .unwrap(); + + let file = buffer.read(cx).file().unwrap(); + let file_path = file.as_local().unwrap().abs_path(cx); + + assert_eq!(file_path.to_str().unwrap(), path!("/second.rs")); + }) + } } diff --git a/crates/vim/src/normal/paste.rs b/crates/vim/src/normal/paste.rs index 978c882f059e1f4cf40089de4a4af746d8526b54..82af828deb85e6e0ef36ea2853a251547051feed 100644 --- a/crates/vim/src/normal/paste.rs +++ b/crates/vim/src/normal/paste.rs @@ -773,6 +773,52 @@ mod test { "}); } + #[gpui::test] + async fn test_paste_system_clipboard_never(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.update_global(|store: &mut SettingsStore, cx| { + store.update_user_settings(cx, |s| { + s.vim.get_or_insert_default().use_system_clipboard = Some(UseSystemClipboard::Never) + }); + }); + + cx.set_state( + indoc! {" + ˇThe quick brown + fox jumps over + the lazy dog"}, + Mode::Normal, + ); + + cx.write_to_clipboard(ClipboardItem::new_string("something else".to_string())); + + cx.simulate_keystrokes("d d"); + cx.assert_state( + indoc! {" + ˇfox jumps over + the lazy dog"}, + Mode::Normal, + ); + + cx.simulate_keystrokes("shift-v p"); + cx.assert_state( + indoc! {" + ˇThe quick brown + the lazy dog"}, + Mode::Normal, + ); + + cx.simulate_keystrokes("shift-v"); + cx.dispatch_action(editor::actions::Paste); + cx.assert_state( + indoc! {" + ˇsomething else + the lazy dog"}, + Mode::Normal, + ); + } + #[gpui::test] async fn test_numbered_registers(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; diff --git a/crates/vim/src/normal/scroll.rs b/crates/vim/src/normal/scroll.rs index ff884e3b7393b39b86114338fe2af11e384e1fa0..73209c88735a59bb2dc5c2b73bb3ba0c7d03dd56 100644 --- a/crates/vim/src/normal/scroll.rs +++ b/crates/vim/src/normal/scroll.rs @@ -294,11 +294,10 @@ mod test { async fn test_scroll(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; - let (line_height, visible_line_count) = cx.editor(|editor, window, _cx| { + let (line_height, visible_line_count) = cx.update_editor(|editor, window, cx| { ( editor - .style() - .unwrap() + .style(cx) .text .line_height_in_pixels(window.rem_size()), editor.visible_line_count().unwrap(), 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/vim/src/state.rs b/crates/vim/src/state.rs index e96fd3a329e95311eeb73b87b53acbe76939f0cd..2a8aa91063be89ebd616a2f9601f90c912cee8b5 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -550,6 +550,10 @@ impl MarksState { let buffer = multibuffer.read(cx).as_singleton(); let abs_path = buffer.as_ref().and_then(|b| self.path_for_buffer(b, cx)); + if self.is_global_mark(&name) && self.global_marks.contains_key(&name) { + self.delete_mark(name.clone(), multibuffer, cx); + } + let Some(abs_path) = abs_path else { self.multibuffer_marks .entry(multibuffer.entity_id()) @@ -573,7 +577,7 @@ impl MarksState { let buffer_id = buffer.read(cx).remote_id(); self.buffer_marks.entry(buffer_id).or_default().insert( - name, + name.clone(), anchors .into_iter() .map(|anchor| anchor.text_anchor) @@ -582,6 +586,10 @@ impl MarksState { if !self.watched_buffers.contains_key(&buffer_id) { self.watch_buffer(MarkLocation::Path(abs_path.clone()), &buffer, cx) } + if self.is_global_mark(&name) { + self.global_marks + .insert(name, MarkLocation::Path(abs_path.clone())); + } self.serialize_buffer_marks(abs_path, &buffer, cx) } diff --git a/crates/vim/src/test.rs b/crates/vim/src/test.rs index 4294b5e1dbdf1a287909bd3ab5770dfcd718f98d..4c61479157268e4f0276bddf9dd1eb913284d27e 100644 --- a/crates/vim/src/test.rs +++ b/crates/vim/src/test.rs @@ -2399,7 +2399,7 @@ async fn test_clipping_on_mode_change(cx: &mut gpui::TestAppContext) { .end; editor.last_bounds().unwrap().origin + editor - .display_to_pixel_point(current_head, &snapshot, window) + .display_to_pixel_point(current_head, &snapshot, window, cx) .unwrap() }); pixel_position.x += px(100.); diff --git a/crates/vim/src/test/neovim_backed_test_context.rs b/crates/vim/src/test/neovim_backed_test_context.rs index 21cdda111c4fdacaf0871dd087bca01de6f83957..d20464ccc4b36c8f7024db6bd63558a6292e7c68 100644 --- a/crates/vim/src/test/neovim_backed_test_context.rs +++ b/crates/vim/src/test/neovim_backed_test_context.rs @@ -304,11 +304,10 @@ impl NeovimBackedTestContext { self.neovim.set_option(&format!("scrolloff={}", 3)).await; // +2 to account for the vim command UI at the bottom. self.neovim.set_option(&format!("lines={}", rows + 2)).await; - let (line_height, visible_line_count) = self.editor(|editor, window, _cx| { + let (line_height, visible_line_count) = self.update_editor(|editor, window, cx| { ( editor - .style() - .unwrap() + .style(cx) .text .line_height_in_pixels(window.rem_size()), editor.visible_line_count().unwrap(), diff --git a/crates/vim/src/test/vim_test_context.rs b/crates/vim/src/test/vim_test_context.rs index 80208fb23ee229c4dc90a7d792ce0348f59ed950..2d5ed4227dcc263f56cfa0bcb337f5673df8ef3c 100644 --- a/crates/vim/src/test/vim_test_context.rs +++ b/crates/vim/src/test/vim_test_context.rs @@ -23,11 +23,13 @@ impl VimTestContext { release_channel::init(Version::new(0, 0, 0), cx); command_palette::init(cx); project_panel::init(cx); + outline_panel::init(cx); git_ui::init(cx); crate::init(cx); search::init(cx); theme::init(theme::LoadThemes::JustBase, cx); settings_ui::init(cx); + markdown_preview::init(cx); }); } diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index 1ffcf7e2224341affc7498032fd5a181e256943d..26fec968fb261fbb80a9f84211357623147ca0f4 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -924,6 +924,7 @@ impl Vim { |vim, _: &editor::actions::Paste, window, cx| match vim.mode { Mode::Replace => vim.paste_replace(window, cx), Mode::Visual | Mode::VisualLine | Mode::VisualBlock => { + vim.selected_register.replace('+'); vim.paste(&VimPaste::default(), window, cx); } _ => { @@ -1942,6 +1943,7 @@ impl Vim { editor.set_collapse_matches(collapse_matches); editor.set_input_enabled(vim.editor_input_enabled()); editor.set_autoindent(vim.should_autoindent()); + editor.set_cursor_offset_on_selection(vim.mode.is_visual()); editor .selections .set_line_mode(matches!(vim.mode, Mode::VisualLine)); diff --git a/crates/which_key/Cargo.toml b/crates/which_key/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..f53ba45dd71abc972ce23efb8871f485dfe47207 --- /dev/null +++ b/crates/which_key/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "which_key" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/which_key.rs" +doctest = false + +[dependencies] +command_palette.workspace = true +gpui.workspace = true +serde.workspace = true +settings.workspace = true +theme.workspace = true +ui.workspace = true +util.workspace = true +workspace.workspace = true diff --git a/crates/which_key/LICENSE-GPL b/crates/which_key/LICENSE-GPL new file mode 120000 index 0000000000000000000000000000000000000000..89e542f750cd3860a0598eff0dc34b56d7336dc4 --- /dev/null +++ b/crates/which_key/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/which_key/src/which_key.rs b/crates/which_key/src/which_key.rs new file mode 100644 index 0000000000000000000000000000000000000000..70889c100f33020a3ceaa8af1ba8812d5e7d4adb --- /dev/null +++ b/crates/which_key/src/which_key.rs @@ -0,0 +1,98 @@ +//! Which-key support for Zed. + +mod which_key_modal; +mod which_key_settings; + +use gpui::{App, Keystroke}; +use settings::Settings; +use std::{sync::LazyLock, time::Duration}; +use util::ResultExt; +use which_key_modal::WhichKeyModal; +use which_key_settings::WhichKeySettings; +use workspace::Workspace; + +pub fn init(cx: &mut App) { + WhichKeySettings::register(cx); + + cx.observe_new(|_: &mut Workspace, window, cx| { + let Some(window) = window else { + return; + }; + let mut timer = None; + cx.observe_pending_input(window, move |workspace, window, cx| { + if window.pending_input_keystrokes().is_none() { + if let Some(modal) = workspace.active_modal::(cx) { + modal.update(cx, |modal, cx| modal.dismiss(cx)); + }; + timer.take(); + return; + } + + let which_key_settings = WhichKeySettings::get_global(cx); + if !which_key_settings.enabled { + return; + } + + let delay_ms = which_key_settings.delay_ms; + + timer.replace(cx.spawn_in(window, async move |workspace_handle, cx| { + cx.background_executor() + .timer(Duration::from_millis(delay_ms)) + .await; + workspace_handle + .update_in(cx, |workspace, window, cx| { + if workspace.active_modal::(cx).is_some() { + return; + }; + + workspace.toggle_modal(window, cx, |window, cx| { + WhichKeyModal::new(workspace_handle.clone(), window, cx) + }); + }) + .log_err(); + })); + }) + .detach(); + }) + .detach(); +} + +// Hard-coded list of keystrokes to filter out from which-key display +pub static FILTERED_KEYSTROKES: LazyLock>> = LazyLock::new(|| { + [ + // Modifiers on normal vim commands + "g h", + "g j", + "g k", + "g l", + "g $", + "g ^", + // Duplicate keys with "ctrl" held, e.g. "ctrl-w ctrl-a" is duplicate of "ctrl-w a" + "ctrl-w ctrl-a", + "ctrl-w ctrl-c", + "ctrl-w ctrl-h", + "ctrl-w ctrl-j", + "ctrl-w ctrl-k", + "ctrl-w ctrl-l", + "ctrl-w ctrl-n", + "ctrl-w ctrl-o", + "ctrl-w ctrl-p", + "ctrl-w ctrl-q", + "ctrl-w ctrl-s", + "ctrl-w ctrl-v", + "ctrl-w ctrl-w", + "ctrl-w ctrl-]", + "ctrl-w ctrl-shift-w", + "ctrl-w ctrl-g t", + "ctrl-w ctrl-g shift-t", + ] + .iter() + .filter_map(|s| { + let keystrokes: Result, _> = s + .split(' ') + .map(|keystroke_str| Keystroke::parse(keystroke_str)) + .collect(); + keystrokes.ok() + }) + .collect() +}); diff --git a/crates/which_key/src/which_key_modal.rs b/crates/which_key/src/which_key_modal.rs new file mode 100644 index 0000000000000000000000000000000000000000..238431b90a8eafdd0e085a3f109e8f812fbe709b --- /dev/null +++ b/crates/which_key/src/which_key_modal.rs @@ -0,0 +1,308 @@ +//! Modal implementation for the which-key display. + +use gpui::prelude::FluentBuilder; +use gpui::{ + App, Context, DismissEvent, EventEmitter, FocusHandle, Focusable, FontWeight, Keystroke, + ScrollHandle, Subscription, WeakEntity, Window, +}; +use settings::Settings; +use std::collections::HashMap; +use theme::ThemeSettings; +use ui::{ + Divider, DividerColor, DynamicSpacing, LabelSize, WithScrollbar, prelude::*, + text_for_keystrokes, +}; +use workspace::{ModalView, Workspace}; + +use crate::FILTERED_KEYSTROKES; + +pub struct WhichKeyModal { + _workspace: WeakEntity, + focus_handle: FocusHandle, + scroll_handle: ScrollHandle, + bindings: Vec<(SharedString, SharedString)>, + pending_keys: SharedString, + _pending_input_subscription: Subscription, + _focus_out_subscription: Subscription, +} + +impl WhichKeyModal { + pub fn new( + workspace: WeakEntity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + // Keep focus where it currently is + let focus_handle = window.focused(cx).unwrap_or(cx.focus_handle()); + + let handle = cx.weak_entity(); + let mut this = Self { + _workspace: workspace, + focus_handle: focus_handle.clone(), + scroll_handle: ScrollHandle::new(), + bindings: Vec::new(), + pending_keys: SharedString::new_static(""), + _pending_input_subscription: cx.observe_pending_input( + window, + |this: &mut Self, window, cx| { + this.update_pending_keys(window, cx); + }, + ), + _focus_out_subscription: window.on_focus_out(&focus_handle, cx, move |_, _, cx| { + handle.update(cx, |_, cx| cx.emit(DismissEvent)).ok(); + }), + }; + this.update_pending_keys(window, cx); + this + } + + pub fn dismiss(&self, cx: &mut Context) { + cx.emit(DismissEvent) + } + + fn update_pending_keys(&mut self, window: &mut Window, cx: &mut Context) { + let Some(pending_keys) = window.pending_input_keystrokes() else { + cx.emit(DismissEvent); + return; + }; + let bindings = window.possible_bindings_for_input(pending_keys); + + let mut binding_data = bindings + .iter() + .map(|binding| { + // Map to keystrokes + ( + binding + .keystrokes() + .iter() + .map(|k| k.inner().to_owned()) + .collect::>(), + binding.action(), + ) + }) + .filter(|(keystrokes, _action)| { + // Check if this binding matches any filtered keystroke pattern + !FILTERED_KEYSTROKES.iter().any(|filtered| { + keystrokes.len() >= filtered.len() + && keystrokes[..filtered.len()] == filtered[..] + }) + }) + .map(|(keystrokes, action)| { + // Map to remaining keystrokes and action name + let remaining_keystrokes = keystrokes[pending_keys.len()..].to_vec(); + let action_name: SharedString = + command_palette::humanize_action_name(action.name()).into(); + (remaining_keystrokes, action_name) + }) + .collect(); + + binding_data = group_bindings(binding_data); + + // Sort bindings from shortest to longest, with groups last + // Using stable sort to preserve relative order of equal elements + binding_data.sort_by(|(keystrokes_a, action_a), (keystrokes_b, action_b)| { + // Groups (actions starting with "+") should go last + let is_group_a = action_a.starts_with('+'); + let is_group_b = action_b.starts_with('+'); + + // First, separate groups from non-groups + let group_cmp = is_group_a.cmp(&is_group_b); + if group_cmp != std::cmp::Ordering::Equal { + return group_cmp; + } + + // Then sort by keystroke count + let keystroke_cmp = keystrokes_a.len().cmp(&keystrokes_b.len()); + if keystroke_cmp != std::cmp::Ordering::Equal { + return keystroke_cmp; + } + + // Finally sort by text length, then lexicographically for full stability + let text_a = text_for_keystrokes(keystrokes_a, cx); + let text_b = text_for_keystrokes(keystrokes_b, cx); + let text_len_cmp = text_a.len().cmp(&text_b.len()); + if text_len_cmp != std::cmp::Ordering::Equal { + return text_len_cmp; + } + text_a.cmp(&text_b) + }); + binding_data.dedup(); + self.pending_keys = text_for_keystrokes(&pending_keys, cx).into(); + self.bindings = binding_data + .into_iter() + .map(|(keystrokes, action)| (text_for_keystrokes(&keystrokes, cx).into(), action)) + .collect(); + } +} + +impl Render for WhichKeyModal { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let has_rows = !self.bindings.is_empty(); + let viewport_size = window.viewport_size(); + + let max_panel_width = px((f32::from(viewport_size.width) * 0.5).min(480.0)); + let max_content_height = px(f32::from(viewport_size.height) * 0.4); + + // Push above status bar when visible + let status_height = self + ._workspace + .upgrade() + .and_then(|workspace| { + workspace.read_with(cx, |workspace, cx| { + if workspace.status_bar_visible(cx) { + Some( + DynamicSpacing::Base04.px(cx) * 2.0 + + ThemeSettings::get_global(cx).ui_font_size(cx), + ) + } else { + None + } + }) + }) + .unwrap_or(px(0.)); + + let margin_bottom = px(16.); + let bottom_offset = margin_bottom + status_height; + + // Title section + let title_section = { + let mut column = v_flex().gap(px(0.)).child( + div() + .child( + Label::new(self.pending_keys.clone()) + .size(LabelSize::Default) + .weight(FontWeight::MEDIUM) + .color(Color::Accent), + ) + .mb(px(2.)), + ); + + if has_rows { + column = column.child( + div() + .child(Divider::horizontal().color(DividerColor::BorderFaded)) + .mb(px(2.)), + ); + } + + column + }; + + let content = h_flex() + .items_start() + .id("which-key-content") + .gap(px(8.)) + .overflow_y_scroll() + .track_scroll(&self.scroll_handle) + .h_full() + .max_h(max_content_height) + .child( + // Keystrokes column + v_flex() + .gap(px(4.)) + .flex_shrink_0() + .children(self.bindings.iter().map(|(keystrokes, _)| { + div() + .child( + Label::new(keystrokes.clone()) + .size(LabelSize::Default) + .color(Color::Accent), + ) + .text_align(gpui::TextAlign::Right) + })), + ) + .child( + // Actions column + v_flex() + .gap(px(4.)) + .flex_1() + .min_w_0() + .children(self.bindings.iter().map(|(_, action_name)| { + let is_group = action_name.starts_with('+'); + let label_color = if is_group { + Color::Success + } else { + Color::Default + }; + + div().child( + Label::new(action_name.clone()) + .size(LabelSize::Default) + .color(label_color) + .single_line() + .truncate(), + ) + })), + ); + + div() + .id("which-key-buffer-panel-scroll") + .occlude() + .absolute() + .bottom(bottom_offset) + .right(px(16.)) + .min_w(px(220.)) + .max_w(max_panel_width) + .elevation_3(cx) + .px(px(12.)) + .child(v_flex().child(title_section).when(has_rows, |el| { + el.child( + div() + .max_h(max_content_height) + .child(content) + .vertical_scrollbar_for(&self.scroll_handle, window, cx), + ) + })) + } +} + +impl EventEmitter for WhichKeyModal {} + +impl Focusable for WhichKeyModal { + fn focus_handle(&self, _cx: &App) -> gpui::FocusHandle { + self.focus_handle.clone() + } +} + +impl ModalView for WhichKeyModal { + fn render_bare(&self) -> bool { + true + } +} + +fn group_bindings( + binding_data: Vec<(Vec, SharedString)>, +) -> Vec<(Vec, SharedString)> { + let mut groups: HashMap, Vec<(Vec, SharedString)>> = + HashMap::new(); + + // Group bindings by their first keystroke + for (remaining_keystrokes, action_name) in binding_data { + let first_key = remaining_keystrokes.first().cloned(); + groups + .entry(first_key) + .or_default() + .push((remaining_keystrokes, action_name)); + } + + let mut result = Vec::new(); + + for (first_key, mut group_bindings) in groups { + // Remove duplicates within each group + group_bindings.dedup_by_key(|(keystrokes, _)| keystrokes.clone()); + + if let Some(first_key) = first_key + && group_bindings.len() > 1 + { + // This is a group - create a single entry with just the first keystroke + let first_keystroke = vec![first_key]; + let count = group_bindings.len(); + result.push((first_keystroke, format!("+{} keybinds", count).into())); + } else { + // Not a group or empty keystrokes - add all bindings as-is + result.append(&mut group_bindings); + } + } + + result +} diff --git a/crates/which_key/src/which_key_settings.rs b/crates/which_key/src/which_key_settings.rs new file mode 100644 index 0000000000000000000000000000000000000000..be19ab1521f4793305efca79b7026f79fd9064e2 --- /dev/null +++ b/crates/which_key/src/which_key_settings.rs @@ -0,0 +1,18 @@ +use settings::{RegisterSetting, Settings, SettingsContent, WhichKeySettingsContent}; + +#[derive(Debug, Clone, Copy, RegisterSetting)] +pub struct WhichKeySettings { + pub enabled: bool, + pub delay_ms: u64, +} + +impl Settings for WhichKeySettings { + fn from_settings(content: &SettingsContent) -> Self { + let which_key: &WhichKeySettingsContent = content.which_key.as_ref().unwrap(); + + Self { + enabled: which_key.enabled.unwrap(), + delay_ms: which_key.delay_ms.unwrap(), + } + } +} diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index d5d3016ab2704392c6cc9cc4bcebf6d50701d3be..956d63580404da351d34af3b5cf5fd531d5a0011 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -35,14 +35,17 @@ clock.workspace = true collections.workspace = true component.workspace = true db.workspace = true +feature_flags.workspace = true fs.workspace = true futures.workspace = true +git.workspace = true gpui.workspace = true http_client.workspace = true itertools.workspace = true language.workspace = true log.workspace = true menu.workspace = true +markdown.workspace = true node_runtime.workspace = true parking_lot.workspace = true postage.workspace = true diff --git a/crates/workspace/src/dock.rs b/crates/workspace/src/dock.rs index dfc341db9c71fd1059853b9480a7e679109ead40..7f4b09df0f94fa421c399ed9d70163f7cc2ba203 100644 --- a/crates/workspace/src/dock.rs +++ b/crates/workspace/src/dock.rs @@ -3,6 +3,7 @@ use crate::{DraggedDock, Event, ModalLayer, Pane}; use crate::{Workspace, status_bar::StatusItemView}; use anyhow::Context as _; use client::proto; + use gpui::{ Action, AnyView, App, Axis, Context, Corner, Entity, EntityId, EventEmitter, FocusHandle, Focusable, IntoElement, KeyContext, MouseButton, MouseDownEvent, MouseUpEvent, ParentElement, @@ -13,6 +14,7 @@ use settings::SettingsStore; use std::sync::Arc; use ui::{ContextMenu, Divider, DividerColor, IconButton, Tooltip, h_flex}; use ui::{prelude::*, right_click_menu}; +use util::ResultExt as _; pub(crate) const RESIZE_HANDLE_SIZE: Pixels = px(6.); @@ -25,6 +27,72 @@ pub enum PanelEvent { pub use proto::PanelId; +pub struct MinimizePane; +pub struct ClosePane; + +pub trait UtilityPane: EventEmitter + EventEmitter + Render { + fn position(&self, window: &Window, cx: &App) -> UtilityPanePosition; + /// The icon to render in the adjacent pane's tab bar for toggling this utility pane + fn toggle_icon(&self, cx: &App) -> IconName; + fn expanded(&self, cx: &App) -> bool; + fn set_expanded(&mut self, expanded: bool, cx: &mut Context); + fn width(&self, cx: &App) -> Pixels; + fn set_width(&mut self, width: Option, cx: &mut Context); +} + +pub trait UtilityPaneHandle: 'static + Send + Sync { + fn position(&self, window: &Window, cx: &App) -> UtilityPanePosition; + fn toggle_icon(&self, cx: &App) -> IconName; + fn expanded(&self, cx: &App) -> bool; + fn set_expanded(&self, expanded: bool, cx: &mut App); + fn width(&self, cx: &App) -> Pixels; + fn set_width(&self, width: Option, cx: &mut App); + fn to_any(&self) -> AnyView; + fn box_clone(&self) -> Box; +} + +impl UtilityPaneHandle for Entity +where + T: UtilityPane, +{ + fn position(&self, window: &Window, cx: &App) -> UtilityPanePosition { + self.read(cx).position(window, cx) + } + + fn toggle_icon(&self, cx: &App) -> IconName { + self.read(cx).toggle_icon(cx) + } + + fn expanded(&self, cx: &App) -> bool { + self.read(cx).expanded(cx) + } + + fn set_expanded(&self, expanded: bool, cx: &mut App) { + self.update(cx, |this, cx| this.set_expanded(expanded, cx)) + } + + fn width(&self, cx: &App) -> Pixels { + self.read(cx).width(cx) + } + + fn set_width(&self, width: Option, cx: &mut App) { + self.update(cx, |this, cx| this.set_width(width, cx)) + } + + fn to_any(&self) -> AnyView { + self.clone().into() + } + + fn box_clone(&self) -> Box { + Box::new(self.clone()) + } +} + +pub enum UtilityPanePosition { + Left, + Right, +} + pub trait Panel: Focusable + EventEmitter + Render + Sized { fn persistent_name() -> &'static str; fn panel_key() -> &'static str; @@ -281,7 +349,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| { @@ -384,6 +452,13 @@ impl Dock { .position(|entry| entry.panel.remote_id() == Some(panel_id)) } + pub fn panel_for_id(&self, panel_id: EntityId) -> Option<&Arc> { + self.panel_entries + .iter() + .find(|entry| entry.panel.panel_id() == panel_id) + .map(|entry| &entry.panel) + } + pub fn first_enabled_panel_idx(&mut self, cx: &mut Context) -> anyhow::Result { self.panel_entries .iter() @@ -491,6 +566,9 @@ impl Dock { new_dock.update(cx, |new_dock, cx| { new_dock.remove_panel(&panel, window, cx); + }); + + new_dock.update(cx, |new_dock, cx| { let index = new_dock.add_panel(panel.clone(), workspace.clone(), window, cx); if was_visible { @@ -498,6 +576,12 @@ impl Dock { new_dock.activate_panel(index, window, cx); } }); + + workspace + .update(cx, |workspace, cx| { + workspace.serialize_workspace(window, cx); + }) + .ok(); } }), cx.subscribe_in( @@ -508,7 +592,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| { @@ -540,7 +624,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 => { @@ -586,6 +670,7 @@ impl Dock { ); self.restore_state(window, cx); + if panel.read(cx).starts_open(window, cx) { self.activate_panel(index, window, cx); self.set_open(true, window, cx); @@ -619,7 +704,7 @@ impl Dock { panel: &Entity, window: &mut Window, cx: &mut Context, - ) { + ) -> bool { if let Some(panel_ix) = self .panel_entries .iter() @@ -637,8 +722,13 @@ impl Dock { std::cmp::Ordering::Greater => {} } } + self.panel_entries.remove(panel_ix); cx.notify(); + + true + } else { + false } } @@ -891,7 +981,13 @@ impl Render for PanelButtons { .enumerate() .filter_map(|(i, entry)| { let icon = entry.panel.icon(window, cx)?; - let icon_tooltip = entry.panel.icon_tooltip(window, cx)?; + let icon_tooltip = entry + .panel + .icon_tooltip(window, cx) + .ok_or_else(|| { + anyhow::anyhow!("can't render a panel button without an icon tooltip") + }) + .log_err()?; let name = entry.panel.persistent_name(); let panel = entry.panel.clone(); @@ -952,7 +1048,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 42eb754c21347e7dced792f3e56cb9901bc70bd1..6e415c23454388bc7931ff9d5e499924d6b8f55d 100644 --- a/crates/workspace/src/item.rs +++ b/crates/workspace/src/item.rs @@ -76,7 +76,13 @@ impl Settings for ItemSettings { fn from_settings(content: &settings::SettingsContent) -> Self { let tabs = content.tabs.as_ref().unwrap(); Self { - git_status: tabs.git_status.unwrap(), + git_status: tabs.git_status.unwrap() + && content + .git + .unwrap() + .enabled + .unwrap() + .is_git_status_enabled(), close_position: tabs.close_position.unwrap(), activate_on_close: tabs.activate_on_close.unwrap(), file_icons: tabs.file_icons.unwrap(), @@ -883,8 +889,18 @@ impl ItemHandle for Entity { if let Some(item) = weak_item.upgrade() && item.workspace_settings(cx).autosave == AutosaveSetting::OnFocusChange { - Pane::autosave_item(&item, workspace.project.clone(), window, cx) - .detach_and_log_err(cx); + // Only trigger autosave if focus has truly left the item. + // If focus is still within the item's hierarchy (e.g., moved to a context menu), + // don't trigger autosave to avoid unwanted formatting and cursor jumps. + // Also skip autosave if focus moved to a modal (e.g., command palette), + // since the user is still interacting with the workspace. + let focus_handle = item.item_focus_handle(cx); + if !focus_handle.contains_focused(window, cx) + && !workspace.has_active_modal(window, cx) + { + Pane::autosave_item(&item, workspace.project.clone(), window, cx) + .detach_and_log_err(cx); + } } }, ) @@ -1036,7 +1052,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..58667e7ffa8ad4fe5a22d293e4fc4aa71015a3bd 100644 --- a/crates/workspace/src/modal_layer.rs +++ b/crates/workspace/src/modal_layer.rs @@ -22,12 +22,17 @@ pub trait ModalView: ManagedView { fn fade_out_background(&self) -> bool { false } + + fn render_bare(&self) -> bool { + false + } } trait ModalViewHandle { fn on_before_dismiss(&mut self, window: &mut Window, cx: &mut App) -> DismissDecision; fn view(&self) -> AnyView; fn fade_out_background(&self, cx: &mut App) -> bool; + fn render_bare(&self, cx: &mut App) -> bool; } impl ModalViewHandle for Entity { @@ -42,6 +47,10 @@ impl ModalViewHandle for Entity { fn fade_out_background(&self, cx: &mut App) -> bool { self.read(cx).fade_out_background() } + + fn render_bare(&self, cx: &mut App) -> bool { + self.read(cx).render_bare() + } } pub struct ActiveModal { @@ -116,7 +125,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 +153,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(); } @@ -167,19 +176,22 @@ impl ModalLayer { impl Render for ModalLayer { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let Some(active_modal) = &self.active_modal else { - return div(); + return div().into_any_element(); }; + if active_modal.modal.render_bare(cx) { + return active_modal.modal.view().into_any_element(); + } + 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, @@ -191,8 +203,6 @@ impl Render for ModalLayer { v_flex() .h(px(0.0)) .top_20() - .flex() - .flex_col() .items_center() .track_focus(&active_modal.focus_handle) .child( @@ -204,5 +214,6 @@ impl Render for ModalLayer { }), ), ) + .into_any_element() } } diff --git a/crates/workspace/src/notifications.rs b/crates/workspace/src/notifications.rs index cfdc730b4db5be8e2f4a317dcf7e12072af40a88..3b126d329e7fafefa4043661c5039f1e17b09b54 100644 --- a/crates/workspace/src/notifications.rs +++ b/crates/workspace/src/notifications.rs @@ -3,9 +3,12 @@ use anyhow::Context as _; use gpui::{ AnyView, App, AppContext as _, AsyncWindowContext, ClickEvent, ClipboardItem, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, PromptLevel, Render, ScrollHandle, - Task, svg, + Task, TextStyleRefinement, UnderlineStyle, svg, }; +use markdown::{Markdown, MarkdownElement, MarkdownStyle}; use parking_lot::Mutex; +use settings::Settings; +use theme::ThemeSettings; use std::ops::Deref; use std::sync::{Arc, LazyLock}; @@ -41,7 +44,7 @@ pub enum NotificationId { impl NotificationId { /// Returns a unique [`NotificationId`] for the given type. - pub fn unique() -> Self { + pub const fn unique() -> Self { Self::Unique(TypeId::of::()) } @@ -216,6 +219,7 @@ pub struct LanguageServerPrompt { focus_handle: FocusHandle, request: Option, scroll_handle: ScrollHandle, + markdown: Entity, } impl Focusable for LanguageServerPrompt { @@ -228,10 +232,13 @@ impl Notification for LanguageServerPrompt {} impl LanguageServerPrompt { pub fn new(request: project::LanguageServerPromptRequest, cx: &mut App) -> Self { + let markdown = cx.new(|cx| Markdown::new(request.message.clone().into(), None, None, cx)); + Self { focus_handle: cx.focus_handle(), request: Some(request), scroll_handle: ScrollHandle::new(), + markdown, } } @@ -262,7 +269,7 @@ impl Render for LanguageServerPrompt { }; let (icon, color) = match request.level { - PromptLevel::Info => (IconName::Info, Color::Accent), + PromptLevel::Info => (IconName::Info, Color::Muted), PromptLevel::Warning => (IconName::Warning, Color::Warning), PromptLevel::Critical => (IconName::XCircle, Color::Error), }; @@ -291,16 +298,15 @@ impl Render for LanguageServerPrompt { .child( h_flex() .justify_between() - .items_start() .child( h_flex() .gap_2() - .child(Icon::new(icon).color(color)) + .child(Icon::new(icon).color(color).size(IconSize::Small)) .child(Label::new(request.lsp_name.clone())), ) .child( h_flex() - .gap_2() + .gap_1() .child( IconButton::new("copy", IconName::Copy) .on_click({ @@ -317,15 +323,17 @@ impl Render for LanguageServerPrompt { IconButton::new(close_id, close_icon) .tooltip(move |_window, cx| { if suppress { - Tooltip::for_action( - "Suppress.\nClose with click.", - &SuppressNotification, + Tooltip::with_meta( + "Suppress", + Some(&SuppressNotification), + "Click to close", cx, ) } else { - Tooltip::for_action( - "Close.\nSuppress with shift-click.", - &menu::Cancel, + Tooltip::with_meta( + "Close", + Some(&menu::Cancel), + "Suppress with shift-click", cx, ) } @@ -342,7 +350,16 @@ impl Render for LanguageServerPrompt { ), ), ) - .child(Label::new(request.message.to_string()).size(LabelSize::Small)) + .child( + MarkdownElement::new(self.markdown.clone(), markdown_style(window, cx)) + .text_size(TextSize::Small.rems(cx)) + .code_block_renderer(markdown::CodeBlockRenderer::Default { + copy_button: false, + copy_button_on_hover: false, + border: false, + }) + .on_url_click(|link, _, cx| cx.open_url(&link)), + ) .children(request.actions.iter().enumerate().map(|(ix, action)| { let this_handle = cx.entity(); Button::new(ix, action.title.clone()) @@ -369,6 +386,42 @@ fn workspace_error_notification_id() -> NotificationId { NotificationId::unique::() } +fn markdown_style(window: &Window, cx: &App) -> MarkdownStyle { + let settings = ThemeSettings::get_global(cx); + let ui_font_family = settings.ui_font.family.clone(); + let ui_font_fallbacks = settings.ui_font.fallbacks.clone(); + let buffer_font_family = settings.buffer_font.family.clone(); + let buffer_font_fallbacks = settings.buffer_font.fallbacks.clone(); + + let mut base_text_style = window.text_style(); + base_text_style.refine(&TextStyleRefinement { + font_family: Some(ui_font_family), + font_fallbacks: ui_font_fallbacks, + color: Some(cx.theme().colors().text), + ..Default::default() + }); + + MarkdownStyle { + base_text_style, + selection_background_color: cx.theme().colors().element_selection_background, + inline_code: TextStyleRefinement { + background_color: Some(cx.theme().colors().editor_background.opacity(0.5)), + font_family: Some(buffer_font_family), + font_fallbacks: buffer_font_fallbacks, + ..Default::default() + }, + link: TextStyleRefinement { + underline: Some(UnderlineStyle { + thickness: px(1.), + color: Some(cx.theme().colors().text_accent), + wavy: false, + }), + ..Default::default() + }, + ..Default::default() + } +} + #[derive(Debug, Clone)] pub struct ErrorMessagePrompt { message: SharedString, diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index e99f8d1dc959def06deebae7c4acc454c9210933..f6256aee46b9e2b5c29c020e9ee12f6ff510210f 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -1,7 +1,7 @@ use crate::{ CloseWindow, NewFile, NewTerminal, OpenInTerminal, OpenOptions, OpenTerminal, OpenVisible, SplitDirection, ToggleFileFinder, ToggleProjectSymbols, ToggleZoom, Workspace, - WorkspaceItemBuilder, + WorkspaceItemBuilder, ZoomIn, ZoomOut, invalid_item_view::InvalidItemView, item::{ ActivateOnClose, ClosePosition, Item, ItemBufferKind, ItemHandle, ItemSettings, @@ -11,10 +11,12 @@ use crate::{ move_item, notifications::NotifyResultExt, toolbar::Toolbar, + utility_pane::UtilityPaneSlot, workspace_settings::{AutosaveSetting, TabBarSettings, WorkspaceSettings}, }; use anyhow::Result; use collections::{BTreeSet, HashMap, HashSet, VecDeque}; +use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt}; use futures::{StreamExt, stream::FuturesUnordered}; use gpui::{ Action, AnyElement, App, AsyncWindowContext, ClickEvent, ClipboardItem, Context, Corner, Div, @@ -45,10 +47,9 @@ use std::{ }; use theme::ThemeSettings; use ui::{ - ButtonSize, Color, ContextMenu, ContextMenuEntry, ContextMenuItem, DecoratedIcon, IconButton, - IconButtonShape, IconDecoration, IconDecorationKind, IconName, IconSize, Indicator, Label, - PopoverMenu, PopoverMenuHandle, Tab, TabBar, TabPosition, Tooltip, prelude::*, - right_click_menu, + ContextMenu, ContextMenuEntry, ContextMenuItem, DecoratedIcon, IconButtonShape, IconDecoration, + IconDecorationKind, Indicator, PopoverMenu, PopoverMenuHandle, Tab, TabBar, TabPosition, + Tooltip, prelude::*, right_click_menu, }; use util::{ResultExt, debug_panic, maybe, paths::PathStyle, truncate_and_remove_front}; @@ -396,6 +397,11 @@ pub struct Pane { diagnostic_summary_update: Task<()>, /// If a certain project item wants to get recreated with specific data, it can persist its data before the recreation here. pub project_item_restoration_data: HashMap>, + welcome_page: Option>, + + pub in_center_group: bool, + pub is_upper_left: bool, + pub is_upper_right: bool, } pub struct ActivationHistoryEntry { @@ -540,6 +546,10 @@ impl Pane { zoom_out_on_close: true, diagnostic_summary_update: Task::ready(()), project_item_restoration_data: HashMap::default(), + welcome_page: None, + in_center_group: false, + is_upper_left: false, + is_upper_right: false, } } @@ -615,17 +625,21 @@ 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) { self.last_focus_handle_by_item .insert(active_item.item_id(), focused.downgrade()); } + } 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, cx); + } } } @@ -1297,6 +1311,25 @@ impl Pane { } } + pub fn zoom_in(&mut self, _: &ZoomIn, window: &mut Window, cx: &mut Context) { + if !self.can_toggle_zoom { + cx.propagate(); + } else if !self.zoomed && !self.items.is_empty() { + if !self.focus_handle.contains_focused(window, cx) { + cx.focus_self(window); + } + cx.emit(Event::ZoomIn); + } + } + + pub fn zoom_out(&mut self, _: &ZoomOut, _window: &mut Window, cx: &mut Context) { + if !self.can_toggle_zoom { + cx.propagate(); + } else if self.zoomed { + cx.emit(Event::ZoomOut); + } + } + pub fn activate_item( &mut self, index: usize, @@ -1966,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, @@ -2317,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); } } @@ -3033,7 +3066,13 @@ impl Pane { } fn render_tab_bar(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { + let Some(workspace) = self.workspace.upgrade() else { + return gpui::Empty.into_any(); + }; + let focus_handle = self.focus_handle.clone(); + let is_pane_focused = self.has_focus(window, cx); + let navigate_backward = IconButton::new("navigate_backward", IconName::ArrowLeft) .icon_size(IconSize::Small) .on_click({ @@ -3057,6 +3096,70 @@ impl Pane { } }); + let open_aside_left = { + let workspace = workspace.read(cx); + workspace.utility_pane(UtilityPaneSlot::Left).map(|pane| { + let toggle_icon = pane.toggle_icon(cx); + let workspace_handle = self.workspace.clone(); + + h_flex() + .h_full() + .pr_1p5() + .border_r_1() + .border_color(cx.theme().colors().border) + .child( + IconButton::new("open_aside_left", toggle_icon) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Toggle Agent Pane")) // TODO: Probably want to make this generic + .on_click(move |_, window, cx| { + workspace_handle + .update(cx, |workspace, cx| { + workspace.toggle_utility_pane( + UtilityPaneSlot::Left, + window, + cx, + ) + }) + .ok(); + }), + ) + .into_any_element() + }) + }; + + let open_aside_right = { + let workspace = workspace.read(cx); + workspace.utility_pane(UtilityPaneSlot::Right).map(|pane| { + let toggle_icon = pane.toggle_icon(cx); + let workspace_handle = self.workspace.clone(); + + h_flex() + .h_full() + .when(is_pane_focused, |this| { + this.pl(DynamicSpacing::Base04.rems(cx)) + .border_l_1() + .border_color(cx.theme().colors().border) + }) + .child( + IconButton::new("open_aside_right", toggle_icon) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Toggle Agent Pane")) // TODO: Probably want to make this generic + .on_click(move |_, window, cx| { + workspace_handle + .update(cx, |workspace, cx| { + workspace.toggle_utility_pane( + UtilityPaneSlot::Right, + window, + cx, + ) + }) + .ok(); + }), + ) + .into_any_element() + }) + }; + let navigate_forward = IconButton::new("navigate_forward", IconName::ArrowRight) .icon_size(IconSize::Small) .on_click({ @@ -3103,7 +3206,44 @@ impl Pane { let unpinned_tabs = tab_items.split_off(self.pinned_tab_count); let pinned_tabs = tab_items; + let render_aside_toggle_left = cx.has_flag::() + && self + .is_upper_left + .then(|| { + self.workspace.upgrade().and_then(|entity| { + let workspace = entity.read(cx); + workspace + .utility_pane(UtilityPaneSlot::Left) + .map(|pane| !pane.expanded(cx)) + }) + }) + .flatten() + .unwrap_or(false); + + let render_aside_toggle_right = cx.has_flag::() + && self + .is_upper_right + .then(|| { + self.workspace.upgrade().and_then(|entity| { + let workspace = entity.read(cx); + workspace + .utility_pane(UtilityPaneSlot::Right) + .map(|pane| !pane.expanded(cx)) + }) + }) + .flatten() + .unwrap_or(false); + TabBar::new("tab_bar") + .map(|tab_bar| { + if let Some(open_aside_left) = open_aside_left + && render_aside_toggle_left + { + tab_bar.start_child(open_aside_left) + } else { + tab_bar + } + }) .when( self.display_nav_history_buttons.unwrap_or_default(), |tab_bar| { @@ -3196,6 +3336,15 @@ impl Pane { })), ), ) + .map(|tab_bar| { + if let Some(open_aside_right) = open_aside_right + && render_aside_toggle_right + { + tab_bar.end_child(open_aside_right) + } else { + tab_bar + } + }) .into_any_element() } @@ -3775,6 +3924,8 @@ impl Render for Pane { cx.emit(Event::JoinAll); })) .on_action(cx.listener(Pane::toggle_zoom)) + .on_action(cx.listener(Pane::zoom_in)) + .on_action(cx.listener(Pane::zoom_out)) .on_action(cx.listener(Self::navigate_backward)) .on_action(cx.listener(Self::navigate_forward)) .on_action( @@ -3915,10 +4066,15 @@ impl Render for Pane { if has_worktrees { placeholder } else { - placeholder.child( - Label::new("Open a file or project to get started.") - .color(Color::Muted), - ) + if self.welcome_page.is_none() { + let workspace = self.workspace.clone(); + self.welcome_page = Some(cx.new(|cx| { + crate::welcome::WelcomePage::new( + workspace, true, window, cx, + ) + })); + } + placeholder.child(self.welcome_page.clone().unwrap()) } } }) @@ -6659,13 +6815,13 @@ mod tests { let tab_bar_scroll_handle = pane.update_in(cx, |pane, _window, _cx| pane.tab_bar_scroll_handle.clone()); assert_eq!(tab_bar_scroll_handle.children_count(), 6); - let tab_bounds = cx.debug_bounds("TAB-3").unwrap(); + let tab_bounds = cx.debug_bounds("TAB-4").unwrap(); let new_tab_button_bounds = cx.debug_bounds("ICON-Plus").unwrap(); let scroll_bounds = tab_bar_scroll_handle.bounds(); let scroll_offset = tab_bar_scroll_handle.offset(); - assert!(tab_bounds.right() <= scroll_bounds.right() + scroll_offset.x); - // -39.5 is the magic number for this setup - assert_eq!(scroll_offset.x, px(-39.5)); + assert!(tab_bounds.right() <= scroll_bounds.right()); + // -43.0 is the magic number for this setup + assert_eq!(scroll_offset.x, px(-43.0)); assert!( !tab_bounds.intersects(&new_tab_button_bounds), "Tab should not overlap with the new tab button, if this is failing check if there's been a redesign!" diff --git a/crates/workspace/src/pane_group.rs b/crates/workspace/src/pane_group.rs index c9d98977139ed644cf5f3bfb7eb26d94ca081d19..393ed74e30c9c34bf7cdb22aabf2de2d05aa84f8 100644 --- a/crates/workspace/src/pane_group.rs +++ b/crates/workspace/src/pane_group.rs @@ -28,6 +28,7 @@ const VERTICAL_MIN_SIZE: f32 = 100.; #[derive(Clone)] pub struct PaneGroup { pub root: Member, + pub is_center: bool, } pub struct PaneRenderResult { @@ -37,22 +38,31 @@ pub struct PaneRenderResult { impl PaneGroup { pub fn with_root(root: Member) -> Self { - Self { root } + Self { + root, + is_center: false, + } } pub fn new(pane: Entity) -> Self { Self { root: Member::Pane(pane), + is_center: false, } } + pub fn set_is_center(&mut self, is_center: bool) { + self.is_center = is_center; + } + pub fn split( &mut self, old_pane: &Entity, new_pane: &Entity, direction: SplitDirection, + cx: &mut App, ) -> Result<()> { - match &mut self.root { + let result = match &mut self.root { Member::Pane(pane) => { if pane == old_pane { self.root = Member::new_axis(old_pane.clone(), new_pane.clone(), direction); @@ -62,7 +72,11 @@ impl PaneGroup { } } Member::Axis(axis) => axis.split(old_pane, new_pane, direction), + }; + if result.is_ok() { + self.mark_positions(cx); } + result } pub fn bounding_box_for_pane(&self, pane: &Entity) -> Option> { @@ -90,6 +104,7 @@ impl PaneGroup { &mut self, active_pane: &Entity, direction: SplitDirection, + cx: &mut App, ) -> Result { if let Some(pane) = self.find_pane_at_border(direction) && pane == active_pane @@ -97,7 +112,7 @@ impl PaneGroup { return Ok(false); } - if !self.remove(active_pane)? { + if !self.remove_internal(active_pane)? { return Ok(false); } @@ -110,6 +125,7 @@ impl PaneGroup { 0 }; root.insert_pane(idx, active_pane); + self.mark_positions(cx); return Ok(true); } @@ -119,6 +135,7 @@ impl PaneGroup { vec![Member::Pane(active_pane.clone()), self.root.clone()] }; self.root = Member::Axis(PaneAxis::new(direction.axis(), members)); + self.mark_positions(cx); Ok(true) } @@ -133,7 +150,15 @@ impl PaneGroup { /// - Ok(true) if it found and removed a pane /// - Ok(false) if it found but did not remove the pane /// - Err(_) if it did not find the pane - pub fn remove(&mut self, pane: &Entity) -> Result { + pub fn remove(&mut self, pane: &Entity, cx: &mut App) -> Result { + let result = self.remove_internal(pane); + if let Ok(true) = result { + self.mark_positions(cx); + } + result + } + + fn remove_internal(&mut self, pane: &Entity) -> Result { match &mut self.root { Member::Pane(_) => Ok(false), Member::Axis(axis) => { @@ -151,6 +176,7 @@ impl PaneGroup { direction: Axis, amount: Pixels, bounds: &Bounds, + cx: &mut App, ) { match &mut self.root { Member::Pane(_) => {} @@ -158,22 +184,29 @@ impl PaneGroup { let _ = axis.resize(pane, direction, amount, bounds); } }; + self.mark_positions(cx); } - pub fn reset_pane_sizes(&mut self) { + pub fn reset_pane_sizes(&mut self, cx: &mut App) { match &mut self.root { Member::Pane(_) => {} Member::Axis(axis) => { let _ = axis.reset_pane_sizes(); } }; + self.mark_positions(cx); } - pub fn swap(&mut self, from: &Entity, to: &Entity) { + pub fn swap(&mut self, from: &Entity, to: &Entity, cx: &mut App) { match &mut self.root { Member::Pane(_) => {} Member::Axis(axis) => axis.swap(from, to), }; + self.mark_positions(cx); + } + + pub fn mark_positions(&mut self, cx: &mut App) { + self.root.mark_positions(self.is_center, true, true, cx); } pub fn render( @@ -232,8 +265,9 @@ impl PaneGroup { self.pane_at_pixel_position(target) } - pub fn invert_axies(&mut self) { + pub fn invert_axies(&mut self, cx: &mut App) { self.root.invert_pane_axies(); + self.mark_positions(cx); } } @@ -243,6 +277,43 @@ pub enum Member { Pane(Entity), } +impl Member { + pub fn mark_positions( + &mut self, + in_center_group: bool, + is_upper_left: bool, + is_upper_right: bool, + cx: &mut App, + ) { + match self { + Member::Axis(pane_axis) => { + let len = pane_axis.members.len(); + for (idx, member) in pane_axis.members.iter_mut().enumerate() { + let member_upper_left = match pane_axis.axis { + Axis::Vertical => is_upper_left && idx == 0, + Axis::Horizontal => is_upper_left && idx == 0, + }; + let member_upper_right = match pane_axis.axis { + Axis::Vertical => is_upper_right && idx == 0, + Axis::Horizontal => is_upper_right && idx == len - 1, + }; + member.mark_positions( + in_center_group, + member_upper_left, + member_upper_right, + cx, + ); + } + } + Member::Pane(entity) => entity.update(cx, |pane, _| { + pane.in_center_group = in_center_group; + pane.is_upper_left = is_upper_left; + pane.is_upper_right = is_upper_right; + }), + } + } +} + #[derive(Clone, Copy)] pub struct PaneRenderContext<'a> { pub project: &'a Entity, diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index 103e51d548648c18b5b2d724362228948a70930b..094d03494e726677dc43235d96fc62c076673bf5 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -9,18 +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::{RemoteConnectionOptions, SshConnectionOptions, WslConnectionOptions}; +use remote::{ + DockerConnectionOptions, RemoteConnectionOptions, SshConnectionOptions, WslConnectionOptions, +}; +use serde::{Deserialize, Serialize}; use sqlez::{ bindable::{Bind, Column, StaticColumnCount}, statement::Statement, @@ -44,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 {} @@ -152,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, @@ -702,6 +833,18 @@ impl Domain for WorkspaceDb { sql!( DROP TABLE ssh_connections; ), + sql!( + 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 @@ -728,9 +871,9 @@ impl WorkspaceDb { pub(crate) fn remote_workspace_for_roots>( &self, worktree_roots: &[P], - ssh_project_id: RemoteConnectionId, + remote_project_id: RemoteConnectionId, ) -> Option { - self.workspace_for_roots_internal(worktree_roots, Some(ssh_project_id)) + self.workspace_for_roots_internal(worktree_roots, Some(remote_project_id)) } pub(crate) fn workspace_for_roots_internal>( @@ -806,9 +949,20 @@ impl WorkspaceDb { order: paths_order, }); + let remote_connection_options = if let Some(remote_connection_id) = remote_connection_id { + self.remote_connection(remote_connection_id) + .context("Get remote connection") + .log_err() + } else { + None + }; + Some(SerializedWorkspace { id: workspace_id, - location: SerializedWorkspaceLocation::Local, + location: match remote_connection_options { + Some(options) => SerializedWorkspaceLocation::Remote(options), + None => SerializedWorkspaceLocation::Local, + }, paths, center_group: self .get_center_pane_group(workspace_id) @@ -1110,14 +1264,16 @@ impl WorkspaceDb { options: RemoteConnectionOptions, ) -> Result { let kind; - let user; + let mut user = None; let mut host = None; let mut port = None; let mut distro = None; + let mut name = None; + let mut container_id = None; match options { RemoteConnectionOptions::Ssh(options) => { kind = RemoteConnectionKind::Ssh; - host = Some(options.host); + host = Some(options.host.to_string()); port = options.port; user = options.username; } @@ -1126,8 +1282,22 @@ impl WorkspaceDb { distro = Some(options.distro_name); user = options.user; } + RemoteConnectionOptions::Docker(options) => { + kind = RemoteConnectionKind::Docker; + container_id = Some(options.container_id); + name = Some(options.name); + } } - Self::get_or_create_remote_connection_query(this, kind, host, port, user, distro) + Self::get_or_create_remote_connection_query( + this, + kind, + host, + port, + user, + distro, + name, + container_id, + ) } fn get_or_create_remote_connection_query( @@ -1137,6 +1307,8 @@ impl WorkspaceDb { port: Option, user: Option, distro: Option, + name: Option, + container_id: Option, ) -> Result { if let Some(id) = this.select_row_bound(sql!( SELECT id @@ -1146,7 +1318,9 @@ impl WorkspaceDb { host IS ? AND port IS ? AND user IS ? AND - distro IS ? + distro IS ? AND + name IS ? AND + container_id IS ? LIMIT 1 ))?(( kind.serialize(), @@ -1154,6 +1328,8 @@ impl WorkspaceDb { port, user.clone(), distro.clone(), + name.clone(), + container_id.clone(), ))? { Ok(RemoteConnectionId(id)) } else { @@ -1163,10 +1339,20 @@ impl WorkspaceDb { host, port, user, - distro - ) VALUES (?1, ?2, ?3, ?4, ?5) + distro, + name, + container_id + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) RETURNING id - ))?((kind.serialize(), host, port, user, distro))? + ))?(( + kind.serialize(), + host, + port, + user, + distro, + name, + container_id, + ))? .context("failed to insert remote project")?; Ok(RemoteConnectionId(id)) } @@ -1249,15 +1435,23 @@ impl WorkspaceDb { fn remote_connections(&self) -> Result> { Ok(self.select(sql!( SELECT - id, kind, host, port, user, distro + id, kind, host, port, user, distro, container_id, name FROM remote_connections ))?()? .into_iter() - .filter_map(|(id, kind, host, port, user, distro)| { + .filter_map(|(id, kind, host, port, user, distro, container_id, name)| { Some(( RemoteConnectionId(id), - Self::remote_connection_from_row(kind, host, port, user, distro)?, + Self::remote_connection_from_row( + kind, + host, + port, + user, + distro, + container_id, + name, + )?, )) }) .collect()) @@ -1267,13 +1461,13 @@ impl WorkspaceDb { &self, id: RemoteConnectionId, ) -> Result { - let (kind, host, port, user, distro) = self.select_row_bound(sql!( - SELECT kind, host, port, user, distro + let (kind, host, port, user, distro, container_id, name) = self.select_row_bound(sql!( + SELECT kind, host, port, user, distro, container_id, name FROM remote_connections WHERE id = ? ))?(id.0)? .context("no such remote connection")?; - Self::remote_connection_from_row(kind, host, port, user, distro) + Self::remote_connection_from_row(kind, host, port, user, distro, container_id, name) .context("invalid remote_connection row") } @@ -1283,6 +1477,8 @@ impl WorkspaceDb { port: Option, user: Option, distro: Option, + container_id: Option, + name: Option, ) -> Option { match RemoteConnectionKind::deserialize(&kind)? { RemoteConnectionKind::Wsl => Some(RemoteConnectionOptions::Wsl(WslConnectionOptions { @@ -1290,32 +1486,21 @@ impl WorkspaceDb { user: user, })), RemoteConnectionKind::Ssh => Some(RemoteConnectionOptions::Ssh(SshConnectionOptions { - host: host?, + host: host?.into(), port, username: user, ..Default::default() })), + RemoteConnectionKind::Docker => { + Some(RemoteConnectionOptions::Docker(DockerConnectionOptions { + container_id: container_id?, + name: name?, + upload_binary_over_docker_exec: false, + })) + } } } - 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 @@ -1730,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( @@ -2437,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() })) @@ -2626,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() }); @@ -2717,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() @@ -2728,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() @@ -2745,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() @@ -2764,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() @@ -2774,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() @@ -2804,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() @@ -2982,4 +3296,53 @@ mod tests { assert_eq!(workspace.center_group, new_workspace.center_group); } + + #[gpui::test] + async fn test_empty_workspace_window_bounds() { + zlog::init_test(); + + let db = WorkspaceDb::open_test_db("test_empty_workspace_window_bounds").await; + let id = db.next_id().await.unwrap(); + + // Create a workspace with empty paths (empty workspace) + let empty_paths: &[&str] = &[]; + let display_uuid = Uuid::new_v4(); + let window_bounds = SerializedWindowBounds(WindowBounds::Windowed(Bounds { + origin: point(px(100.0), px(200.0)), + size: size(px(800.0), px(600.0)), + })); + + let workspace = SerializedWorkspace { + id, + paths: PathList::new(empty_paths), + location: SerializedWorkspaceLocation::Local, + center_group: Default::default(), + window_bounds: None, + display: None, + docks: Default::default(), + breakpoints: Default::default(), + centered_layout: false, + session_id: None, + window_id: None, + user_toolchains: Default::default(), + }; + + // Save the workspace (this creates the record with empty paths) + db.save_workspace(workspace.clone()).await; + + // Save window bounds separately (as the actual code does via set_window_open_status) + db.set_window_open_status(id, window_bounds, display_uuid) + .await + .unwrap(); + + // Retrieve it using empty paths + let retrieved = db.workspace_for_roots(empty_paths).unwrap(); + + // Verify window bounds were persisted + assert_eq!(retrieved.id, id); + assert!(retrieved.window_bounds.is_some()); + assert_eq!(retrieved.window_bounds.unwrap().0, window_bounds.0); + assert!(retrieved.display.is_some()); + assert_eq!(retrieved.display.unwrap(), display_uuid); + } } diff --git a/crates/workspace/src/persistence/model.rs b/crates/workspace/src/persistence/model.rs index a37b2ebbe93efb23cad6a98f127ba1f8800a3eb3..08a3adf9ebd7fa49a5f8fb86eec65c66deb00421 100644 --- a/crates/workspace/src/persistence/model.rs +++ b/crates/workspace/src/persistence/model.rs @@ -32,6 +32,7 @@ pub(crate) struct RemoteConnectionId(pub u64); pub(crate) enum RemoteConnectionKind { Ssh, Wsl, + Docker, } #[derive(Debug, PartialEq, Clone)] @@ -75,6 +76,7 @@ impl RemoteConnectionKind { match self { RemoteConnectionKind::Ssh => "ssh", RemoteConnectionKind::Wsl => "wsl", + RemoteConnectionKind::Docker => "docker", } } @@ -82,6 +84,7 @@ impl RemoteConnectionKind { match text { "ssh" => Some(Self::Ssh), "wsl" => Some(Self::Wsl), + "docker" => Some(Self::Docker), _ => None, } } diff --git a/crates/workspace/src/security_modal.rs b/crates/workspace/src/security_modal.rs new file mode 100644 index 0000000000000000000000000000000000000000..bb1482d7cce2a9849a78a9512598e389a6e5eea0 --- /dev/null +++ b/crates/workspace/src/security_modal.rs @@ -0,0 +1,334 @@ +//! 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().filter_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 &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(), + }; + Some(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/shared_screen.rs b/crates/workspace/src/shared_screen.rs index 3c009f613ea52906649b73bb9fd657bab6906c3b..564560274699ab6685d481340c5efd4b6336ed56 100644 --- a/crates/workspace/src/shared_screen.rs +++ b/crates/workspace/src/shared_screen.rs @@ -42,6 +42,11 @@ impl SharedScreen { }) .detach(); + cx.observe_release(&room, |_, _, cx| { + cx.emit(Event::Close); + }) + .detach(); + let view = cx.new(|cx| RemoteVideoTrackView::new(track.clone(), window, cx)); cx.subscribe(&view, |_, _, ev, cx| match ev { call::RemoteVideoTrackViewEvent::Close => cx.emit(Event::Close), diff --git a/crates/workspace/src/utility_pane.rs b/crates/workspace/src/utility_pane.rs new file mode 100644 index 0000000000000000000000000000000000000000..2760000216d9164367c58d41d4f1b1893dc8cd75 --- /dev/null +++ b/crates/workspace/src/utility_pane.rs @@ -0,0 +1,282 @@ +use gpui::{ + AppContext as _, EntityId, MouseButton, Pixels, Render, StatefulInteractiveElement, + Subscription, WeakEntity, deferred, px, +}; +use ui::{ + ActiveTheme as _, Context, FluentBuilder as _, InteractiveElement as _, IntoElement, + ParentElement as _, RenderOnce, Styled as _, Window, div, +}; + +use crate::{ + DockPosition, Workspace, + dock::{ClosePane, MinimizePane, UtilityPane, UtilityPaneHandle}, +}; + +pub(crate) const UTILITY_PANE_RESIZE_HANDLE_SIZE: Pixels = px(6.0); +pub(crate) const UTILITY_PANE_MIN_WIDTH: Pixels = px(20.0); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum UtilityPaneSlot { + Left, + Right, +} + +struct UtilityPaneSlotState { + panel_id: EntityId, + utility_pane: Box, + _subscriptions: Vec, +} + +#[derive(Default)] +pub struct UtilityPaneState { + left_slot: Option, + right_slot: Option, +} + +#[derive(Clone)] +pub struct DraggedUtilityPane(pub UtilityPaneSlot); + +impl Render for DraggedUtilityPane { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + gpui::Empty + } +} + +pub fn utility_slot_for_dock_position(position: DockPosition) -> UtilityPaneSlot { + match position { + DockPosition::Left => UtilityPaneSlot::Left, + DockPosition::Right => UtilityPaneSlot::Right, + DockPosition::Bottom => UtilityPaneSlot::Left, + } +} + +impl Workspace { + pub fn utility_pane(&self, slot: UtilityPaneSlot) -> Option<&dyn UtilityPaneHandle> { + match slot { + UtilityPaneSlot::Left => self + .utility_panes + .left_slot + .as_ref() + .map(|s| s.utility_pane.as_ref()), + UtilityPaneSlot::Right => self + .utility_panes + .right_slot + .as_ref() + .map(|s| s.utility_pane.as_ref()), + } + } + + pub fn toggle_utility_pane( + &mut self, + slot: UtilityPaneSlot, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(handle) = self.utility_pane(slot) { + let current = handle.expanded(cx); + handle.set_expanded(!current, cx); + } + cx.notify(); + self.serialize_workspace(window, cx); + } + + pub fn register_utility_pane( + &mut self, + slot: UtilityPaneSlot, + panel_id: EntityId, + handle: gpui::Entity, + cx: &mut Context, + ) { + let minimize_subscription = + cx.subscribe(&handle, move |this, _, _event: &MinimizePane, cx| { + if let Some(handle) = this.utility_pane(slot) { + handle.set_expanded(false, cx); + } + cx.notify(); + }); + + let close_subscription = cx.subscribe(&handle, move |this, _, _event: &ClosePane, cx| { + this.clear_utility_pane(slot, cx); + }); + + let subscriptions = vec![minimize_subscription, close_subscription]; + let boxed_handle: Box = Box::new(handle); + + match slot { + UtilityPaneSlot::Left => { + self.utility_panes.left_slot = Some(UtilityPaneSlotState { + panel_id, + utility_pane: boxed_handle, + _subscriptions: subscriptions, + }); + } + UtilityPaneSlot::Right => { + self.utility_panes.right_slot = Some(UtilityPaneSlotState { + panel_id, + utility_pane: boxed_handle, + _subscriptions: subscriptions, + }); + } + } + cx.notify(); + } + + pub fn clear_utility_pane(&mut self, slot: UtilityPaneSlot, cx: &mut Context) { + match slot { + UtilityPaneSlot::Left => { + self.utility_panes.left_slot = None; + } + UtilityPaneSlot::Right => { + self.utility_panes.right_slot = None; + } + } + cx.notify(); + } + + pub fn clear_utility_pane_if_provider( + &mut self, + slot: UtilityPaneSlot, + provider_panel_id: EntityId, + cx: &mut Context, + ) { + let should_clear = match slot { + UtilityPaneSlot::Left => self + .utility_panes + .left_slot + .as_ref() + .is_some_and(|slot| slot.panel_id == provider_panel_id), + UtilityPaneSlot::Right => self + .utility_panes + .right_slot + .as_ref() + .is_some_and(|slot| slot.panel_id == provider_panel_id), + }; + + if should_clear { + self.clear_utility_pane(slot, cx); + } + } + + pub fn resize_utility_pane( + &mut self, + slot: UtilityPaneSlot, + new_width: Pixels, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(handle) = self.utility_pane(slot) { + let max_width = self.max_utility_pane_width(window, cx); + let width = new_width.max(UTILITY_PANE_MIN_WIDTH).min(max_width); + handle.set_width(Some(width), cx); + cx.notify(); + self.serialize_workspace(window, cx); + } + } + + pub fn reset_utility_pane_width( + &mut self, + slot: UtilityPaneSlot, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(handle) = self.utility_pane(slot) { + handle.set_width(None, cx); + cx.notify(); + self.serialize_workspace(window, cx); + } + } +} + +#[derive(IntoElement)] +pub struct UtilityPaneFrame { + workspace: WeakEntity, + slot: UtilityPaneSlot, + handle: Box, +} + +impl UtilityPaneFrame { + pub fn new( + slot: UtilityPaneSlot, + handle: Box, + cx: &mut Context, + ) -> Self { + let workspace = cx.weak_entity(); + Self { + workspace, + slot, + handle, + } + } +} + +impl RenderOnce for UtilityPaneFrame { + fn render(self, _window: &mut Window, cx: &mut ui::App) -> impl IntoElement { + let workspace = self.workspace.clone(); + let slot = self.slot; + let width = self.handle.width(cx); + + let create_resize_handle = || { + let workspace_handle = workspace.clone(); + let handle = div() + .id(match slot { + UtilityPaneSlot::Left => "utility-pane-resize-handle-left", + UtilityPaneSlot::Right => "utility-pane-resize-handle-right", + }) + .on_drag(DraggedUtilityPane(slot), move |pane, _, _, cx| { + cx.stop_propagation(); + cx.new(|_| pane.clone()) + }) + .on_mouse_down(MouseButton::Left, move |_, _, cx| { + cx.stop_propagation(); + }) + .on_mouse_up( + MouseButton::Left, + move |e: &gpui::MouseUpEvent, window, cx| { + if e.click_count == 2 { + workspace_handle + .update(cx, |workspace, cx| { + workspace.reset_utility_pane_width(slot, window, cx); + }) + .ok(); + cx.stop_propagation(); + } + }, + ) + .occlude(); + + match slot { + UtilityPaneSlot::Left => deferred( + handle + .absolute() + .right(-UTILITY_PANE_RESIZE_HANDLE_SIZE / 2.) + .top(px(0.)) + .h_full() + .w(UTILITY_PANE_RESIZE_HANDLE_SIZE) + .cursor_col_resize(), + ), + UtilityPaneSlot::Right => deferred( + handle + .absolute() + .left(-UTILITY_PANE_RESIZE_HANDLE_SIZE / 2.) + .top(px(0.)) + .h_full() + .w(UTILITY_PANE_RESIZE_HANDLE_SIZE) + .cursor_col_resize(), + ), + } + }; + + div() + .h_full() + .bg(cx.theme().colors().tab_bar_background) + .w(width) + .border_color(cx.theme().colors().border) + .when(self.slot == UtilityPaneSlot::Left, |this| this.border_r_1()) + .when(self.slot == UtilityPaneSlot::Right, |this| { + this.border_l_1() + }) + .child(create_resize_handle()) + .child(self.handle.to_any()) + .into_any_element() + } +} diff --git a/crates/workspace/src/welcome.rs b/crates/workspace/src/welcome.rs new file mode 100644 index 0000000000000000000000000000000000000000..4d84f3072f87ffa3246a313cbc749ddd61287d25 --- /dev/null +++ b/crates/workspace/src/welcome.rs @@ -0,0 +1,568 @@ +use crate::{ + NewFile, Open, PathList, SerializedWorkspaceLocation, WORKSPACE_DB, Workspace, WorkspaceId, + item::{Item, ItemEvent}, +}; +use git::Clone as GitClone; +use gpui::WeakEntity; +use gpui::{ + Action, App, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, + ParentElement, Render, Styled, Task, Window, actions, +}; +use menu::{SelectNext, SelectPrevious}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use ui::{ButtonLike, Divider, DividerColor, KeyBinding, Vector, VectorName, prelude::*}; +use util::ResultExt; +use zed_actions::{Extensions, OpenOnboarding, OpenSettings, agent, command_palette}; + +#[derive(PartialEq, Clone, Debug, Deserialize, Serialize, JsonSchema, Action)] +#[action(namespace = welcome)] +#[serde(transparent)] +pub struct OpenRecentProject { + pub index: usize, +} + +actions!( + zed, + [ + /// Show the Zed welcome screen + ShowWelcome + ] +); + +#[derive(IntoElement)] +struct SectionHeader { + title: SharedString, +} + +impl SectionHeader { + fn new(title: impl Into) -> Self { + Self { + title: title.into(), + } + } +} + +impl RenderOnce for SectionHeader { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + h_flex() + .px_1() + .mb_2() + .gap_2() + .child( + Label::new(self.title.to_ascii_uppercase()) + .buffer_font(cx) + .color(Color::Muted) + .size(LabelSize::XSmall), + ) + .child(Divider::horizontal().color(DividerColor::BorderVariant)) + } +} + +#[derive(IntoElement)] +struct SectionButton { + label: SharedString, + icon: IconName, + action: Box, + tab_index: usize, + focus_handle: FocusHandle, +} + +impl SectionButton { + fn new( + label: impl Into, + icon: IconName, + action: &dyn Action, + tab_index: usize, + focus_handle: FocusHandle, + ) -> Self { + Self { + label: label.into(), + icon, + action: action.boxed_clone(), + tab_index, + focus_handle, + } + } +} + +impl RenderOnce for SectionButton { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let id = format!("onb-button-{}", self.label); + let action_ref: &dyn Action = &*self.action; + + ButtonLike::new(id) + .tab_index(self.tab_index as isize) + .full_width() + .size(ButtonSize::Medium) + .child( + h_flex() + .w_full() + .justify_between() + .child( + h_flex() + .gap_2() + .child( + Icon::new(self.icon) + .color(Color::Muted) + .size(IconSize::Small), + ) + .child(Label::new(self.label)), + ) + .child( + KeyBinding::for_action_in(action_ref, &self.focus_handle, cx) + .size(rems_from_px(12.)), + ), + ) + .on_click(move |_, window, cx| window.dispatch_action(self.action.boxed_clone(), cx)) + } +} + +struct SectionEntry { + icon: IconName, + title: &'static str, + action: &'static dyn Action, +} + +impl SectionEntry { + fn render(&self, button_index: usize, focus: &FocusHandle, _cx: &App) -> impl IntoElement { + SectionButton::new( + self.title, + self.icon, + self.action, + button_index, + focus.clone(), + ) + } +} + +const CONTENT: (Section<4>, Section<3>) = ( + Section { + title: "Get Started", + entries: [ + SectionEntry { + icon: IconName::Plus, + title: "New File", + action: &NewFile, + }, + SectionEntry { + icon: IconName::FolderOpen, + title: "Open Project", + action: &Open, + }, + SectionEntry { + icon: IconName::CloudDownload, + title: "Clone Repository", + action: &GitClone, + }, + SectionEntry { + icon: IconName::ListCollapse, + title: "Open Command Palette", + action: &command_palette::Toggle, + }, + ], + }, + Section { + title: "Configure", + entries: [ + SectionEntry { + icon: IconName::Settings, + title: "Open Settings", + action: &OpenSettings, + }, + SectionEntry { + icon: IconName::ZedAssistant, + title: "View AI Settings", + action: &agent::OpenSettings, + }, + SectionEntry { + icon: IconName::Blocks, + title: "Explore Extensions", + action: &Extensions { + category_filter: None, + id: None, + }, + }, + ], + }, +); + +struct Section { + title: &'static str, + entries: [SectionEntry; COLS], +} + +impl Section { + fn render(self, index_offset: usize, focus: &FocusHandle, cx: &App) -> impl IntoElement { + v_flex() + .min_w_full() + .child(SectionHeader::new(self.title)) + .children( + self.entries + .iter() + .enumerate() + .map(|(index, entry)| entry.render(index_offset + index, focus, cx)), + ) + } +} + +pub struct WelcomePage { + workspace: WeakEntity, + focus_handle: FocusHandle, + fallback_to_recent_projects: bool, + recent_workspaces: Option>, +} + +impl WelcomePage { + pub fn new( + workspace: WeakEntity, + fallback_to_recent_projects: bool, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let focus_handle = cx.focus_handle(); + cx.on_focus(&focus_handle, window, |_, _, cx| cx.notify()) + .detach(); + + if fallback_to_recent_projects { + cx.spawn_in(window, async move |this: WeakEntity, cx| { + let workspaces = WORKSPACE_DB + .recent_workspaces_on_disk() + .await + .log_err() + .unwrap_or_default(); + + this.update(cx, |this, cx| { + this.recent_workspaces = Some(workspaces); + cx.notify(); + }) + .ok(); + }) + .detach(); + } + + WelcomePage { + workspace, + focus_handle, + fallback_to_recent_projects, + recent_workspaces: None, + } + } + + fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context) { + window.focus_next(cx); + cx.notify(); + } + + fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context) { + window.focus_prev(cx); + cx.notify(); + } + + fn open_recent_project( + &mut self, + action: &OpenRecentProject, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(recent_workspaces) = &self.recent_workspaces { + if let Some((_workspace_id, location, paths)) = recent_workspaces.get(action.index) { + let paths = paths.clone(); + let location = location.clone(); + let is_local = matches!(location, SerializedWorkspaceLocation::Local); + let workspace = self.workspace.clone(); + + if is_local { + let paths = paths.paths().to_vec(); + cx.spawn_in(window, async move |_, cx| { + let _ = workspace.update_in(cx, |workspace, window, cx| { + workspace + .open_workspace_for_paths(true, paths, window, cx) + .detach(); + }); + }) + .detach(); + } else { + use zed_actions::OpenRecent; + window.dispatch_action(OpenRecent::default().boxed_clone(), cx); + } + } + } + } + + fn render_recent_project_section( + &self, + recent_projects: Vec, + ) -> impl IntoElement { + v_flex() + .w_full() + .child(SectionHeader::new("Recent Projects")) + .children(recent_projects) + } + + fn render_recent_project( + &self, + index: usize, + location: &SerializedWorkspaceLocation, + paths: &PathList, + ) -> impl IntoElement { + let (icon, title) = match location { + SerializedWorkspaceLocation::Local => { + let path = paths.paths().first().map(|p| p.as_path()); + let name = path + .and_then(|p| p.file_name()) + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "Untitled".to_string()); + (IconName::Folder, name) + } + SerializedWorkspaceLocation::Remote(_) => { + (IconName::Server, "Remote Project".to_string()) + } + }; + + SectionButton::new( + title, + icon, + &OpenRecentProject { index }, + 10, + self.focus_handle.clone(), + ) + } +} + +impl Render for WelcomePage { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let (first_section, second_section) = CONTENT; + let first_section_entries = first_section.entries.len(); + let last_index = first_section_entries + second_section.entries.len(); + + let recent_projects = self + .recent_workspaces + .as_ref() + .into_iter() + .flatten() + .take(5) + .enumerate() + .map(|(index, (_, loc, paths))| self.render_recent_project(index, loc, paths)) + .collect::>(); + + let second_section = if self.fallback_to_recent_projects && !recent_projects.is_empty() { + self.render_recent_project_section(recent_projects) + .into_any_element() + } else { + second_section + .render(first_section_entries, &self.focus_handle, cx) + .into_any_element() + }; + + let welcome_label = if self.fallback_to_recent_projects { + "Welcome back to Zed" + } else { + "Welcome to Zed" + }; + + h_flex() + .key_context("Welcome") + .track_focus(&self.focus_handle(cx)) + .on_action(cx.listener(Self::select_previous)) + .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::open_recent_project)) + .size_full() + .justify_center() + .overflow_hidden() + .bg(cx.theme().colors().editor_background) + .child( + h_flex() + .relative() + .size_full() + .px_12() + .py_40() + .max_w(px(1100.)) + .child( + v_flex() + .size_full() + .max_w_128() + .mx_auto() + .gap_6() + .overflow_x_hidden() + .child( + h_flex() + .w_full() + .justify_center() + .mb_4() + .gap_4() + .child(Vector::square(VectorName::ZedLogo, rems_from_px(45.))) + .child( + v_flex().child(Headline::new(welcome_label)).child( + Label::new("The editor for what's next") + .size(LabelSize::Small) + .color(Color::Muted) + .italic(), + ), + ), + ) + .child(first_section.render(Default::default(), &self.focus_handle, cx)) + .child(second_section) + .when(!self.fallback_to_recent_projects, |this| { + this.child( + v_flex().gap_1().child(Divider::horizontal()).child( + Button::new("welcome-exit", "Return to Onboarding") + .tab_index(last_index as isize) + .full_width() + .label_size(LabelSize::XSmall) + .on_click(|_, window, cx| { + window.dispatch_action( + OpenOnboarding.boxed_clone(), + cx, + ); + }), + ), + ) + }), + ), + ) + } +} + +impl EventEmitter for WelcomePage {} + +impl Focusable for WelcomePage { + fn focus_handle(&self, _: &App) -> gpui::FocusHandle { + self.focus_handle.clone() + } +} + +impl Item for WelcomePage { + type Event = ItemEvent; + + fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { + "Welcome".into() + } + + fn telemetry_event_text(&self) -> Option<&'static str> { + Some("New Welcome Page Opened") + } + + fn show_toolbar(&self) -> bool { + false + } + + fn to_item_events(event: &Self::Event, mut f: impl FnMut(crate::item::ItemEvent)) { + f(*event) + } +} + +impl crate::SerializableItem for WelcomePage { + fn serialized_item_kind() -> &'static str { + "WelcomePage" + } + + fn cleanup( + workspace_id: crate::WorkspaceId, + alive_items: Vec, + _window: &mut Window, + cx: &mut App, + ) -> Task> { + crate::delete_unloaded_items( + alive_items, + workspace_id, + "welcome_pages", + &persistence::WELCOME_PAGES, + cx, + ) + } + + fn deserialize( + _project: Entity, + workspace: gpui::WeakEntity, + workspace_id: crate::WorkspaceId, + item_id: crate::ItemId, + window: &mut Window, + cx: &mut App, + ) -> Task>> { + if persistence::WELCOME_PAGES + .get_welcome_page(item_id, workspace_id) + .ok() + .is_some_and(|is_open| is_open) + { + Task::ready(Ok( + cx.new(|cx| WelcomePage::new(workspace, false, window, cx)) + )) + } else { + Task::ready(Err(anyhow::anyhow!("No welcome page to deserialize"))) + } + } + + fn serialize( + &mut self, + workspace: &mut Workspace, + item_id: crate::ItemId, + _closing: bool, + _window: &mut Window, + cx: &mut Context, + ) -> Option>> { + let workspace_id = workspace.database_id()?; + Some(cx.background_spawn(async move { + persistence::WELCOME_PAGES + .save_welcome_page(item_id, workspace_id, true) + .await + })) + } + + fn should_serialize(&self, event: &Self::Event) -> bool { + event == &ItemEvent::UpdateTab + } +} + +mod persistence { + use crate::WorkspaceDb; + use db::{ + query, + sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection}, + sqlez_macros::sql, + }; + + pub struct WelcomePagesDb(ThreadSafeConnection); + + impl Domain for WelcomePagesDb { + const NAME: &str = stringify!(WelcomePagesDb); + + const MIGRATIONS: &[&str] = (&[sql!( + CREATE TABLE welcome_pages ( + workspace_id INTEGER, + item_id INTEGER UNIQUE, + is_open INTEGER DEFAULT FALSE, + + PRIMARY KEY(workspace_id, item_id), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) + ON DELETE CASCADE + ) STRICT; + )]); + } + + db::static_connection!(WELCOME_PAGES, WelcomePagesDb, [WorkspaceDb]); + + impl WelcomePagesDb { + query! { + pub async fn save_welcome_page( + item_id: crate::ItemId, + workspace_id: crate::WorkspaceId, + is_open: bool + ) -> Result<()> { + INSERT OR REPLACE INTO welcome_pages(item_id, workspace_id, is_open) + VALUES (?, ?, ?) + } + } + + query! { + pub fn get_welcome_page( + item_id: crate::ItemId, + workspace_id: crate::WorkspaceId + ) -> Result { + SELECT is_open + FROM welcome_pages + WHERE item_id = ? AND workspace_id = ? + } + } + } +} diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index cc3ba7577ae6a0d8af889bcde174a00f185dd502..0c5c9ffa5d0bfb1f70ce6a861b0209f321222fc0 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -9,12 +9,15 @@ 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; mod theme_preview; mod toast_layer; mod toolbar; +pub mod utility_pane; +pub mod welcome; mod workspace_settings; pub use crate::notifications::NotificationFrame; @@ -30,6 +33,7 @@ use client::{ }; use collections::{HashMap, HashSet, hash_map}; use dock::{Dock, DockPosition, PanelButtons, PanelHandle, RESIZE_HANDLE_SIZE}; +use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt}; use futures::{ Future, FutureExt, StreamExt, channel::{ @@ -74,7 +78,9 @@ use project::{ DirectoryLister, Project, ProjectEntryId, ProjectPath, ResolvedPath, Worktree, WorktreeId, WorktreeSettings, debugger::{breakpoint_store::BreakpointStoreEvent, session::ThreadStatus}, + project_settings::ProjectSettings, toolchain_store::ToolchainStoreEvent, + trusted_worktrees::{TrustedWorktrees, TrustedWorktreesEvent}, }; use remote::{ RemoteClientDelegate, RemoteConnection, RemoteConnectionOptions, @@ -83,7 +89,9 @@ use remote::{ use schemars::JsonSchema; use serde::Deserialize; use session::AppSession; -use settings::{CenteredPaddingSettings, Settings, SettingsLocation, update_settings_file}; +use settings::{ + CenteredPaddingSettings, Settings, SettingsLocation, SettingsStore, update_settings_file, +}; use shared_screen::SharedScreen; use sqlez::{ bindable::{Bind, Column, StaticColumnCount}, @@ -126,11 +134,19 @@ pub use workspace_settings::{ }; use zed_actions::{Spawn, feedback::FileBugReport}; -use crate::persistence::{ - SerializedAxis, - model::{DockData, DockStructure, SerializedItem, SerializedPane, SerializedPaneGroup}, +use crate::{ + item::ItemBufferKind, + notifications::NotificationId, + utility_pane::{UTILITY_PANE_MIN_WIDTH, utility_slot_for_dock_position}, +}; +use crate::{ + persistence::{ + SerializedAxis, + model::{DockData, DockStructure, SerializedItem, SerializedPane, SerializedPaneGroup}, + }, + security_modal::SecurityModal, + utility_pane::{DraggedUtilityPane, UtilityPaneFrame, UtilityPaneSlot, UtilityPaneState}, }; -use crate::{item::ItemBufferKind, notifications::NotificationId}; pub const SERIALIZATION_THROTTLE_TIME: Duration = Duration::from_millis(200); @@ -265,6 +281,16 @@ actions!( ToggleRightDock, /// Toggles zoom on the active pane. ToggleZoom, + /// Zooms in on the active pane. + 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. @@ -569,44 +595,43 @@ pub fn init(app_state: Arc, cx: &mut App) { toast_layer::init(cx); history_manager::init(cx); - cx.on_action(|_: &CloseWindow, cx| Workspace::close_global(cx)); - cx.on_action(|_: &Reload, cx| reload(cx)); - - cx.on_action({ - let app_state = Arc::downgrade(&app_state); - move |_: &Open, cx: &mut App| { - if let Some(app_state) = app_state.upgrade() { - prompt_and_open_paths( - app_state, - PathPromptOptions { - files: true, - directories: true, - multiple: true, - prompt: None, - }, - cx, - ); + cx.on_action(|_: &CloseWindow, cx| Workspace::close_global(cx)) + .on_action(|_: &Reload, cx| reload(cx)) + .on_action({ + let app_state = Arc::downgrade(&app_state); + move |_: &Open, cx: &mut App| { + if let Some(app_state) = app_state.upgrade() { + prompt_and_open_paths( + app_state, + PathPromptOptions { + files: true, + directories: true, + multiple: true, + prompt: None, + }, + cx, + ); + } } - } - }); - cx.on_action({ - let app_state = Arc::downgrade(&app_state); - move |_: &OpenFiles, cx: &mut App| { - let directories = cx.can_select_mixed_files_and_dirs(); - if let Some(app_state) = app_state.upgrade() { - prompt_and_open_paths( - app_state, - PathPromptOptions { - files: true, - directories, - multiple: true, - prompt: None, - }, - cx, - ); + }) + .on_action({ + let app_state = Arc::downgrade(&app_state); + move |_: &OpenFiles, cx: &mut App| { + let directories = cx.can_select_mixed_files_and_dirs(); + if let Some(app_state) = app_state.upgrade() { + prompt_and_open_paths( + app_state, + PathPromptOptions { + files: true, + directories, + multiple: true, + prompt: None, + }, + cx, + ); + } } - } - }); + }); } type BuildProjectItemFn = @@ -963,6 +988,7 @@ impl AppState { #[cfg(any(test, feature = "test-support"))] pub fn test(cx: &mut App) -> Arc { + use fs::Fs; use node_runtime::NodeRuntime; use session::Session; use settings::SettingsStore; @@ -973,6 +999,7 @@ impl AppState { } let fs = fs::FakeFs::new(cx.background_executor().clone()); + ::set_global(fs.clone(), cx); let languages = Arc::new(LanguageRegistry::test(cx.background_executor().clone())); let clock = Arc::new(clock::FakeSystemClock::new()); let http_client = http_client::FakeHttpClient::with_404_response(); @@ -1161,6 +1188,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, @@ -1175,6 +1203,7 @@ pub struct Workspace { scheduled_tasks: Vec>, last_open_dock_positions: Vec, removing: bool, + utility_panes: UtilityPaneState, } impl EventEmitter for Workspace {} @@ -1205,6 +1234,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(_) => { @@ -1215,11 +1279,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 => { @@ -1316,7 +1394,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(); @@ -1340,7 +1418,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())); @@ -1431,6 +1509,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( @@ -1439,6 +1526,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(); @@ -1462,16 +1556,21 @@ 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); }); + + let mut center = PaneGroup::new(center_pane.clone()); + center.set_is_center(true); + center.mark_positions(cx); + Workspace { weak_self: weak_handle.clone(), zoomed: None, zoomed_position: None, previous_dock_drag_coordinates: None, - center: PaneGroup::new(center_pane.clone()), + center, panes: vec![center_pane.clone()], panes_by_item: Default::default(), active_pane: center_pane.clone(), @@ -1500,6 +1599,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, @@ -1519,6 +1619,7 @@ impl Workspace { scheduled_tasks: Vec::new(), last_open_dock_positions: Vec::new(), removing: false, + utility_panes: UtilityPaneState::default(), } } @@ -1527,6 +1628,7 @@ impl Workspace { app_state: Arc, requesting_window: Option>, env: Option>, + init: Option) + Send>>, cx: &mut App, ) -> Task< anyhow::Result<( @@ -1541,6 +1643,7 @@ impl Workspace { app_state.languages.clone(), app_state.fs.clone(), env, + true, cx, ); @@ -1633,6 +1736,12 @@ 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 }); })?; @@ -1642,15 +1751,15 @@ impl Workspace { let (window_bounds, display) = if let Some(bounds) = window_bounds_override { (Some(WindowBounds::Windowed(bounds)), None) - } else if let Some(workspace) = serialized_workspace.as_ref() { + } else if let Some(workspace) = serialized_workspace.as_ref() + && let Some(display) = workspace.display + && let Some(bounds) = workspace.window_bounds.as_ref() + { // Reopening an existing workspace - restore its saved bounds - if let (Some(display), Some(bounds)) = - (workspace.display, workspace.window_bounds.as_ref()) - { - (Some(bounds.0), Some(display)) - } else { - (None, None) - } + (Some(bounds.0), Some(display)) + } else if let Some((display, bounds)) = persistence::read_default_window_bounds() { + // New or empty workspace - use the last known window bounds + (Some(bounds), Some(display)) } else { // New window - let GPUI's default_bounds() handle cascading (None, None) @@ -1676,6 +1785,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 }) } @@ -1771,10 +1886,18 @@ impl Workspace { window: &mut Window, cx: &mut Context, ) { + let mut found_in_dock = None; for dock in [&self.left_dock, &self.bottom_dock, &self.right_dock] { - dock.update(cx, |dock, cx| { - dock.remove_panel(panel, window, cx); - }) + let found = dock.update(cx, |dock, cx| dock.remove_panel(panel, window, cx)); + + if found { + found_in_dock = Some(dock.clone()); + } + } + if let Some(found_in_dock) = found_in_dock { + let position = found_in_dock.read(cx).position(); + let slot = utility_slot_for_dock_position(position); + self.clear_utility_pane_if_provider(slot, Entity::entity_id(panel), cx); } } @@ -1935,7 +2058,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)?; @@ -2241,7 +2364,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) @@ -2452,6 +2575,12 @@ impl Workspace { .0 .split(' ') .flat_map(|k| Keystroke::parse(k).log_err()) + .map(|k| { + cx.keyboard_mapper() + .map_key_equivalent(k, true) + .inner() + .clone() + }) .collect(); let _ = self.send_keystrokes_impl(keystrokes, window, cx); } @@ -3048,7 +3177,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; } } @@ -3060,7 +3189,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(); @@ -3228,7 +3357,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; } @@ -3238,7 +3367,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; @@ -3312,7 +3441,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 { @@ -3343,7 +3472,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 @@ -3738,7 +3867,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(); @@ -3765,7 +3894,7 @@ impl Workspace { let new_pane = self.add_pane(window, cx); if self .center - .split(&split_off_pane, &new_pane, direction) + .split(&split_off_pane, &new_pane, direction, cx) .log_err() .is_none() { @@ -3808,7 +3937,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); } } @@ -3817,7 +3946,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); } } @@ -3913,7 +4042,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", @@ -3925,7 +4054,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()); } @@ -3950,7 +4079,7 @@ impl Workspace { let new_pane = self.add_pane(window, cx); if self .center - .split(&self.active_pane, &new_pane, action.direction) + .split(&self.active_pane, &new_pane, action.direction, cx) .log_err() .is_none() { @@ -4004,7 +4133,7 @@ impl Workspace { pub fn swap_pane_in_direction(&mut self, direction: SplitDirection, cx: &mut Context) { if let Some(to) = self.find_pane_in_direction(direction, cx) { - self.center.swap(&self.active_pane, &to); + self.center.swap(&self.active_pane, &to, cx); cx.notify(); } } @@ -4012,7 +4141,7 @@ impl Workspace { pub fn move_pane_to_border(&mut self, direction: SplitDirection, cx: &mut Context) { if self .center - .move_to_border(&self.active_pane, direction) + .move_to_border(&self.active_pane, direction, cx) .unwrap() { cx.notify(); @@ -4042,13 +4171,13 @@ impl Workspace { } } else { self.center - .resize(&self.active_pane, axis, amount, &self.bounds); + .resize(&self.active_pane, axis, amount, &self.bounds, cx); } cx.notify(); } pub fn reset_pane_sizes(&mut self, cx: &mut Context) { - self.center.reset_pane_sizes(); + self.center.reset_pane_sizes(cx); cx.notify(); } @@ -4234,7 +4363,7 @@ impl Workspace { ) -> Entity { let new_pane = self.add_pane(window, cx); self.center - .split(&pane_to_split, &new_pane, split_direction) + .split(&pane_to_split, &new_pane, split_direction, cx) .unwrap(); cx.notify(); new_pane @@ -4254,7 +4383,7 @@ impl Workspace { new_pane.update(cx, |pane, cx| { pane.add_item(item, true, true, None, window, cx) }); - self.center.split(&pane, &new_pane, direction).unwrap(); + self.center.split(&pane, &new_pane, direction, cx).unwrap(); cx.notify(); } @@ -4279,7 +4408,7 @@ impl Workspace { new_pane.update(cx, |pane, cx| { pane.add_item(clone, true, true, None, window, cx) }); - this.center.split(&pane, &new_pane, direction).unwrap(); + this.center.split(&pane, &new_pane, direction, cx).unwrap(); cx.notify(); new_pane }) @@ -4326,7 +4455,7 @@ impl Workspace { window: &mut Window, cx: &mut Context, ) { - if self.center.remove(&pane).unwrap() { + if self.center.remove(&pane, cx).unwrap() { self.force_remove_pane(&pane, &focus_on, window, cx); self.unfollow_in_pane(&pane, window, cx); self.last_leaders_by_pane.remove(&pane.downgrade()); @@ -4545,7 +4674,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; } @@ -5357,12 +5486,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; @@ -5536,12 +5665,24 @@ impl Workspace { persistence::DB.save_workspace(serialized_workspace).await; }) } - WorkspaceLocation::DetachFromSession => window.spawn(cx, async move |_| { - persistence::DB - .set_session_id(database_id, None) - .await - .log_err(); - }), + WorkspaceLocation::DetachFromSession => { + let window_bounds = SerializedWindowBounds(window.window_bounds()); + let display = window.display(cx).and_then(|d| d.uuid().ok()); + window.spawn(cx, async move |_| { + persistence::DB + .set_window_open_status( + database_id, + window_bounds, + display.unwrap_or_default(), + ) + .await + .log_err(); + persistence::DB + .set_session_id(database_id, None) + .await + .log_err(); + }) + } WorkspaceLocation::None => Task::ready(()), } } @@ -5678,6 +5819,9 @@ impl Workspace { // Swap workspace center group workspace.center = PaneGroup::with_root(center_group); + workspace.center.set_is_center(true); + workspace.center.mark_positions(cx); + if let Some(active_pane) = active_pane { workspace.set_active_pane(&active_pane, window, cx); cx.focus_self(window); @@ -5911,6 +6055,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(); @@ -6096,7 +6261,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 } @@ -6303,6 +6468,7 @@ impl Workspace { left_dock.resize_active_panel(Some(size), window, cx); } }); + self.clamp_utility_pane_widths(window, cx); } fn resize_right_dock(&mut self, new_size: Pixels, window: &mut Window, cx: &mut App) { @@ -6325,6 +6491,7 @@ impl Workspace { right_dock.resize_active_panel(Some(size), window, cx); } }); + self.clamp_utility_pane_widths(window, cx); } fn resize_bottom_dock(&mut self, new_size: Pixels, window: &mut Window, cx: &mut App) { @@ -6339,6 +6506,42 @@ impl Workspace { bottom_dock.resize_active_panel(Some(size), window, cx); } }); + self.clamp_utility_pane_widths(window, cx); + } + + fn max_utility_pane_width(&self, window: &Window, cx: &App) -> Pixels { + let left_dock_width = self + .left_dock + .read(cx) + .active_panel_size(window, cx) + .unwrap_or(px(0.0)); + let right_dock_width = self + .right_dock + .read(cx) + .active_panel_size(window, cx) + .unwrap_or(px(0.0)); + let center_pane_width = self.bounds.size.width - left_dock_width - right_dock_width; + center_pane_width - px(10.0) + } + + fn clamp_utility_pane_widths(&mut self, window: &mut Window, cx: &mut App) { + let max_width = self.max_utility_pane_width(window, cx); + + // Clamp left slot utility pane if it exists + if let Some(handle) = self.utility_pane(UtilityPaneSlot::Left) { + let current_width = handle.width(cx); + if current_width > max_width { + handle.set_width(Some(max_width.max(UTILITY_PANE_MIN_WIDTH)), cx); + } + } + + // Clamp right slot utility pane if it exists + if let Some(handle) = self.utility_pane(UtilityPaneSlot::Right) { + let current_width = handle.width(cx); + if current_width > max_width { + handle.set_width(Some(max_width.max(UTILITY_PANE_MIN_WIDTH)), cx); + } + } } fn toggle_edit_predictions_all_files( @@ -6353,6 +6556,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( @@ -6806,6 +7051,34 @@ impl Render for Workspace { } }, )) + .on_drag_move(cx.listener( + move |workspace, + e: &DragMoveEvent, + window, + cx| { + let slot = e.drag(cx).0; + match slot { + UtilityPaneSlot::Left => { + let left_dock_width = workspace.left_dock.read(cx) + .active_panel_size(window, cx) + .unwrap_or(gpui::px(0.0)); + let new_width = e.event.position.x + - workspace.bounds.left() + - left_dock_width; + workspace.resize_utility_pane(slot, new_width, window, cx); + } + UtilityPaneSlot::Right => { + let right_dock_width = workspace.right_dock.read(cx) + .active_panel_size(window, cx) + .unwrap_or(gpui::px(0.0)); + let new_width = workspace.bounds.right() + - e.event.position.x + - right_dock_width; + workspace.resize_utility_pane(slot, new_width, window, cx); + } + } + }, + )) }) .child({ match bottom_dock_layout { @@ -6825,6 +7098,15 @@ impl Render for Workspace { window, cx, )) + .when(cx.has_flag::(), |this| { + this.when_some(self.utility_pane(UtilityPaneSlot::Left), |this, pane| { + this.when(pane.expanded(cx), |this| { + this.child( + UtilityPaneFrame::new(UtilityPaneSlot::Left, pane.box_clone(), cx) + ) + }) + }) + }) .child( div() .flex() @@ -6866,6 +7148,15 @@ impl Render for Workspace { ), ), ) + .when(cx.has_flag::(), |this| { + this.when_some(self.utility_pane(UtilityPaneSlot::Right), |this, pane| { + this.when(pane.expanded(cx), |this| { + this.child( + UtilityPaneFrame::new(UtilityPaneSlot::Right, pane.box_clone(), cx) + ) + }) + }) + }) .children(self.render_dock( DockPosition::Right, &self.right_dock, @@ -6896,6 +7187,15 @@ impl Render for Workspace { .flex_row() .flex_1() .children(self.render_dock(DockPosition::Left, &self.left_dock, window, cx)) + .when(cx.has_flag::(), |this| { + this.when_some(self.utility_pane(UtilityPaneSlot::Left), |this, pane| { + this.when(pane.expanded(cx), |this| { + this.child( + UtilityPaneFrame::new(UtilityPaneSlot::Left, pane.box_clone(), cx) + ) + }) + }) + }) .child( div() .flex() @@ -6923,6 +7223,13 @@ impl Render for Workspace { .when_some(paddings.1, |this, p| this.child(p.border_l_1())), ) ) + .when_some(self.utility_pane(UtilityPaneSlot::Right), |this, pane| { + this.when(pane.expanded(cx), |this| { + this.child( + UtilityPaneFrame::new(UtilityPaneSlot::Right, pane.box_clone(), cx) + ) + }) + }) ) .child( div() @@ -6947,6 +7254,15 @@ impl Render for Workspace { window, cx, )) + .when(cx.has_flag::(), |this| { + this.when_some(self.utility_pane(UtilityPaneSlot::Left), |this, pane| { + this.when(pane.expanded(cx), |this| { + this.child( + UtilityPaneFrame::new(UtilityPaneSlot::Left, pane.box_clone(), cx) + ) + }) + }) + }) .child( div() .flex() @@ -6985,6 +7301,15 @@ impl Render for Workspace { .when_some(paddings.1, |this, p| this.child(p.border_l_1())), ) ) + .when(cx.has_flag::(), |this| { + this.when_some(self.utility_pane(UtilityPaneSlot::Right), |this, pane| { + this.when(pane.expanded(cx), |this| { + this.child( + UtilityPaneFrame::new(UtilityPaneSlot::Right, pane.box_clone(), cx) + ) + }) + }) + }) .children(self.render_dock(DockPosition::Right, &self.right_dock, window, cx)) ) .child( @@ -7004,6 +7329,13 @@ impl Render for Workspace { window, cx, )) + .when_some(self.utility_pane(UtilityPaneSlot::Left), |this, pane| { + this.when(pane.expanded(cx), |this| { + this.child( + UtilityPaneFrame::new(UtilityPaneSlot::Left, pane.box_clone(), cx) + ) + }) + }) .child( div() .flex() @@ -7041,6 +7373,15 @@ impl Render for Workspace { cx, )), ) + .when(cx.has_flag::(), |this| { + this.when_some(self.utility_pane(UtilityPaneSlot::Right), |this, pane| { + this.when(pane.expanded(cx), |this| { + this.child( + UtilityPaneFrame::new(UtilityPaneSlot::Right, pane.box_clone(), cx) + ) + }) + }) + }) .children(self.render_dock( DockPosition::Right, &self.right_dock, @@ -7443,7 +7784,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?; @@ -7509,7 +7857,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") @@ -7676,6 +8024,7 @@ pub fn open_paths( app_state.clone(), open_options.replace_window, open_options.env, + None, cx, ) })? @@ -7720,14 +8069,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(()) }) } @@ -7780,7 +8132,7 @@ pub fn open_remote_project_with_new_connection( ) -> Task>>>> { cx.spawn(async move |cx| { let (workspace_id, serialized_workspace) = - serialize_remote_project(remote_connection.connection_options(), paths.clone(), cx) + deserialize_remote_project(remote_connection.connection_options(), paths.clone(), cx) .await?; let session = match cx @@ -7807,6 +8159,7 @@ pub fn open_remote_project_with_new_connection( app_state.user_store.clone(), app_state.languages.clone(), app_state.fs.clone(), + true, cx, ) })?; @@ -7834,7 +8187,7 @@ pub fn open_remote_project_with_existing_connection( ) -> Task>>>> { cx.spawn(async move |cx| { let (workspace_id, serialized_workspace) = - serialize_remote_project(connection_options.clone(), paths.clone(), cx).await?; + deserialize_remote_project(connection_options.clone(), paths.clone(), cx).await?; open_remote_project_inner( project, @@ -7936,7 +8289,7 @@ async fn open_remote_project_inner( Ok(items.into_iter().map(|item| item?.ok()).collect()) } -fn serialize_remote_project( +fn deserialize_remote_project( connection_options: RemoteConnectionOptions, paths: Vec, cx: &AsyncApp, @@ -8382,7 +8735,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) }); } } @@ -8426,7 +8779,7 @@ pub fn move_item( cx, ); if activate { - window.focus(&destination.focus_handle(cx)) + window.focus(&destination.focus_handle(cx), cx) } }); } @@ -8528,14 +8881,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) } @@ -9438,6 +9790,105 @@ mod tests { }); } + #[gpui::test] + async fn test_pane_zoom_in_out(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + let project = Project::test(fs, [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + + let pane = workspace.update_in(cx, |workspace, _window, _cx| { + workspace.active_pane().clone() + }); + + // Add an item to the pane so it can be zoomed + workspace.update_in(cx, |workspace, window, cx| { + let item = cx.new(TestItem::new); + workspace.add_item(pane.clone(), Box::new(item), None, true, true, window, cx); + }); + + // Initially not zoomed + workspace.update_in(cx, |workspace, _window, cx| { + assert!(!pane.read(cx).is_zoomed(), "Pane starts unzoomed"); + assert!( + workspace.zoomed.is_none(), + "Workspace should track no zoomed pane" + ); + assert!(pane.read(cx).items_len() > 0, "Pane should have items"); + }); + + // Zoom In + pane.update_in(cx, |pane, window, cx| { + pane.zoom_in(&crate::ZoomIn, window, cx); + }); + + workspace.update_in(cx, |workspace, window, cx| { + assert!( + pane.read(cx).is_zoomed(), + "Pane should be zoomed after ZoomIn" + ); + assert!( + workspace.zoomed.is_some(), + "Workspace should track the zoomed pane" + ); + assert!( + pane.read(cx).focus_handle(cx).contains_focused(window, cx), + "ZoomIn should focus the pane" + ); + }); + + // Zoom In again is a no-op + pane.update_in(cx, |pane, window, cx| { + pane.zoom_in(&crate::ZoomIn, window, cx); + }); + + workspace.update_in(cx, |workspace, window, cx| { + assert!(pane.read(cx).is_zoomed(), "Second ZoomIn keeps pane zoomed"); + assert!( + workspace.zoomed.is_some(), + "Workspace still tracks zoomed pane" + ); + assert!( + pane.read(cx).focus_handle(cx).contains_focused(window, cx), + "Pane remains focused after repeated ZoomIn" + ); + }); + + // Zoom Out + pane.update_in(cx, |pane, window, cx| { + pane.zoom_out(&crate::ZoomOut, window, cx); + }); + + workspace.update_in(cx, |workspace, _window, cx| { + assert!( + !pane.read(cx).is_zoomed(), + "Pane should unzoom after ZoomOut" + ); + assert!( + workspace.zoomed.is_none(), + "Workspace clears zoom tracking after ZoomOut" + ); + }); + + // Zoom Out again is a no-op + pane.update_in(cx, |pane, window, cx| { + pane.zoom_out(&crate::ZoomOut, window, cx); + }); + + workspace.update_in(cx, |workspace, _window, cx| { + assert!( + !pane.read(cx).is_zoomed(), + "Second ZoomOut keeps pane unzoomed" + ); + assert!( + workspace.zoomed.is_none(), + "Workspace remains without zoomed pane" + ); + }); + } + #[gpui::test] async fn test_toggle_all_docks(cx: &mut gpui::TestAppContext) { init_test(cx); diff --git a/crates/worktree/Cargo.toml b/crates/worktree/Cargo.toml index 6d132fbd2cb8c7a1282bffcea6577260a15c4572..e7d3ac34e1886bd76e0a0f5d23ea981b6626909a 100644 --- a/crates/worktree/Cargo.toml +++ b/crates/worktree/Cargo.toml @@ -25,8 +25,10 @@ test-support = [ [dependencies] anyhow.workspace = true async-lock.workspace = true +chardetng.workspace = true clock.workspace = true collections.workspace = true +encoding_rs.workspace = true fs.workspace = true futures.workspace = true fuzzy.workspace = true 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 4df7a93f13e3c1ff80f716141a2db727b7a5e693..7145bccd514fbb5d6093efda765a826162c91260 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -5,8 +5,10 @@ mod worktree_tests; use ::ignore::gitignore::{Gitignore, GitignoreBuilder}; use anyhow::{Context as _, Result, anyhow}; +use chardetng::EncodingDetector; use clock::ReplicaId; use collections::{HashMap, HashSet, VecDeque}; +use encoding_rs::Encoding; use fs::{Fs, MTime, PathEvent, RemoveOptions, Watcher, copy_recursive, read_dir_items}; use futures::{ FutureExt as _, Stream, StreamExt, @@ -14,15 +16,17 @@ use futures::{ mpsc::{self, UnboundedSender}, oneshot, }, - select_biased, + select_biased, stream, task::Poll, }; 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, Task, + App, AppContext as _, AsyncApp, BackgroundExecutor, Context, Entity, EventEmitter, Priority, + Task, }; use ignore::IgnoreStack; use language::DiskState; @@ -70,6 +74,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. @@ -97,9 +103,12 @@ pub enum CreatedEntry { Excluded { abs_path: PathBuf }, } +#[derive(Debug)] pub struct LoadedFile { pub file: Arc, pub text: String, + pub encoding: &'static Encoding, + pub has_bom: bool, } pub struct LoadedBinaryFile { @@ -129,6 +138,7 @@ pub struct LocalWorktree { next_entry_id: Arc, settings: WorktreeSettings, share_private_files: bool, + scanning_enabled: bool, } pub struct PathPrefixScanRequest { @@ -230,6 +240,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)>, @@ -356,6 +369,7 @@ impl Worktree { visible: bool, fs: Arc, next_entry_id: Arc, + scanning_enabled: bool, cx: &mut AsyncApp, ) -> Result> { let abs_path = path.into(); @@ -389,6 +403,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(), @@ -459,6 +474,7 @@ impl Worktree { fs_case_sensitive, visible, settings, + scanning_enabled, }; worktree.start_background_scanner(scan_requests_rx, path_prefixes_to_scan_rx, cx); Worktree::Local(worktree) @@ -729,10 +745,14 @@ impl Worktree { path: Arc, text: Rope, line_ending: LineEnding, + encoding: &'static Encoding, + has_bom: bool, cx: &Context, ) -> Task>> { match self { - Worktree::Local(this) => this.write_file(path, text, line_ending, cx), + Worktree::Local(this) => { + this.write_file(path, text, line_ending, encoding, has_bom, cx) + } Worktree::Remote(_) => { Task::ready(Err(anyhow!("remote worktree can't yet write files"))) } @@ -1049,13 +1069,18 @@ impl LocalWorktree { let share_private_files = self.share_private_files; let next_entry_id = self.next_entry_id.clone(); let fs = self.fs.clone(); + let scanning_enabled = self.scanning_enabled; let settings = self.settings.clone(); let (scan_states_tx, mut scan_states_rx) = mpsc::unbounded(); let background_scanner = cx.background_spawn({ let abs_path = snapshot.abs_path.as_path().to_path_buf(); let background = cx.background_executor().clone(); async move { - let (events, watcher) = fs.watch(&abs_path, FS_WATCH_LATENCY).await; + let (events, watcher) = if scanning_enabled { + fs.watch(&abs_path, FS_WATCH_LATENCY).await + } else { + (Box::pin(stream::pending()) as _, Arc::new(NullWatcher) as _) + }; let fs_case_sensitive = fs.is_case_sensitive().await.unwrap_or_else(|e| { log::error!("Failed to determine whether filesystem is case sensitive: {e:#}"); true @@ -1080,6 +1105,7 @@ impl LocalWorktree { }), phase: BackgroundScannerPhase::InitialScan, share_private_files, + scanning_enabled, settings, watcher, }; @@ -1333,7 +1359,9 @@ impl LocalWorktree { anyhow::bail!("File is too large to load"); } } - let text = fs.load(&abs_path).await?; + + let content = fs.load_bytes(&abs_path).await?; + let (text, encoding, has_bom) = decode_byte(content); let worktree = this.upgrade().context("worktree was dropped")?; let file = match entry.await? { @@ -1361,7 +1389,12 @@ impl LocalWorktree { } }; - Ok(LoadedFile { file, text }) + Ok(LoadedFile { + file, + text, + encoding, + has_bom, + }) }) } @@ -1444,6 +1477,8 @@ impl LocalWorktree { path: Arc, text: Rope, line_ending: LineEnding, + encoding: &'static Encoding, + has_bom: bool, cx: &Context, ) -> Task>> { let fs = self.fs.clone(); @@ -1453,7 +1488,49 @@ impl LocalWorktree { let write = cx.background_spawn({ let fs = fs.clone(); let abs_path = abs_path.clone(); - async move { fs.save(&abs_path, &text, line_ending).await } + async move { + let bom_bytes = if has_bom { + if encoding == encoding_rs::UTF_16LE { + vec![0xFF, 0xFE] + } else if encoding == encoding_rs::UTF_16BE { + vec![0xFE, 0xFF] + } else if encoding == encoding_rs::UTF_8 { + vec![0xEF, 0xBB, 0xBF] + } else { + vec![] + } + } else { + vec![] + }; + + // For UTF-8, use the optimized `fs.save` which writes Rope chunks directly to disk + // without allocating a contiguous string. + if encoding == encoding_rs::UTF_8 && !has_bom { + return fs.save(&abs_path, &text, line_ending).await; + } + // For legacy encodings (e.g. Shift-JIS), we fall back to converting the entire Rope + // to a String/Bytes in memory before writing. + // + // Note: This is inefficient for very large files compared to the streaming approach above, + // but supporting streaming writes for arbitrary encodings would require a significant + // refactor of the `fs` crate to expose a Writer interface. + let text_string = text.to_string(); + let normalized_text = match line_ending { + LineEnding::Unix => text_string, + LineEnding::Windows => text_string.replace('\n', "\r\n"), + }; + + let (cow, _, _) = encoding.encode(&normalized_text); + let bytes = if !bom_bytes.is_empty() { + let mut bytes = bom_bytes; + bytes.extend_from_slice(&cow); + bytes.into() + } else { + cow + }; + + fs.write(&abs_path, &bytes).await + } }); cx.spawn(async move |this, cx| { @@ -2554,13 +2631,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); } } @@ -3617,6 +3702,7 @@ struct BackgroundScanner { watcher: Arc, settings: WorktreeSettings, share_private_files: bool, + scanning_enabled: bool, } #[derive(Copy, Clone, PartialEq)] @@ -3632,14 +3718,33 @@ impl BackgroundScanner { // the git repository in an ancestor directory. Find any gitignore files // in ancestor directories. let root_abs_path = self.state.lock().await.snapshot.abs_path.clone(); - let (ignores, repo) = discover_ancestor_git_repo(self.fs.clone(), &root_abs_path).await; - self.state - .lock() - .await - .snapshot - .ignores_by_parent_abs_path - .extend(ignores); - let containing_git_repository = if let Some((ancestor_dot_git, work_directory)) = repo { + + let repo = if self.scanning_enabled { + 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 + }; + + let containing_git_repository = if let Some((ancestor_dot_git, work_directory)) = repo + && self.scanning_enabled + { maybe!(async { self.state .lock() @@ -3663,6 +3768,7 @@ impl BackgroundScanner { let mut global_gitignore_events = if let Some(global_gitignore_path) = &paths::global_gitignore_path() + && self.scanning_enabled { let is_file = self.fs.is_file(&global_gitignore_path).await; self.state.lock().await.snapshot.global_gitignore = if is_file { @@ -3705,7 +3811,7 @@ impl BackgroundScanner { .insert_entry(root_entry, self.fs.as_ref(), self.watcher.as_ref()) .await; } - if root_entry.is_dir() { + if root_entry.is_dir() && self.scanning_enabled { state .enqueue_scan_dir( root_abs_path.as_path().into(), @@ -3892,6 +3998,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)); { @@ -3965,6 +4072,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 @@ -4010,6 +4129,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(); @@ -4123,7 +4255,7 @@ impl BackgroundScanner { let progress_update_count = AtomicUsize::new(0); self.executor - .scoped(|scope| { + .scoped_priority(Priority::Low, |scope| { for _ in 0..self.executor.num_cpus() { scope.spawn(async { let mut last_progress_update_count = 0; @@ -4277,7 +4409,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) => { @@ -4539,11 +4672,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, @@ -4641,6 +4787,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)| { @@ -4695,7 +4871,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(); @@ -4870,6 +5047,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 }); @@ -4909,8 +5089,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 { @@ -4946,6 +5128,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 { @@ -4957,12 +5140,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( @@ -5641,3 +5829,52 @@ async fn discover_git_paths(dot_git_abs_path: &Arc, fs: &dyn Fs) -> (Arc

Result<()> { + Ok(()) + } + + fn remove(&self, _path: &Path) -> Result<()> { + Ok(()) + } +} + +fn decode_byte(bytes: Vec) -> (String, &'static Encoding, bool) { + // check BOM + if let Some((encoding, _bom_len)) = Encoding::for_bom(&bytes) { + let (cow, _) = encoding.decode_with_bom_removal(&bytes); + return (cow.into_owned(), encoding, true); + } + + fn detect_encoding(bytes: Vec) -> (String, &'static Encoding) { + let mut detector = EncodingDetector::new(); + detector.feed(&bytes, true); + + let encoding = detector.guess(None, true); // Use None for TLD hint to ensure neutral detection logic. + + let (cow, _, _) = encoding.decode(&bytes); + (cow.into_owned(), encoding) + } + + match String::from_utf8(bytes) { + Ok(text) => { + // ISO-2022-JP (and other ISO-2022 variants) consists entirely of 7-bit ASCII bytes, + // so it is valid UTF-8. However, it contains escape sequences starting with '\x1b'. + // If we find an escape character, we double-check the encoding to prevent + // displaying raw escape sequences instead of the correct characters. + if text.contains('\x1b') { + let (s, enc) = detect_encoding(text.into_bytes()); + (s, enc, false) + } else { + (text, encoding_rs::UTF_8, false) + } + } + Err(e) => { + let (s, enc) = detect_encoding(e.into_bytes()); + (s, enc, false) + } + } +} diff --git a/crates/worktree/src/worktree_tests.rs b/crates/worktree/src/worktree_tests.rs index 08086118aacb37215227690532b927b3c7c46123..094a6d52ea4168752578eab06cea511a57e65c10 100644 --- a/crates/worktree/src/worktree_tests.rs +++ b/crates/worktree/src/worktree_tests.rs @@ -1,7 +1,8 @@ use crate::{Entry, EntryKind, Event, PathChange, Worktree, WorktreeModelHandle}; -use anyhow::Result; +use anyhow::{Context as _, Result}; +use encoding_rs; 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; @@ -19,6 +20,7 @@ use std::{ }; use util::{ ResultExt, path, + paths::PathStyle, rel_path::{RelPath, rel_path}, test::TempTree, }; @@ -44,6 +46,7 @@ async fn test_traversal(cx: &mut TestAppContext) { true, fs, Default::default(), + true, &mut cx.to_async(), ) .await @@ -108,6 +111,7 @@ async fn test_circular_symlinks(cx: &mut TestAppContext) { true, fs.clone(), Default::default(), + true, &mut cx.to_async(), ) .await @@ -207,6 +211,7 @@ async fn test_symlinks_pointing_outside(cx: &mut TestAppContext) { true, fs.clone(), Default::default(), + true, &mut cx.to_async(), ) .await @@ -357,6 +362,7 @@ async fn test_renaming_case_only(cx: &mut TestAppContext) { true, fs.clone(), Default::default(), + true, &mut cx.to_async(), ) .await @@ -434,6 +440,7 @@ async fn test_open_gitignored_files(cx: &mut TestAppContext) { true, fs.clone(), Default::default(), + true, &mut cx.to_async(), ) .await @@ -598,6 +605,7 @@ async fn test_dirs_no_longer_ignored(cx: &mut TestAppContext) { true, fs.clone(), Default::default(), + true, &mut cx.to_async(), ) .await @@ -698,6 +706,7 @@ async fn test_write_file(cx: &mut TestAppContext) { true, Arc::new(RealFs::new(None, cx.executor())), Default::default(), + true, &mut cx.to_async(), ) .await @@ -716,6 +725,8 @@ async fn test_write_file(cx: &mut TestAppContext) { rel_path("tracked-dir/file.txt").into(), "hello".into(), Default::default(), + encoding_rs::UTF_8, + false, cx, ) }) @@ -727,6 +738,8 @@ async fn test_write_file(cx: &mut TestAppContext) { rel_path("ignored-dir/file.txt").into(), "world".into(), Default::default(), + encoding_rs::UTF_8, + false, cx, ) }) @@ -791,6 +804,7 @@ async fn test_file_scan_inclusions(cx: &mut TestAppContext) { true, Arc::new(RealFs::new(None, cx.executor())), Default::default(), + true, &mut cx.to_async(), ) .await @@ -856,6 +870,7 @@ async fn test_file_scan_exclusions_overrules_inclusions(cx: &mut TestAppContext) true, Arc::new(RealFs::new(None, cx.executor())), Default::default(), + true, &mut cx.to_async(), ) .await @@ -914,6 +929,7 @@ async fn test_file_scan_inclusions_reindexes_on_setting_change(cx: &mut TestAppC true, Arc::new(RealFs::new(None, cx.executor())), Default::default(), + true, &mut cx.to_async(), ) .await @@ -999,6 +1015,7 @@ async fn test_file_scan_exclusions(cx: &mut TestAppContext) { true, Arc::new(RealFs::new(None, cx.executor())), Default::default(), + true, &mut cx.to_async(), ) .await @@ -1080,6 +1097,7 @@ async fn test_hidden_files(cx: &mut TestAppContext) { true, Arc::new(RealFs::new(None, cx.executor())), Default::default(), + true, &mut cx.to_async(), ) .await @@ -1190,6 +1208,7 @@ async fn test_fs_events_in_exclusions(cx: &mut TestAppContext) { true, Arc::new(RealFs::new(None, cx.executor())), Default::default(), + true, &mut cx.to_async(), ) .await @@ -1301,6 +1320,7 @@ async fn test_fs_events_in_dot_git_worktree(cx: &mut TestAppContext) { true, Arc::new(RealFs::new(None, cx.executor())), Default::default(), + true, &mut cx.to_async(), ) .await @@ -1339,6 +1359,7 @@ async fn test_create_directory_during_initial_scan(cx: &mut TestAppContext) { true, fs, Default::default(), + true, &mut cx.to_async(), ) .await @@ -1407,6 +1428,7 @@ async fn test_create_dir_all_on_create_entry(cx: &mut TestAppContext) { true, fs_fake, Default::default(), + true, &mut cx.to_async(), ) .await @@ -1448,6 +1470,7 @@ async fn test_create_dir_all_on_create_entry(cx: &mut TestAppContext) { true, fs_real, Default::default(), + true, &mut cx.to_async(), ) .await @@ -1556,6 +1579,7 @@ async fn test_create_file_in_expanded_gitignored_dir(cx: &mut TestAppContext) { true, fs.clone(), Default::default(), + true, &mut cx.to_async(), ) .await @@ -1651,6 +1675,7 @@ async fn test_fs_event_for_gitignored_dir_does_not_lose_contents(cx: &mut TestAp true, fs.clone(), Default::default(), + true, &mut cx.to_async(), ) .await @@ -1728,6 +1753,7 @@ async fn test_random_worktree_operations_during_initial_scan( true, fs.clone(), Default::default(), + true, &mut cx.to_async(), ) .await @@ -1818,6 +1844,7 @@ async fn test_random_worktree_changes(cx: &mut TestAppContext, mut rng: StdRng) true, fs.clone(), Default::default(), + true, &mut cx.to_async(), ) .await @@ -1890,6 +1917,7 @@ async fn test_random_worktree_changes(cx: &mut TestAppContext, mut rng: StdRng) true, fs.clone(), Default::default(), + true, &mut cx.to_async(), ) .await @@ -2013,8 +2041,14 @@ fn randomly_mutate_worktree( }) } else { log::info!("overwriting file {:?} ({})", &entry.path, entry.id.0); - let task = - worktree.write_file(entry.path.clone(), "".into(), Default::default(), cx); + let task = worktree.write_file( + entry.path.clone(), + "".into(), + Default::default(), + encoding_rs::UTF_8, + false, + cx, + ); cx.background_spawn(async move { task.await?; Ok(()) @@ -2203,6 +2237,7 @@ async fn test_private_single_file_worktree(cx: &mut TestAppContext) { true, fs.clone(), Default::default(), + true, &mut cx.to_async(), ) .await @@ -2235,6 +2270,7 @@ async fn test_repository_above_root(executor: BackgroundExecutor, cx: &mut TestA true, fs.clone(), Arc::default(), + true, &mut cx.to_async(), ) .await @@ -2312,6 +2348,7 @@ async fn test_global_gitignore(executor: BackgroundExecutor, cx: &mut TestAppCon true, fs.clone(), Arc::default(), + true, &mut cx.to_async(), ) .await @@ -2387,6 +2424,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, @@ -2439,3 +2564,176 @@ fn init_test(cx: &mut gpui::TestAppContext) { cx.set_global(settings_store); }); } + +#[gpui::test] +async fn test_load_file_encoding(cx: &mut TestAppContext) { + init_test(cx); + let test_cases: Vec<(&str, &[u8], &str)> = vec![ + ("utf8.txt", "こんにちは".as_bytes(), "こんにちは"), // "こんにちは" is Japanese "Hello" + ( + "sjis.txt", + &[0x82, 0xb1, 0x82, 0xf1, 0x82, 0xc9, 0x82, 0xbf, 0x82, 0xcd], + "こんにちは", + ), + ( + "eucjp.txt", + &[0xa4, 0xb3, 0xa4, 0xf3, 0xa4, 0xcb, 0xa4, 0xc1, 0xa4, 0xcf], + "こんにちは", + ), + ( + "iso2022jp.txt", + &[ + 0x1b, 0x24, 0x42, 0x24, 0x33, 0x24, 0x73, 0x24, 0x4b, 0x24, 0x41, 0x24, 0x4f, 0x1b, + 0x28, 0x42, + ], + "こんにちは", + ), + // Western Europe (Windows-1252) + // "Café" -> 0xE9 is 'é' in Windows-1252 (it is typically 0xC3 0xA9 in UTF-8) + ("win1252.txt", &[0x43, 0x61, 0x66, 0xe9], "Café"), + // Chinese Simplified (GBK) + // Note: We use a slightly longer string here because short byte sequences can be ambiguous + // in multi-byte encodings. Providing more context helps the heuristic detector guess correctly. + // Text: "今天天气不错" (Today's weather is not bad / nice) + // Bytes: + // 今: BD F1 + // 天: CC EC + // 天: CC EC + // 气: C6 F8 + // 不: B2 BB + // 错: B4 ED + ( + "gbk.txt", + &[ + 0xbd, 0xf1, 0xcc, 0xec, 0xcc, 0xec, 0xc6, 0xf8, 0xb2, 0xbb, 0xb4, 0xed, + ], + "今天天气不错", + ), + ( + "utf16le_bom.txt", + &[ + 0xFF, 0xFE, // BOM + 0x53, 0x30, // こ + 0x93, 0x30, // ん + 0x6B, 0x30, // に + 0x61, 0x30, // ち + 0x6F, 0x30, // は + ], + "こんにちは", + ), + ( + "utf8_bom.txt", + &[ + 0xEF, 0xBB, 0xBF, // UTF-8 BOM + 0xE3, 0x81, 0x93, // こ + 0xE3, 0x82, 0x93, // ん + 0xE3, 0x81, 0xAB, // に + 0xE3, 0x81, 0xA1, // ち + 0xE3, 0x81, 0xAF, // は + ], + "こんにちは", + ), + ]; + + let root_path = if cfg!(windows) { + Path::new("C:\\root") + } else { + Path::new("/root") + }; + + let fs = FakeFs::new(cx.background_executor.clone()); + + let mut files_json = serde_json::Map::new(); + for (name, _, _) in &test_cases { + files_json.insert(name.to_string(), serde_json::Value::String("".to_string())); + } + + for (name, bytes, _) in &test_cases { + let path = root_path.join(name); + fs.write(&path, bytes).await.unwrap(); + } + + let tree = Worktree::local( + root_path, + true, + fs, + Default::default(), + true, + &mut cx.to_async(), + ) + .await + .unwrap(); + + cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) + .await; + + for (name, _, expected) in test_cases { + let loaded = tree + .update(cx, |tree, cx| tree.load_file(rel_path(name), cx)) + .await + .with_context(|| format!("Failed to load {}", name)) + .unwrap(); + + assert_eq!( + loaded.text, expected, + "Encoding mismatch for file: {}", + name + ); + } +} + +#[gpui::test] +async fn test_write_file_encoding(cx: &mut gpui::TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + let root_path = if cfg!(windows) { + Path::new("C:\\root") + } else { + Path::new("/root") + }; + fs.create_dir(root_path).await.unwrap(); + let file_path = root_path.join("test.txt"); + + fs.insert_file(&file_path, "initial".into()).await; + + let worktree = Worktree::local( + root_path, + true, + fs.clone(), + Default::default(), + true, + &mut cx.to_async(), + ) + .await + .unwrap(); + + let path: Arc = Path::new("test.txt").into(); + let rel_path = RelPath::new(&path, PathStyle::local()).unwrap().into_arc(); + + let text = text::Rope::from("こんにちは"); + + let task = worktree.update(cx, |wt, cx| { + wt.write_file( + rel_path, + text, + text::LineEnding::Unix, + encoding_rs::SHIFT_JIS, + false, + cx, + ) + }); + + task.await.unwrap(); + + let bytes = fs.load_bytes(&file_path).await.unwrap(); + + let expected_bytes = vec![ + 0x82, 0xb1, // こ + 0x82, 0xf1, // ん + 0x82, 0xc9, // に + 0x82, 0xbf, // ち + 0x82, 0xcd, // は + ]; + + assert_eq!(bytes, expected_bytes, "Should be saved as Shift-JIS"); +} diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index a9a8ba87c645e99a68409865a95737e3222c87b3..fd160759f4440e2736d57cea62abb6bdb138ae72 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.217.0" +version = "0.219.0" publish.workspace = true license = "GPL-3.0-or-later" authors = ["Zed Team "] @@ -26,6 +26,7 @@ acp_tools.workspace = true activity_indicator.workspace = true agent_settings.workspace = true agent_ui.workspace = true +agent_ui_v2.workspace = true anyhow.workspace = true askpass.workspace = true assets.workspace = true @@ -162,6 +163,7 @@ vim_mode_setting.workspace = true watch.workspace = true web_search.workspace = true web_search_providers.workspace = true +which_key.workspace = true workspace.workspace = true zed_actions.workspace = true zed_env_vars.workspace = true @@ -194,6 +196,10 @@ terminal_view = { workspace = true, features = ["test-support"] } tree-sitter-md.workspace = true tree-sitter-rust.workspace = true workspace = { workspace = true, features = ["test-support"] } +agent_ui = { workspace = true, features = ["test-support"] } +agent_ui_v2 = { workspace = true, features = ["test-support"] } +search = { workspace = true, features = ["test-support"] } + [package.metadata.bundle-dev] icon = ["resources/app-icon-dev@2x.png", "resources/app-icon-dev.png"] diff --git a/crates/zed/resources/Document.icns b/crates/zed/resources/Document.icns new file mode 100644 index 0000000000000000000000000000000000000000..5d0185c81a32c214f213f12243aeab01e32830e1 Binary files /dev/null and b/crates/zed/resources/Document.icns differ diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 0e82b3323b36f4845b584b33f65e440963801a9f..7008e491c5e2ade35fa96cafbd9d8969c008fa96 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -3,7 +3,7 @@ mod zed; use agent_ui::AgentPanel; use anyhow::{Context as _, Error, Result}; -use clap::{Parser, command}; +use clap::Parser; use cli::FORCE_CLI_MODE_ENV_VAR_NAME; use client::{Client, ProxySettings, UserStore, parse_zed_link}; use collab_ui::channel_view::ChannelView; @@ -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}; @@ -166,8 +166,6 @@ fn fail_to_open_window(e: anyhow::Error, _cx: &mut App) { pub static STARTUP_TIME: OnceLock = OnceLock::new(); pub fn main() { - ztracing::init(); - STARTUP_TIME.get_or_init(|| Instant::now()); #[cfg(unix)] @@ -242,6 +240,7 @@ pub fn main() { } zlog::init(); + if stdout_is_a_pty() { zlog::init_output_stdout(); } else { @@ -251,6 +250,7 @@ pub fn main() { zlog::init_output_stdout(); }; } + ztracing::init(); let version = option_env!("ZED_BUILD_ID"); let app_commit_sha = @@ -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); @@ -597,6 +606,7 @@ pub fn main() { false, cx, ); + agent_ui_v2::agents_panel::init(cx); repl::init(app_state.fs.clone(), cx); recent_projects::init(cx); @@ -646,6 +656,7 @@ pub fn main() { inspector_ui::init(app_state.clone(), cx); json_schema_store::init(cx); miniprofiler_ui::init(*STARTUP_TIME.get().unwrap(), cx); + which_key::init(cx); cx.observe_global::({ let http = app_state.client.http_client(); @@ -801,7 +812,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); } }) }) @@ -882,6 +893,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; @@ -1156,7 +1205,13 @@ async fn restore_or_create_workspace(app_state: Arc, cx: &mut AsyncApp app_state, cx, |workspace, window, cx| { - Editor::new_file(workspace, &Default::default(), window, cx) + let restore_on_startup = WorkspaceSettings::get_global(cx).restore_on_startup; + match restore_on_startup { + workspace::RestoreOnStartupBehavior::Launchpad => {} + _ => { + Editor::new_file(workspace, &Default::default(), window, cx); + } + } }, ) })? diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 1361fcdba788752099c8e5b37b51e751fccf4dfd..d088df00839814e32a9c246a3486ac5ad5ca4b9e 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -10,6 +10,7 @@ mod quick_action_bar; pub(crate) mod windows_only_instance; use agent_ui::{AgentDiffToolbar, AgentPanelDelegate}; +use agent_ui_v2::agents_panel::AgentsPanel; use anyhow::Context as _; pub use app_menus::*; use assets::Assets; @@ -31,8 +32,8 @@ use git_ui::project_diff::ProjectDiffToolbar; use gpui::{ Action, App, AppContext as _, AsyncWindowContext, Context, DismissEvent, Element, Entity, Focusable, KeyBinding, ParentElement, PathPromptOptions, PromptLevel, ReadGlobal, SharedString, - Styled, Task, TitlebarOptions, UpdateGlobal, WeakEntity, Window, WindowKind, WindowOptions, - actions, image_cache, point, px, retain_all, + Task, TitlebarOptions, UpdateGlobal, WeakEntity, Window, WindowKind, WindowOptions, actions, + image_cache, point, px, retain_all, }; use image_viewer::ImageInfo; use language::Capability; @@ -81,8 +82,9 @@ use vim_mode_setting::VimModeSetting; use workspace::notifications::{ NotificationId, SuppressEvent, dismiss_app_notification, show_app_notification, }; +use workspace::utility_pane::utility_slot_for_dock_position; use workspace::{ - AppState, NewFile, NewWindow, OpenLog, Toast, Workspace, WorkspaceSettings, + AppState, NewFile, NewWindow, OpenLog, Panel, Toast, Workspace, WorkspaceSettings, create_and_open_local_file, notifications::simple_message_notification::MessageNotification, open_new, }; @@ -159,15 +161,15 @@ pub fn init(cx: &mut App) { || flag.await { cx.update(|cx| { - cx.on_action(|_: &TestPanic, _| panic!("Ran the TestPanic action")); - cx.on_action(|_: &TestCrash, _| { - unsafe extern "C" { - fn puts(s: *const i8); - } - unsafe { - puts(0xabad1d3a as *const i8); - } - }); + cx.on_action(|_: &TestPanic, _| panic!("Ran the TestPanic action")) + .on_action(|_: &TestCrash, _| { + unsafe extern "C" { + fn puts(s: *const i8); + } + unsafe { + puts(0xabad1d3a as *const i8); + } + }); }) .ok(); }; @@ -177,11 +179,11 @@ pub fn init(cx: &mut App) { with_active_or_new_workspace(cx, |workspace, window, cx| { open_log_file(workspace, window, cx); }); - }); - cx.on_action(|_: &workspace::RevealLogInFileManager, cx| { + }) + .on_action(|_: &workspace::RevealLogInFileManager, cx| { cx.reveal_path(paths::log_file().as_path()); - }); - cx.on_action(|_: &zed_actions::OpenLicenses, cx| { + }) + .on_action(|_: &zed_actions::OpenLicenses, cx| { with_active_or_new_workspace(cx, |workspace, window, cx| { open_bundled_file( workspace, @@ -192,13 +194,13 @@ pub fn init(cx: &mut App) { cx, ); }); - }); - cx.on_action(|_: &zed_actions::OpenTelemetryLog, cx| { + }) + .on_action(|_: &zed_actions::OpenTelemetryLog, cx| { with_active_or_new_workspace(cx, |workspace, window, cx| { open_telemetry_log_file(workspace, window, cx); }); - }); - cx.on_action(|&zed_actions::OpenKeymapFile, cx| { + }) + .on_action(|&zed_actions::OpenKeymapFile, cx| { with_active_or_new_workspace(cx, |_, window, cx| { open_settings_file( paths::keymap_file(), @@ -207,8 +209,8 @@ pub fn init(cx: &mut App) { cx, ); }); - }); - cx.on_action(|_: &OpenSettingsFile, cx| { + }) + .on_action(|_: &OpenSettingsFile, cx| { with_active_or_new_workspace(cx, |_, window, cx| { open_settings_file( paths::settings_file(), @@ -217,13 +219,13 @@ pub fn init(cx: &mut App) { cx, ); }); - }); - cx.on_action(|_: &OpenAccountSettings, cx| { + }) + .on_action(|_: &OpenAccountSettings, cx| { with_active_or_new_workspace(cx, |_, _, cx| { cx.open_url(&zed_urls::account_url(cx)); }); - }); - cx.on_action(|_: &OpenTasks, cx| { + }) + .on_action(|_: &OpenTasks, cx| { with_active_or_new_workspace(cx, |_, window, cx| { open_settings_file( paths::tasks_file(), @@ -232,8 +234,8 @@ pub fn init(cx: &mut App) { cx, ); }); - }); - cx.on_action(|_: &OpenDebugTasks, cx| { + }) + .on_action(|_: &OpenDebugTasks, cx| { with_active_or_new_workspace(cx, |_, window, cx| { open_settings_file( paths::debug_scenarios_file(), @@ -242,8 +244,8 @@ pub fn init(cx: &mut App) { cx, ); }); - }); - cx.on_action(|_: &OpenDefaultSettings, cx| { + }) + .on_action(|_: &OpenDefaultSettings, cx| { with_active_or_new_workspace(cx, |workspace, window, cx| { open_bundled_file( workspace, @@ -254,8 +256,8 @@ pub fn init(cx: &mut App) { cx, ); }); - }); - cx.on_action(|_: &zed_actions::OpenDefaultKeymap, cx| { + }) + .on_action(|_: &zed_actions::OpenDefaultKeymap, cx| { with_active_or_new_workspace(cx, |workspace, window, cx| { open_bundled_file( workspace, @@ -266,8 +268,8 @@ pub fn init(cx: &mut App) { cx, ); }); - }); - cx.on_action(|_: &zed_actions::About, cx| { + }) + .on_action(|_: &zed_actions::About, cx| { with_active_or_new_workspace(cx, |workspace, window, cx| { about(workspace, window, cx); }); @@ -351,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(); @@ -473,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(); } @@ -679,7 +683,8 @@ fn initialize_panels( add_panel_when_ready(channels_panel, workspace_handle.clone(), cx.clone()), add_panel_when_ready(notification_panel, workspace_handle.clone(), cx.clone()), add_panel_when_ready(debug_panel, workspace_handle.clone(), cx.clone()), - initialize_agent_panel(workspace_handle, prompt_builder, cx.clone()).map(|r| r.log_err()) + initialize_agent_panel(workspace_handle.clone(), prompt_builder, cx.clone()).map(|r| r.log_err()), + initialize_agents_panel(workspace_handle, cx.clone()).map(|r| r.log_err()) ); anyhow::Ok(()) @@ -687,58 +692,64 @@ fn initialize_panels( .detach(); } +fn setup_or_teardown_ai_panel( + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, + load_panel: impl FnOnce( + WeakEntity, + AsyncWindowContext, + ) -> Task>> + + 'static, +) -> Task> { + let disable_ai = SettingsStore::global(cx) + .get::(None) + .disable_ai + || cfg!(test); + let existing_panel = workspace.panel::

(cx); + match (disable_ai, existing_panel) { + (false, None) => cx.spawn_in(window, async move |workspace, cx| { + let panel = load_panel(workspace.clone(), cx.clone()).await?; + workspace.update_in(cx, |workspace, window, cx| { + let disable_ai = SettingsStore::global(cx) + .get::(None) + .disable_ai; + let have_panel = workspace.panel::

(cx).is_some(); + if !disable_ai && !have_panel { + workspace.add_panel(panel, window, cx); + } + }) + }), + (true, Some(existing_panel)) => { + workspace.remove_panel::

(&existing_panel, window, cx); + Task::ready(Ok(())) + } + _ => Task::ready(Ok(())), + } +} + async fn initialize_agent_panel( workspace_handle: WeakEntity, prompt_builder: Arc, mut cx: AsyncWindowContext, ) -> anyhow::Result<()> { - fn setup_or_teardown_agent_panel( - workspace: &mut Workspace, - prompt_builder: Arc, - window: &mut Window, - cx: &mut Context, - ) -> Task> { - let disable_ai = SettingsStore::global(cx) - .get::(None) - .disable_ai - || cfg!(test); - let existing_panel = workspace.panel::(cx); - match (disable_ai, existing_panel) { - (false, None) => cx.spawn_in(window, async move |workspace, cx| { - let panel = - agent_ui::AgentPanel::load(workspace.clone(), prompt_builder, cx.clone()) - .await?; - workspace.update_in(cx, |workspace, window, cx| { - let disable_ai = SettingsStore::global(cx) - .get::(None) - .disable_ai; - let have_panel = workspace.panel::(cx).is_some(); - if !disable_ai && !have_panel { - workspace.add_panel(panel, window, cx); - } - }) - }), - (true, Some(existing_panel)) => { - workspace.remove_panel::(&existing_panel, window, cx); - Task::ready(Ok(())) - } - _ => Task::ready(Ok(())), - } - } - workspace_handle .update_in(&mut cx, |workspace, window, cx| { - setup_or_teardown_agent_panel(workspace, prompt_builder.clone(), window, cx) + let prompt_builder = prompt_builder.clone(); + setup_or_teardown_ai_panel(workspace, window, cx, move |workspace, cx| { + agent_ui::AgentPanel::load(workspace, prompt_builder, cx) + }) })? .await?; workspace_handle.update_in(&mut cx, |workspace, window, cx| { - cx.observe_global_in::(window, { + let prompt_builder = prompt_builder.clone(); + cx.observe_global_in::(window, move |workspace, window, cx| { let prompt_builder = prompt_builder.clone(); - move |workspace, window, cx| { - setup_or_teardown_agent_panel(workspace, prompt_builder.clone(), window, cx) - .detach_and_log_err(cx); - } + setup_or_teardown_ai_panel(workspace, window, cx, move |workspace, cx| { + agent_ui::AgentPanel::load(workspace, prompt_builder, cx) + }) + .detach_and_log_err(cx); }) .detach(); @@ -763,6 +774,31 @@ async fn initialize_agent_panel( anyhow::Ok(()) } +async fn initialize_agents_panel( + workspace_handle: WeakEntity, + mut cx: AsyncWindowContext, +) -> anyhow::Result<()> { + workspace_handle + .update_in(&mut cx, |workspace, window, cx| { + setup_or_teardown_ai_panel(workspace, window, cx, |workspace, cx| { + AgentsPanel::load(workspace, cx) + }) + })? + .await?; + + workspace_handle.update_in(&mut cx, |_workspace, window, cx| { + cx.observe_global_in::(window, move |workspace, window, cx| { + setup_or_teardown_ai_panel(workspace, window, cx, |workspace, cx| { + AgentsPanel::load(workspace, cx) + }) + .detach_and_log_err(cx); + }) + .detach(); + })?; + + anyhow::Ok(()) +} + fn register_actions( app_state: Arc, workspace: &mut Workspace, @@ -1052,6 +1088,18 @@ fn register_actions( workspace.toggle_panel_focus::(window, cx); }, ) + .register_action( + |workspace: &mut Workspace, + _: &zed_actions::agent::ToggleAgentPane, + window: &mut Window, + cx: &mut Context| { + if let Some(panel) = workspace.panel::(cx) { + let position = panel.read(cx).position(window, cx); + let slot = utility_slot_for_dock_position(position); + workspace.toggle_utility_pane(slot, window, cx); + } + }, + ) .register_action({ let app_state = Arc::downgrade(&app_state); move |_, _: &NewWindow, _, cx| { @@ -1062,7 +1110,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(); @@ -1643,6 +1705,7 @@ fn show_keymap_file_json_error( cx.new(|cx| { MessageNotification::new(message.clone(), cx) .primary_message("Open Keymap File") + .primary_icon(IconName::Settings) .primary_on_click(|window, cx| { window.dispatch_action(zed_actions::OpenKeymapFile.boxed_clone(), cx); cx.emit(DismissEvent); @@ -1701,16 +1764,18 @@ fn show_markdown_app_notification( cx.new(move |cx| { MessageNotification::new_from_builder(cx, move |window, cx| { image_cache(retain_all("notification-cache")) - .text_xs() - .child(markdown_preview::markdown_renderer::render_parsed_markdown( - &parsed_markdown.clone(), - Some(workspace_handle.clone()), - window, - cx, + .child(div().text_ui(cx).child( + markdown_preview::markdown_renderer::render_parsed_markdown( + &parsed_markdown.clone(), + Some(workspace_handle.clone()), + window, + cx, + ), )) .into_any() }) .primary_message(primary_button_message) + .primary_icon(IconName::Settings) .primary_on_click_arc(primary_button_on_click) }) }) @@ -2261,7 +2326,7 @@ mod tests { use project::{Project, ProjectPath}; use semver::Version; use serde_json::json; - use settings::{SettingsStore, watch_config_file}; + use settings::{SaturatingBool, SettingsStore, watch_config_file}; use std::{ path::{Path, PathBuf}, time::Duration, @@ -4714,6 +4779,7 @@ mod tests { "action", "activity_indicator", "agent", + "agents", #[cfg(not(target_os = "macos"))] "app_menu", "assistant", @@ -4745,10 +4811,12 @@ mod tests { "git_panel", "go_to_line", "icon_theme_selector", + "inline_assistant", "journal", "keymap_editor", "keystroke_input", "language_selector", + "welcome", "line_ending_selector", "lsp_tool", "markdown", @@ -4940,6 +5008,7 @@ mod tests { false, cx, ); + agent_ui_v2::agents_panel::init(cx); repl::init(app_state.fs.clone(), cx); repl::notebook::init(cx); tasks_ui::init(cx); @@ -5101,6 +5170,28 @@ mod tests { ); } + #[gpui::test] + async fn test_disable_ai_crash(cx: &mut gpui::TestAppContext) { + let app_state = init_test(cx); + cx.update(init); + let project = Project::test(app_state.fs.clone(), [], cx).await; + let _window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx)); + + cx.run_until_parked(); + + cx.update(|cx| { + SettingsStore::update_global(cx, |settings_store, cx| { + settings_store.update_user_settings(cx, |settings| { + settings.disable_ai = Some(SaturatingBool(true)); + }); + }); + }); + + cx.run_until_parked(); + + // If this panics, the test has failed + } + #[gpui::test] async fn test_prefer_focused_window(cx: &mut gpui::TestAppContext) { let app_state = init_test(cx); 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/edit_prediction_registry.rs b/crates/zed/src/zed/edit_prediction_registry.rs index 77a1f71596f9cf1d2f4e32137580d0e3648359f5..51327bfc9ab715a1b11aa3c639ffd60b6b0a0ea8 100644 --- a/crates/zed/src/zed/edit_prediction_registry.rs +++ b/crates/zed/src/zed/edit_prediction_registry.rs @@ -145,23 +145,6 @@ fn register_backward_compatible_actions(editor: &mut Editor, cx: &mut Context| { - editor.next_edit_prediction(&Default::default(), window, cx); - }, - )) - .detach(); - editor - .register_action(cx.listener( - |editor, - _: &copilot::PreviousSuggestion, - window: &mut Window, - cx: &mut Context| { - editor.previous_edit_prediction(&Default::default(), window, cx); - }, - )) - .detach(); } fn assign_edit_prediction_provider( diff --git a/crates/zed/src/zed/open_listener.rs b/crates/zed/src/zed/open_listener.rs index 5e855aa5a949254ba32658c26a59c48c7413844e..d61de0a291f3d3e7869225c0e07424cc3523f69b 100644 --- a/crates/zed/src/zed/open_listener.rs +++ b/crates/zed/src/zed/open_listener.rs @@ -3,13 +3,14 @@ use crate::restorable_workspace_locations; use anyhow::{Context as _, Result, anyhow}; use cli::{CliRequest, CliResponse, ipc::IpcSender}; use cli::{IpcHandshake, ipc}; -use client::parse_zed_link; +use client::{ZedLink, parse_zed_link}; use collections::HashMap; use db::kvp::KEY_VALUE_STORE; use editor::Editor; use fs::Fs; use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender}; use futures::channel::{mpsc, oneshot}; +use futures::future; use futures::future::join_all; use futures::{FutureExt, SinkExt, StreamExt}; use git_ui::file_diff_view::FileDiffView; @@ -57,6 +58,9 @@ pub enum OpenRequestKind { /// `None` opens settings without navigating to a specific path. setting_path: Option, }, + GitCommit { + sha: String, + }, } impl OpenRequest { @@ -109,10 +113,22 @@ 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(request_path) = parse_zed_link(&url, cx) { - this.parse_request_path(request_path).log_err(); + } else if let Some(zed_link) = parse_zed_link(&url, cx) { + match zed_link { + ZedLink::Channel { channel_id } => { + this.join_channel = Some(channel_id); + } + ZedLink::ChannelNotes { + channel_id, + heading, + } => { + this.open_channel_notes.push((channel_id, heading)); + } + } } else { log::error!("unhandled url: {}", url); } @@ -127,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 @@ -156,31 +194,6 @@ impl OpenRequest { self.parse_file_path(url.path()); Ok(()) } - - fn parse_request_path(&mut self, request_path: &str) -> Result<()> { - let mut parts = request_path.split('/'); - if parts.next() == Some("channel") - && let Some(slug) = parts.next() - && let Some(id_str) = slug.split('-').next_back() - && let Ok(channel_id) = id_str.parse::() - { - let Some(next) = parts.next() else { - self.join_channel = Some(channel_id); - return Ok(()); - }; - - if let Some(heading) = next.strip_prefix("notes#") { - self.open_channel_notes - .push((channel_id, Some(heading.to_string()))); - return Ok(()); - } - if next == "notes" { - self.open_channel_notes.push((channel_id, None)); - return Ok(()); - } - } - anyhow::bail!("invalid zed url: {request_path}") - } } #[derive(Clone)] @@ -514,33 +527,27 @@ async fn open_local_workspace( app_state: &Arc, cx: &mut AsyncApp, ) -> bool { - let mut errored = false; - let paths_with_position = derive_paths_with_position(app_state.fs.as_ref(), workspace_paths).await; - // Handle reuse flag by finding existing window to replace - let replace_window = if reuse { - cx.update(|cx| workspace::local_workspace_windows(cx).into_iter().next()) - .ok() - .flatten() - } else { - None - }; - - // For reuse, force new workspace creation but with replace_window set - let effective_open_new_workspace = if reuse { - Some(true) + // If reuse flag is passed, open a new workspace in an existing window. + let (open_new_workspace, replace_window) = if reuse { + ( + Some(true), + cx.update(|cx| workspace::local_workspace_windows(cx).into_iter().next()) + .ok() + .flatten(), + ) } else { - open_new_workspace + (open_new_workspace, None) }; - match open_paths_with_positions( + let (workspace, items) = match open_paths_with_positions( &paths_with_position, &diff_paths, app_state.clone(), workspace::OpenOptions { - open_new_workspace: effective_open_new_workspace, + open_new_workspace, replace_window, prefer_focused_window: wait, env: env.cloned(), @@ -550,80 +557,95 @@ async fn open_local_workspace( ) .await { - Ok((workspace, items)) => { - let mut item_release_futures = Vec::new(); + Ok(result) => result, + Err(error) => { + responses + .send(CliResponse::Stderr { + message: format!("error opening {paths_with_position:?}: {error}"), + }) + .log_err(); + return true; + } + }; - for item in items { - match item { - Some(Ok(item)) => { - cx.update(|cx| { - let released = oneshot::channel(); - item.on_release( - cx, - Box::new(move |_| { - let _ = released.0.send(()); - }), - ) - .detach(); - item_release_futures.push(released.1); - }) - .log_err(); - } - Some(Err(err)) => { - responses - .send(CliResponse::Stderr { - message: err.to_string(), - }) - .log_err(); - errored = true; - } - None => {} - } + let mut errored = false; + let mut item_release_futures = Vec::new(); + let mut subscriptions = Vec::new(); + + // If --wait flag is used with no paths, or a directory, then wait until + // the entire workspace is closed. + if wait { + let mut wait_for_window_close = paths_with_position.is_empty() && diff_paths.is_empty(); + for path_with_position in &paths_with_position { + if app_state.fs.is_dir(&path_with_position.path).await { + wait_for_window_close = true; + break; } + } + + if wait_for_window_close { + let (release_tx, release_rx) = oneshot::channel(); + item_release_futures.push(release_rx); + subscriptions.push(workspace.update(cx, |_, _, cx| { + cx.on_release(move |_, _| { + let _ = release_tx.send(()); + }) + })); + } + } - if wait { - let background = cx.background_executor().clone(); - let wait = async move { - if paths_with_position.is_empty() && diff_paths.is_empty() { - let (done_tx, done_rx) = oneshot::channel(); - let _subscription = workspace.update(cx, |_, _, cx| { - cx.on_release(move |_, _| { - let _ = done_tx.send(()); - }) - }); - let _ = done_rx.await; - } else { - let _ = futures::future::try_join_all(item_release_futures).await; - }; + for item in items { + match item { + Some(Ok(item)) => { + if wait { + let (release_tx, release_rx) = oneshot::channel(); + item_release_futures.push(release_rx); + subscriptions.push(cx.update(|cx| { + item.on_release( + cx, + Box::new(move |_| { + release_tx.send(()).ok(); + }), + ) + })); } - .fuse(); - - futures::pin_mut!(wait); - - loop { - // Repeatedly check if CLI is still open to avoid wasting resources - // waiting for files or workspaces to close. - let mut timer = background.timer(Duration::from_secs(1)).fuse(); - futures::select_biased! { - _ = wait => break, - _ = timer => { - if responses.send(CliResponse::Ping).is_err() { - break; - } - } + } + Some(Err(err)) => { + responses + .send(CliResponse::Stderr { + message: err.to_string(), + }) + .log_err(); + errored = true; + } + None => {} + } + } + + if wait { + let wait = async move { + let _subscriptions = subscriptions; + let _ = future::try_join_all(item_release_futures).await; + } + .fuse(); + futures::pin_mut!(wait); + + let background = cx.background_executor().clone(); + loop { + // Repeatedly check if CLI is still open to avoid wasting resources + // waiting for files or workspaces to close. + let mut timer = background.timer(Duration::from_secs(1)).fuse(); + futures::select_biased! { + _ = wait => break, + _ = timer => { + if responses.send(CliResponse::Ping).is_err() { + break; } } } } - Err(error) => { - errored = true; - responses - .send(CliResponse::Stderr { - message: format!("error opening {paths_with_position:?}: {error}"), - }) - .log_err(); - } } + errored } @@ -653,12 +675,13 @@ mod tests { ipc::{self}, }; use editor::Editor; - use gpui::TestAppContext; + use futures::poll; + use gpui::{AppContext as _, TestAppContext}; use language::LineEnding; use remote::SshConnectionOptions; use rope::Rope; use serde_json::json; - use std::sync::Arc; + use std::{sync::Arc, task::Poll}; use util::path; use workspace::{AppState, Workspace}; @@ -686,11 +709,92 @@ mod tests { port_forwards: None, nickname: None, upload_binary_over_ssh: false, + connection_timeout: None, }) ); 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); @@ -753,6 +857,60 @@ mod tests { .unwrap(); } + #[gpui::test] + async fn test_wait_with_directory_waits_for_window_close(cx: &mut TestAppContext) { + let app_state = init_test(cx); + + app_state + .fs + .as_fake() + .insert_tree( + path!("/root"), + json!({ + "dir1": { + "file1.txt": "content1", + }, + }), + ) + .await; + + let (response_tx, _) = ipc::channel::().unwrap(); + let workspace_paths = vec![path!("/root/dir1").to_owned()]; + + let (done_tx, mut done_rx) = futures::channel::oneshot::channel(); + cx.spawn({ + let app_state = app_state.clone(); + move |mut cx| async move { + let errored = open_local_workspace( + workspace_paths, + vec![], + None, + false, + true, + &response_tx, + None, + &app_state, + &mut cx, + ) + .await; + let _ = done_tx.send(errored); + } + }) + .detach(); + + cx.background_executor.run_until_parked(); + assert_eq!(cx.windows().len(), 1); + assert!(matches!(poll!(&mut done_rx), Poll::Pending)); + + let window = cx.windows()[0]; + cx.update_window(window, |_, window, _| window.remove_window()) + .unwrap(); + cx.background_executor.run_until_parked(); + + let errored = done_rx.await.unwrap(); + assert!(!errored); + } + #[gpui::test] async fn test_open_workspace_with_nonexistent_files(cx: &mut TestAppContext) { let app_state = init_test(cx); diff --git a/crates/zed/src/zed/quick_action_bar.rs b/crates/zed/src/zed/quick_action_bar.rs index 402881680232ea636f7cb105db759f417a435145..2a52cc697249cb1f8eb280a48c89ff5aadf6fd85 100644 --- a/crates/zed/src/zed/quick_action_bar.rs +++ b/crates/zed/src/zed/quick_action_bar.rs @@ -174,17 +174,13 @@ impl Render for QuickActionBar { .as_ref() .is_some_and(|menu| matches!(menu.origin(), ContextMenuOrigin::QuickActionBar)) }; - let code_action_element = if is_deployed { - editor.update(cx, |editor, cx| { - if let Some(style) = editor.style() { - editor.render_context_menu(style, MAX_CODE_ACTION_MENU_LINES, window, cx) - } else { - None - } + let code_action_element = is_deployed + .then(|| { + editor.update(cx, |editor, cx| { + editor.render_context_menu(MAX_CODE_ACTION_MENU_LINES, window, cx) + }) }) - } else { - None - }; + .flatten(); v_flex() .child( IconButton::new("toggle_code_actions_icon", IconName::BoltOutlined) diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index d4d28433d4c76dcab3df627789df82e99854fbc1..85b6d4d37d06d5f1c229fc852dd5bad117bbd9d7 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -70,6 +70,8 @@ actions!( OpenTelemetryLog, /// Opens the performance profiler. OpenPerformanceProfiler, + /// Opens the onboarding view. + OpenOnboarding, ] ); @@ -350,6 +352,10 @@ pub mod agent { AddSelectionToThread, /// Resets the agent panel zoom levels (agent UI and buffer font sizes). ResetAgentZoom, + /// Toggles the utility/agent pane open/closed state. + ToggleAgentPane, + /// Pastes clipboard content without any formatting. + PasteRaw, ] ); } @@ -428,6 +434,12 @@ pub struct OpenRemote { pub create_new_window: bool, } +/// Opens the dev container connection modal. +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)] +#[action(namespace = projects)] +#[serde(deny_unknown_fields)] +pub struct OpenDevContainer; + /// Where to spawn the task in the UI. #[derive(Default, Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] diff --git a/crates/zed_env_vars/src/zed_env_vars.rs b/crates/zed_env_vars/src/zed_env_vars.rs index 53b9c22bb207e81831d1d9ae6087d1a297331d3f..e601cc9536602ac943bd76bf1bfd8b8ac8979dd9 100644 --- a/crates/zed_env_vars/src/zed_env_vars.rs +++ b/crates/zed_env_vars/src/zed_env_vars.rs @@ -5,6 +5,7 @@ use std::sync::LazyLock; /// When true, Zed will use in-memory databases instead of persistent storage. pub static ZED_STATELESS: LazyLock = bool_env_var!("ZED_STATELESS"); +#[derive(Clone)] pub struct EnvVar { pub name: SharedString, /// Value of the environment variable. Also `None` when set to an empty string. @@ -30,7 +31,7 @@ impl EnvVar { #[macro_export] macro_rules! env_var { ($name:expr) => { - LazyLock::new(|| $crate::EnvVar::new(($name).into())) + ::std::sync::LazyLock::new(|| $crate::EnvVar::new(($name).into())) }; } @@ -39,6 +40,6 @@ macro_rules! env_var { #[macro_export] macro_rules! bool_env_var { ($name:expr) => { - LazyLock::new(|| $crate::EnvVar::new(($name).into()).value.is_some()) + ::std::sync::LazyLock::new(|| $crate::EnvVar::new(($name).into()).value.is_some()) }; } diff --git a/crates/zeta_prompt/Cargo.toml b/crates/zeta_prompt/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..c9b1e2d784d10ea2fd278f70ffdae2ef0981fce0 --- /dev/null +++ b/crates/zeta_prompt/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "zeta_prompt" +version = "0.1.0" +publish.workspace = true +edition.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/zeta_prompt.rs" + +[dependencies] +serde.workspace = true \ No newline at end of file diff --git a/crates/zeta_prompt/LICENSE-GPL b/crates/zeta_prompt/LICENSE-GPL new file mode 120000 index 0000000000000000000000000000000000000000..89e542f750cd3860a0598eff0dc34b56d7336dc4 --- /dev/null +++ b/crates/zeta_prompt/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/zeta_prompt/src/zeta_prompt.rs b/crates/zeta_prompt/src/zeta_prompt.rs new file mode 100644 index 0000000000000000000000000000000000000000..21fbca1ae10b715d0c11a31dc9390aada03fa157 --- /dev/null +++ b/crates/zeta_prompt/src/zeta_prompt.rs @@ -0,0 +1,165 @@ +use serde::{Deserialize, Serialize}; +use std::fmt::Write; +use std::ops::Range; +use std::path::Path; +use std::sync::Arc; + +pub const CURSOR_MARKER: &str = "<|user_cursor|>"; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ZetaPromptInput { + pub cursor_path: Arc, + pub cursor_excerpt: Arc, + pub editable_range_in_excerpt: Range, + pub cursor_offset_in_excerpt: usize, + pub events: Vec>, + pub related_files: Arc<[RelatedFile]>, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "event")] +pub enum Event { + BufferChange { + path: Arc, + old_path: Arc, + diff: String, + predicted: bool, + in_open_source_repo: bool, + }, +} + +pub fn write_event(prompt: &mut String, event: &Event) { + fn write_path_as_unix_str(prompt: &mut String, path: &Path) { + for component in path.components() { + prompt.push('/'); + write!(prompt, "{}", component.as_os_str().display()).ok(); + } + } + match event { + Event::BufferChange { + path, + old_path, + diff, + predicted, + in_open_source_repo: _, + } => { + if *predicted { + prompt.push_str("// User accepted prediction:\n"); + } + prompt.push_str("--- a"); + write_path_as_unix_str(prompt, old_path.as_ref()); + prompt.push_str("\n+++ b"); + write_path_as_unix_str(prompt, path.as_ref()); + prompt.push('\n'); + prompt.push_str(diff); + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RelatedFile { + pub path: Arc, + pub max_row: u32, + pub excerpts: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RelatedExcerpt { + pub row_range: Range, + pub text: String, +} + +pub fn format_zeta_prompt(input: &ZetaPromptInput) -> String { + let mut prompt = String::new(); + write_related_files(&mut prompt, &input.related_files); + write_edit_history_section(&mut prompt, input); + write_cursor_excerpt_section(&mut prompt, input); + prompt +} + +pub fn write_related_files(prompt: &mut String, related_files: &[RelatedFile]) { + push_delimited(prompt, "related_files", &[], |prompt| { + for file in related_files { + let path_str = file.path.to_string_lossy(); + push_delimited(prompt, "related_file", &[("path", &path_str)], |prompt| { + for excerpt in &file.excerpts { + push_delimited( + prompt, + "related_excerpt", + &[( + "lines", + &format!( + "{}-{}", + excerpt.row_range.start + 1, + excerpt.row_range.end + 1 + ), + )], + |prompt| { + prompt.push_str(&excerpt.text); + prompt.push('\n'); + }, + ); + } + }); + } + }); +} + +fn write_edit_history_section(prompt: &mut String, input: &ZetaPromptInput) { + push_delimited(prompt, "edit_history", &[], |prompt| { + if input.events.is_empty() { + prompt.push_str("(No edit history)"); + } else { + for event in &input.events { + write_event(prompt, event); + } + } + }); +} + +fn write_cursor_excerpt_section(prompt: &mut String, input: &ZetaPromptInput) { + push_delimited(prompt, "cursor_excerpt", &[], |prompt| { + let path_str = input.cursor_path.to_string_lossy(); + push_delimited(prompt, "file", &[("path", &path_str)], |prompt| { + prompt.push_str(&input.cursor_excerpt[..input.editable_range_in_excerpt.start]); + push_delimited(prompt, "editable_region", &[], |prompt| { + prompt.push_str( + &input.cursor_excerpt + [input.editable_range_in_excerpt.start..input.cursor_offset_in_excerpt], + ); + prompt.push_str(CURSOR_MARKER); + prompt.push_str( + &input.cursor_excerpt + [input.cursor_offset_in_excerpt..input.editable_range_in_excerpt.end], + ); + }); + prompt.push_str(&input.cursor_excerpt[input.editable_range_in_excerpt.end..]); + }); + }); +} + +fn push_delimited( + prompt: &mut String, + tag: &'static str, + arguments: &[(&str, &str)], + cb: impl FnOnce(&mut String), +) { + if !prompt.ends_with("\n") { + prompt.push('\n'); + } + prompt.push('<'); + prompt.push_str(tag); + for (arg_name, arg_value) in arguments { + write!(prompt, " {}=\"{}\"", arg_name, arg_value).ok(); + } + prompt.push_str(">\n"); + + cb(prompt); + + if !prompt.ends_with('\n') { + prompt.push('\n'); + } + prompt.push_str("\n"); +} diff --git a/crates/ztracing/Cargo.toml b/crates/ztracing/Cargo.toml index c68ac15423cf3a26a8dc855769ba44b9ac29696a..0d9f15b9afccca4a1a05036c013562c8ad1ae8f4 100644 --- a/crates/ztracing/Cargo.toml +++ b/crates/ztracing/Cargo.toml @@ -12,6 +12,7 @@ workspace = true tracy = ["tracing-tracy"] [dependencies] +zlog.workspace = true tracing.workspace = true tracing-subscriber = "0.3.22" diff --git a/crates/ztracing/src/lib.rs b/crates/ztracing/src/lib.rs index 1ab687a2f4550e9b08432764dd7f80aedf5791c0..c9007be1ed43150ef877d51c882aee77845e5bd6 100644 --- a/crates/ztracing/src/lib.rs +++ b/crates/ztracing/src/lib.rs @@ -1,10 +1,52 @@ +pub use tracing::{Level, field}; + #[cfg(ztracing)] -pub use tracing::instrument; +pub use tracing::{ + Span, debug_span, error_span, event, info_span, instrument, span, trace_span, warn_span, +}; #[cfg(not(ztracing))] pub use ztracing_macro::instrument; +#[cfg(not(ztracing))] +pub use __consume_all_tokens as trace_span; +#[cfg(not(ztracing))] +pub use __consume_all_tokens as info_span; +#[cfg(not(ztracing))] +pub use __consume_all_tokens as debug_span; +#[cfg(not(ztracing))] +pub use __consume_all_tokens as warn_span; +#[cfg(not(ztracing))] +pub use __consume_all_tokens as error_span; +#[cfg(not(ztracing))] +pub use __consume_all_tokens as event; +#[cfg(not(ztracing))] +pub use __consume_all_tokens as span; + +#[cfg(not(ztracing))] +#[macro_export] +macro_rules! __consume_all_tokens { + ($($t:tt)*) => { + $crate::Span + }; +} + +#[cfg(not(ztracing))] +pub struct Span; + +#[cfg(not(ztracing))] +impl Span { + pub fn current() -> Self { + Self + } + + pub fn enter(&self) {} + + pub fn record(&self, _t: T, _s: S) {} +} + #[cfg(ztracing)] pub fn init() { + zlog::info!("Starting tracy subscriber, you can now connect the profiler"); use tracing_subscriber::prelude::*; tracing::subscriber::set_global_default( tracing_subscriber::registry().with(tracing_tracy::TracyLayer::default()), 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/billing.md b/docs/src/ai/billing.md index 64ff871ce1b629fad72d4ddd6f9c8f42f2bf92da..788c0c1cf7cb0bfd64bdd83812e1e62bf51abf88 100644 --- a/docs/src/ai/billing.md +++ b/docs/src/ai/billing.md @@ -5,7 +5,7 @@ For invoice-based billing, a Business plan is required. Contact [sales@zed.dev]( ## Billing Information {#settings} -You can access billing information and settings at [zed.dev/account](https://zed.dev/account). +You can access billing information and settings at [dashboard.zed.dev/account](https://dashboard.zed.dev/account). Most of the page embeds information from our invoicing/metering partner, Orb (we're planning on a more native experience soon!). ## Billing Cycles {#billing-cycles} @@ -28,7 +28,7 @@ If payment of an invoice fails, Zed will block usage of our hosted models until ## Invoice History {#invoice-history} -You can access your invoice history by navigating to [zed.dev/account](https://zed.dev/account) and clicking `Invoice history` within the embedded Orb portal. +You can access your invoice history by navigating to [dashboard.zed.dev/account](https://dashboard.zed.dev/account) and clicking `Invoice history` within the embedded Orb portal. If you require historical Stripe invoices, email [billing-support@zed.dev](mailto:billing-support@zed.dev) diff --git a/docs/src/ai/edit-prediction.md b/docs/src/ai/edit-prediction.md index feef6d36d29eca4157254cc4c209f4a614a927de..65a427842cda461806dc79ecf67f3a180afd9763 100644 --- a/docs/src/ai/edit-prediction.md +++ b/docs/src/ai/edit-prediction.md @@ -58,7 +58,8 @@ In these cases, `alt-tab` is used instead to accept the prediction. When the lan On Linux, `alt-tab` is often used by the window manager for switching windows, so `alt-l` is provided as the default binding for accepting predictions. `tab` and `alt-tab` also work, but aren't displayed by default. -{#action editor::AcceptPartialEditPrediction} ({#kb editor::AcceptPartialEditPrediction}) can be used to accept the current edit prediction up to the next word boundary. +{#action editor::AcceptNextWordEditPrediction} ({#kb editor::AcceptNextWordEditPrediction}) can be used to accept the current edit prediction up to the next word boundary. +{#action editor::AcceptNextLineEditPrediction} ({#kb editor::AcceptNextLineEditPrediction}) can be used to accept the current edit prediction up to the new line boundary. ## Configuring Edit Prediction Keybindings {#edit-predictions-keybinding} diff --git a/docs/src/ai/llm-providers.md b/docs/src/ai/llm-providers.md index f13ece5d3eb6aac3af38a0046abddc474649f503..ee495b1ba7e67a6cc15359453fd7d3ae41b17233 100644 --- a/docs/src/ai/llm-providers.md +++ b/docs/src/ai/llm-providers.md @@ -347,6 +347,33 @@ Download and install Ollama from [ollama.com/download](https://ollama.com/downlo 3. In the Agent Panel, select one of the Ollama models using the model dropdown. +#### Ollama Autodiscovery + +Zed will automatically discover models that Ollama has pulled. You can turn this off by setting +the `auto_discover` field in the Ollama settings. If you do this, you should manually specify which +models are available. + +```json [settings] +{ + "language_models": { + "ollama": { + "api_url": "http://localhost:11434", + "auto_discover": false, + "available_models": [ + { + "name": "qwen2.5-coder", + "display_name": "qwen 2.5 coder", + "max_tokens": 32768, + "supports_tools": true, + "supports_thinking": true, + "supports_images": true + } + ] + } + } +} +``` + #### Ollama Context Length {#ollama-context} Zed has pre-configured maximum context lengths (`max_tokens`) to match the capabilities of common models. diff --git a/docs/src/ai/plans-and-usage.md b/docs/src/ai/plans-and-usage.md index fc59a894aacd524a10e31b65ababd4f8d79e3b8e..63f72211aa70b19b820fb9b368d47a3b008b726d 100644 --- a/docs/src/ai/plans-and-usage.md +++ b/docs/src/ai/plans-and-usage.md @@ -12,11 +12,11 @@ Usage of Zed's hosted models is measured on a token basis, converted to dollars Zed Pro comes with $5 of monthly dollar credit. A trial of Zed Pro includes $20 of credit, usable for 14 days. Monthly included credit resets on your monthly billing date. -To view your current usage, you can visit your account at [zed.dev/account](https://zed.dev/account). Information from our metering and billing provider, Orb, is embedded on that page. +To view your current usage, you can visit your account at [dashboard.zed.dev/account](https://dashboard.zed.dev/account). Information from our metering and billing provider, Orb, is embedded on that page. ## Spend Limits {#usage-spend-limits} -At the top of [the Account page](https://zed.dev/account), you'll find an input for `Maximum Token Spend`. The dollar amount here specifies your _monthly_ limit for spend on tokens, _not counting_ the $5/month included with your Pro subscription. +At the top of [the Account page](https://dashboard.zed.dev/account), you'll find an input for `Maximum Token Spend`. The dollar amount here specifies your _monthly_ limit for spend on tokens, _not counting_ the $5/month included with your Pro subscription. The default value for all Pro users is $10, for a total monthly spend with Zed of $20 ($10 for your Pro subscription, $10 in incremental token spend). This can be set to $0 to limit your spend with Zed to exactly $10/month. If you adjust this limit _higher_ than $10 and consume more than $10 of incremental token spend, you'll be billed via [threshold billing](./billing.md#threshold-billing). 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/completions.md b/docs/src/completions.md index ff96ede7503cd461bbd3d7b4afdedcaa2f36a2e5..7b35ec2d09d91a7ba7dc5ae4b968157e0184227f 100644 --- a/docs/src/completions.md +++ b/docs/src/completions.md @@ -15,6 +15,10 @@ When there is an appropriate language server available, Zed will provide complet You can manually trigger completions with `ctrl-space` or by triggering the `editor::ShowCompletions` action from the command palette. +> Note: Using `ctrl-space` in Zed requires disabling the macOS global shortcut. +> Open **System Settings** > **Keyboard** > **Keyboard Shortcut**s > +> **Input Sources** and uncheck **Select the previous input source**. + For more information, see: - [Configuring Supported Languages](./configuring-languages.md) diff --git a/docs/src/configuring-zed.md b/docs/src/configuring-zed.md index 477885a4537580aaf562aa596c1a06cae1c65bc8..8a638d9f7857e1a55aaa5589a77110a7b803bbfe 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. @@ -3142,7 +3183,15 @@ List of strings containing any combination of: ```json [settings] { - "restore_on_startup": "none" + "restore_on_startup": "empty_tab" +} +``` + +4. Always start with the welcome launchpad: + +```json [settings] +{ + "restore_on_startup": "launchpad" } ``` @@ -4309,6 +4358,7 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a "show_project_items": true, "show_onboarding_banner": true, "show_user_picture": true, + "show_user_menu": true, "show_sign_in": true, "show_menus": false } @@ -4321,6 +4371,7 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a - `show_project_items`: Whether to show the project host and name in the titlebar - `show_onboarding_banner`: Whether to show onboarding banners in the titlebar - `show_user_picture`: Whether to show user picture in the titlebar +- `show_user_menu`: Whether to show the user menu button in the titlebar (the one that displays your avatar by default and contains options like Settings, Keymap, Themes, etc.) - `show_sign_in`: Whether to show the sign in button in the titlebar - `show_menus`: Whether to show the menus in the titlebar 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/development/glossary.md b/docs/src/development/glossary.md index 34172ec9a590fdae537ff78920e1fadda2c331fa..0e0f984e214fe1a46e0aff790ab5e85bb46a8674 100644 --- a/docs/src/development/glossary.md +++ b/docs/src/development/glossary.md @@ -73,7 +73,7 @@ h_flex() - `Window`: A struct in zed representing a zed window in your desktop environment (see image below). There can be multiple if you have multiple zed instances open. Mostly passed around for rendering. - `Modal`: A UI element that floats on top of the rest of the UI -- `Picker`: A struct representing a list of items in floating on top of the UI (Modal). You can select an item and confirm. What happens on select or confirm is determined by the picker's delegate. (The 'Model' in the image below is a picker.) +- `Picker`: A struct representing a list of items floating on top of the UI (Modal). You can select an item and confirm. What happens on select or confirm is determined by the picker's delegate. (The 'Modal' in the image below is a picker.) - `PickerDelegate`: A trait used to specialize behavior for a `Picker`. The `Picker` stores the `PickerDelegate` in the field delegate. - `Center`: The middle of the zed window, the center is split into multiple `Pane`s. In the codebase this is a field on the `Workspace` struct. (see image below). - `Pane`: An area in the `Center` where we can place items, such as an editor, multi-buffer or terminal (see image below). diff --git a/docs/src/git.md b/docs/src/git.md index d562eb4d0a3b07f4de7df1b0831f6e91a2767c1d..8a94a79973b390f1d4e8075469b610d51b6f2016 100644 --- a/docs/src/git.md +++ b/docs/src/git.md @@ -145,7 +145,6 @@ You can specify your preferred model to use by providing a `commit_message_model ```json [settings] { "agent": { - "version": "2", "commit_message_model": { "provider": "anthropic", "model": "claude-3-5-haiku" diff --git a/docs/src/installation.md b/docs/src/installation.md index 7802ef7776a78deefb196ab005297e1f54314ea6..7d2009e3a0266160ce4e13056287c36ef7660008 100644 --- a/docs/src/installation.md +++ b/docs/src/installation.md @@ -22,6 +22,12 @@ brew install --cask zed@preview Get the latest stable builds via [the download page](https://zed.dev/download). If you want to download our preview build, you can find it on its [releases page](https://zed.dev/releases/preview). After the first manual installation, Zed will periodically check for install updates. +Additionally, you can install Zed using winget: + +```sh +winget install -e --id ZedIndustries.Zed +``` + ### Linux For most Linux users, the easiest way to install Zed is through our installation script: diff --git a/docs/src/languages/cpp.md b/docs/src/languages/cpp.md index c20dd58335caca45a6923cc0527605d6cc4b5564..629a0ab640e245bdfec41370fa966589728c2c94 100644 --- a/docs/src/languages/cpp.md +++ b/docs/src/languages/cpp.md @@ -158,3 +158,26 @@ You can use CodeLLDB or GDB to debug native binaries. (Make sure that your build } ] ``` + +## Protocol Extensions + +Zed currently implements the following `clangd` [extensions](https://clangd.llvm.org/extensions): + +### Inactive Regions + +Automatically dims inactive sections of code due to preprocessor directives, such as `#if`, `#ifdef`, or `#ifndef` blocks that evaluate to false. + +### Switch Between Source and Header Files + +Allows switching between corresponding C++ source files (e.g., `.cpp`) and header files (e.g., `.h`). +by running the command {#action editor::SwitchSourceHeader} from the command palette or by setting +a keybinding for the `editor::SwitchSourceHeader` action. + +```json [settings] +{ + "context": "Editor", + "bindings": { + "alt-enter": "editor::SwitchSourceHeader" + } +} +``` 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/python.md b/docs/src/languages/python.md index 5051a72209121176e05d41f57cb8d341db2ca351..2323fe2f9560cf03c586eced0052627705addcc3 100644 --- a/docs/src/languages/python.md +++ b/docs/src/languages/python.md @@ -258,6 +258,25 @@ quote-style = "single" For more details, refer to the Ruff documentation about [configuration files](https://docs.astral.sh/ruff/configuration/) and [language server settings](https://docs.astral.sh/ruff/editors/settings/), and the [list of options](https://docs.astral.sh/ruff/settings/). +### Embedded Language Highlighting + +Zed supports syntax highlighting for code embedded in Python strings by adding a comment with the language name. + +```python +# sql +query = "SELECT * FROM users" + +#sql +query = """ + SELECT * + FROM users +""" + +result = func( #sql + "SELECT * FROM users" +) +``` + ## Debugging Zed supports Python debugging through the `debugpy` adapter. You can start with no configuration or define custom launch profiles in `.zed/debug.json`. 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/vim.md b/docs/src/vim.md index c9a0cd09f2dafb9f07a26ef07b71205f5ddbdf15..09baa9b54f7e1aeb5f16777f4292131315d18928 100644 --- a/docs/src/vim.md +++ b/docs/src/vim.md @@ -471,7 +471,7 @@ But you cannot use the same shortcuts to move between all the editor docks (the } ``` -Subword motion, which allows you to navigate and select individual words in camelCase or snake_case, is not enabled by default. To enable it, add these bindings to your keymap. +Subword motion, which allows you to navigate and select individual words in `camelCase` or `snake_case`, is not enabled by default. To enable it, add these bindings to your keymap. ```json [settings] { @@ -485,6 +485,9 @@ Subword motion, which allows you to navigate and select individual words in came } ``` +> Note: Operations like `dw` remain unaffected. If you would like operations to +> also use subword motion, remove `vim_mode != operator` from the `context`. + Vim mode comes with shortcuts to surround the selection in normal mode (`ys`), but it doesn't have a shortcut to add surrounds in visual mode. By default, `shift-s` substitutes the selection (erases the text and enters insert mode). To use `shift-s` to add surrounds in visual mode, you can add the following object to your keymap. ```json [settings] @@ -566,7 +569,8 @@ You can change the following settings to modify vim mode's behavior: | use_system_clipboard | Determines how system clipboard is used:

  • "always": use for all operations
  • "never": only use when explicitly specified
  • "on_yank": use for yank operations
| "always" | | use_multiline_find | deprecated | | use_smartcase_find | If `true`, `f` and `t` motions are case-insensitive when the target letter is lowercase. | false | -| toggle_relative_line_numbers | If `true`, line numbers are relative in normal mode and absolute in insert mode, giving you the best of both options. | false | +| toggle_relative_line_numbers | deprecated | false | +| relative_line_numbers | If "enabled", line numbers are relative in normal mode and absolute in insert mode, giving you the best of both options. | "disabled" | | custom_digraphs | An object that allows you to add custom digraphs. Read below for an example. | {} | | highlight_on_yank_duration | The duration of the highlight animation(in ms). Set to `0` to disable | 200 | @@ -590,7 +594,7 @@ Here's an example of these settings changed: "default_mode": "insert", "use_system_clipboard": "never", "use_smartcase_find": true, - "toggle_relative_line_numbers": true, + "relative_line_numbers": "enabled", "highlight_on_yank_duration": 50, "custom_digraphs": { "fz": "🧟‍♀️" diff --git a/docs/src/visual-customization.md b/docs/src/visual-customization.md index e5185719279dde488c40573d94fd842c06860f4d..234776b1d3223a4b8634b42df1973a27c736616c 100644 --- a/docs/src/visual-customization.md +++ b/docs/src/visual-customization.md @@ -118,6 +118,7 @@ To disable this behavior use: "show_project_items": true, // Show/hide project host and name "show_onboarding_banner": true, // Show/hide onboarding banners "show_user_picture": true, // Show/hide user avatar + "show_user_menu": true, // Show/hide app user button "show_sign_in": true, // Show/hide sign-in button "show_menus": false // Show/hide menus }, diff --git a/docs/src/windows.md b/docs/src/windows.md index 34a553dd5b032915ed52651f7f02b737995b959b..b7b4b6b7bf153a2cae7cbf2b7168d502cfbdaeb0 100644 --- a/docs/src/windows.md +++ b/docs/src/windows.md @@ -6,6 +6,14 @@ Get the latest stable builds via [the download page](https://zed.dev/download). You can also build zed from source, see [these docs](https://zed.dev/docs/development/windows) for instructions. +### Package managers + +Additionally, you can install Zed using winget: + +```sh +winget install -e --id ZedIndustries.Zed +``` + ## Uninstall - Installed via installer: Use `Settings` → `Apps` → `Installed apps`, search for Zed, and click Uninstall. 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/extensions/html/Cargo.toml b/extensions/html/Cargo.toml index 22cdb401a7ebcf4bb6afab7702fb81f345b7aa14..2c89f86cb450b7ea8476bffdff003a94b137d213 100644 --- a/extensions/html/Cargo.toml +++ b/extensions/html/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zed_html" -version = "0.2.3" +version = "0.3.0" edition.workspace = true publish.workspace = true license = "Apache-2.0" diff --git a/extensions/html/extension.toml b/extensions/html/extension.toml index 1ded7af6413d0f1990a178a19a4014caadf48240..68ab0e4b9d3f56fca17cbd518d5990edc2ec711a 100644 --- a/extensions/html/extension.toml +++ b/extensions/html/extension.toml @@ -1,7 +1,7 @@ id = "html" name = "HTML" description = "HTML support." -version = "0.2.3" +version = "0.3.0" schema_version = 1 authors = ["Isaac Clayton "] repository = "https://github.com/zed-industries/zed" diff --git a/extensions/proto/Cargo.toml b/extensions/proto/Cargo.toml index 1013d62cfa085275a1230d0816049da6c35ba38a..c3606f668aa01d7a8baa20d54d073a7004a6f8c0 100644 --- a/extensions/proto/Cargo.toml +++ b/extensions/proto/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zed_proto" -version = "0.2.3" +version = "0.3.0" edition.workspace = true publish.workspace = true license = "Apache-2.0" @@ -13,4 +13,4 @@ path = "src/proto.rs" crate-type = ["cdylib"] [dependencies] -zed_extension_api = "0.1.0" +zed_extension_api = "0.7.0" diff --git a/extensions/proto/extension.toml b/extensions/proto/extension.toml index 9bb8625065fe957308c47488c4aeb9010a773984..13c4054eef083e131ab311b1ec6e5a63aff545d8 100644 --- a/extensions/proto/extension.toml +++ b/extensions/proto/extension.toml @@ -1,15 +1,24 @@ id = "proto" name = "Proto" description = "Protocol Buffers support." -version = "0.2.3" +version = "0.3.0" schema_version = 1 authors = ["Zed Industries "] repository = "https://github.com/zed-industries/zed" [grammars.proto] -repository = "https://github.com/zed-industries/tree-sitter-proto" -commit = "0848bd30a64be48772e15fbb9d5ba8c0cc5772ad" +repository = "https://github.com/coder3101/tree-sitter-proto" +commit = "a6caac94b5aa36b322b5b70040d5b67132f109d0" + + +[language_servers.buf] +name = "Buf" +languages = ["Proto"] [language_servers.protobuf-language-server] name = "Protobuf Language Server" languages = ["Proto"] + +[language_servers.protols] +name = "Protols" +languages = ["Proto"] diff --git a/extensions/proto/src/language_servers.rs b/extensions/proto/src/language_servers.rs new file mode 100644 index 0000000000000000000000000000000000000000..47a5e72d8aadf5d0286667148f0a7dd95fea10ba --- /dev/null +++ b/extensions/proto/src/language_servers.rs @@ -0,0 +1,8 @@ +mod buf; +mod protobuf_language_server; +mod protols; +mod util; + +pub(crate) use buf::*; +pub(crate) use protobuf_language_server::*; +pub(crate) use protols::*; diff --git a/extensions/proto/src/language_servers/buf.rs b/extensions/proto/src/language_servers/buf.rs new file mode 100644 index 0000000000000000000000000000000000000000..92106298d3d1deb6ed2b0f4194ab09321fa09552 --- /dev/null +++ b/extensions/proto/src/language_servers/buf.rs @@ -0,0 +1,114 @@ +use std::fs; + +use zed_extension_api::{ + self as zed, Architecture, DownloadedFileType, GithubReleaseOptions, Os, Result, + settings::LspSettings, +}; + +use crate::language_servers::util; + +pub(crate) struct BufLsp { + cached_binary_path: Option, +} + +impl BufLsp { + pub(crate) const SERVER_NAME: &str = "buf"; + + pub(crate) fn new() -> Self { + BufLsp { + cached_binary_path: None, + } + } + + pub(crate) fn language_server_binary( + &mut self, + worktree: &zed::Worktree, + ) -> Result { + let binary_settings = LspSettings::for_worktree(Self::SERVER_NAME, worktree) + .ok() + .and_then(|lsp_settings| lsp_settings.binary); + + let args = binary_settings + .as_ref() + .and_then(|binary_settings| binary_settings.arguments.clone()) + .unwrap_or_else(|| ["lsp", "serve"].map(ToOwned::to_owned).into()); + + if let Some(path) = binary_settings.and_then(|binary_settings| binary_settings.path) { + return Ok(zed::Command { + command: path, + args, + env: Default::default(), + }); + } else if let Some(path) = self.cached_binary_path.clone() { + return Ok(zed::Command { + command: path, + args, + env: Default::default(), + }); + } else if let Some(path) = worktree.which(Self::SERVER_NAME) { + self.cached_binary_path = Some(path.clone()); + return Ok(zed::Command { + command: path, + args, + env: Default::default(), + }); + } + + let latest_release = zed::latest_github_release( + "bufbuild/buf", + GithubReleaseOptions { + require_assets: true, + pre_release: false, + }, + )?; + + let (os, arch) = zed::current_platform(); + + let release_suffix = match (os, arch) { + (Os::Mac, Architecture::Aarch64) => "Darwin-arm64", + (Os::Mac, Architecture::X8664) => "Darwin-x86_64", + (Os::Linux, Architecture::Aarch64) => "Linux-aarch64", + (Os::Linux, Architecture::X8664) => "Linux-x86_64", + (Os::Windows, Architecture::Aarch64) => "Windows-arm64.exe", + (Os::Windows, Architecture::X8664) => "Windows-x86_64.exe", + _ => { + return Err("Platform and architecture not supported by buf CLI".to_string()); + } + }; + + let release_name = format!("buf-{release_suffix}"); + + let version_dir = format!("{}-{}", Self::SERVER_NAME, latest_release.version); + fs::create_dir_all(&version_dir).map_err(|_| "Could not create directory")?; + + let binary_path = format!("{version_dir}/buf"); + + let download_target = latest_release + .assets + .into_iter() + .find(|asset| asset.name == release_name) + .ok_or_else(|| { + format!( + "Could not find asset with name {} in buf CLI release", + &release_name + ) + })?; + + zed::download_file( + &download_target.download_url, + &binary_path, + DownloadedFileType::Uncompressed, + )?; + zed::make_file_executable(&binary_path)?; + + util::remove_outdated_versions(Self::SERVER_NAME, &version_dir)?; + + self.cached_binary_path = Some(binary_path.clone()); + + Ok(zed::Command { + command: binary_path, + args, + env: Default::default(), + }) + } +} diff --git a/extensions/proto/src/language_servers/protobuf_language_server.rs b/extensions/proto/src/language_servers/protobuf_language_server.rs new file mode 100644 index 0000000000000000000000000000000000000000..f4b13077f73182dd0c30486ee274ade26ec1e40e --- /dev/null +++ b/extensions/proto/src/language_servers/protobuf_language_server.rs @@ -0,0 +1,52 @@ +use zed_extension_api::{self as zed, Result, settings::LspSettings}; + +pub(crate) struct ProtobufLanguageServer { + cached_binary_path: Option, +} + +impl ProtobufLanguageServer { + pub(crate) const SERVER_NAME: &str = "protobuf-language-server"; + + pub(crate) fn new() -> Self { + ProtobufLanguageServer { + cached_binary_path: None, + } + } + + pub(crate) fn language_server_binary( + &mut self, + worktree: &zed::Worktree, + ) -> Result { + let binary_settings = LspSettings::for_worktree(Self::SERVER_NAME, worktree) + .ok() + .and_then(|lsp_settings| lsp_settings.binary); + + let args = binary_settings + .as_ref() + .and_then(|binary_settings| binary_settings.arguments.clone()) + .unwrap_or_else(|| vec!["-logs".into(), "".into()]); + + if let Some(path) = binary_settings.and_then(|binary_settings| binary_settings.path) { + Ok(zed::Command { + command: path, + args, + env: Default::default(), + }) + } else if let Some(path) = self.cached_binary_path.clone() { + Ok(zed::Command { + command: path, + args, + env: Default::default(), + }) + } else if let Some(path) = worktree.which(Self::SERVER_NAME) { + self.cached_binary_path = Some(path.clone()); + Ok(zed::Command { + command: path, + args, + env: Default::default(), + }) + } else { + Err(format!("{} not found in PATH", Self::SERVER_NAME)) + } + } +} diff --git a/extensions/proto/src/language_servers/protols.rs b/extensions/proto/src/language_servers/protols.rs new file mode 100644 index 0000000000000000000000000000000000000000..90d365eae7d99ccb27d60f774ed700b47323d8d0 --- /dev/null +++ b/extensions/proto/src/language_servers/protols.rs @@ -0,0 +1,113 @@ +use zed_extension_api::{ + self as zed, Architecture, DownloadedFileType, GithubReleaseOptions, Os, Result, + settings::LspSettings, +}; + +use crate::language_servers::util; + +pub(crate) struct ProtoLs { + cached_binary_path: Option, +} + +impl ProtoLs { + pub(crate) const SERVER_NAME: &str = "protols"; + + pub(crate) fn new() -> Self { + ProtoLs { + cached_binary_path: None, + } + } + + pub(crate) fn language_server_binary( + &mut self, + worktree: &zed::Worktree, + ) -> Result { + let binary_settings = LspSettings::for_worktree(Self::SERVER_NAME, worktree) + .ok() + .and_then(|lsp_settings| lsp_settings.binary); + + let args = binary_settings + .as_ref() + .and_then(|binary_settings| binary_settings.arguments.clone()) + .unwrap_or_default(); + + let env = worktree.shell_env(); + + if let Some(path) = binary_settings.and_then(|binary_settings| binary_settings.path) { + return Ok(zed::Command { + command: path, + args, + env, + }); + } else if let Some(path) = self.cached_binary_path.clone() { + return Ok(zed::Command { + command: path, + args, + env, + }); + } else if let Some(path) = worktree.which(Self::SERVER_NAME) { + self.cached_binary_path = Some(path.clone()); + return Ok(zed::Command { + command: path, + args, + env, + }); + } + + let latest_release = zed::latest_github_release( + "coder3101/protols", + GithubReleaseOptions { + require_assets: true, + pre_release: false, + }, + )?; + + let (os, arch) = zed::current_platform(); + + let release_suffix = match (os, arch) { + (Os::Mac, Architecture::Aarch64) => "aarch64-apple-darwin.tar.gz", + (Os::Mac, Architecture::X8664) => "x86_64-apple-darwin.tar.gz", + (Os::Linux, Architecture::Aarch64) => "aarch64-unknown-linux-gnu.tar.gz", + (Os::Linux, Architecture::X8664) => "x86_64-unknown-linux-gnu.tar.gz", + (Os::Windows, Architecture::X8664) => "x86_64-pc-windows-msvc.zip", + _ => { + return Err("Platform and architecture not supported by Protols".to_string()); + } + }; + + let release_name = format!("protols-{release_suffix}"); + + let file_type = if os == Os::Windows { + DownloadedFileType::Zip + } else { + DownloadedFileType::GzipTar + }; + + let version_dir = format!("{}-{}", Self::SERVER_NAME, latest_release.version); + let binary_path = format!("{version_dir}/protols"); + + let download_target = latest_release + .assets + .into_iter() + .find(|asset| asset.name == release_name) + .ok_or_else(|| { + format!( + "Could not find asset with name {} in Protols release", + &release_name + ) + })?; + + zed::download_file(&download_target.download_url, &version_dir, file_type)?; + zed::make_file_executable(&binary_path)?; + + util::remove_outdated_versions(Self::SERVER_NAME, &version_dir)?; + + self.cached_binary_path = Some(binary_path.clone()); + + Ok(zed::Command { + command: binary_path, + args, + env, + }) + } +} diff --git a/extensions/proto/src/language_servers/util.rs b/extensions/proto/src/language_servers/util.rs new file mode 100644 index 0000000000000000000000000000000000000000..3036c9bc3aaf9cc3fccd462fe0ad70aa31892012 --- /dev/null +++ b/extensions/proto/src/language_servers/util.rs @@ -0,0 +1,19 @@ +use std::fs; + +use zed_extension_api::Result; + +pub(super) fn remove_outdated_versions( + language_server_id: &'static str, + version_dir: &str, +) -> Result<()> { + let entries = fs::read_dir(".").map_err(|e| format!("failed to list working directory {e}"))?; + for entry in entries { + let entry = entry.map_err(|e| format!("failed to load directory entry {e}"))?; + if entry.file_name().to_str().is_none_or(|file_name| { + file_name.starts_with(language_server_id) && file_name != version_dir + }) { + fs::remove_dir_all(entry.path()).ok(); + } + } + Ok(()) +} diff --git a/extensions/proto/src/proto.rs b/extensions/proto/src/proto.rs index 36ba0faf5feda66af8824387240e34a730a476b7..07e0ccedcee287f037576db56d5a9d7958ea83f9 100644 --- a/extensions/proto/src/proto.rs +++ b/extensions/proto/src/proto.rs @@ -1,48 +1,22 @@ use zed_extension_api::{self as zed, Result, settings::LspSettings}; -const PROTOBUF_LANGUAGE_SERVER_NAME: &str = "protobuf-language-server"; +use crate::language_servers::{BufLsp, ProtoLs, ProtobufLanguageServer}; -struct ProtobufLanguageServerBinary { - path: String, - args: Option>, -} - -struct ProtobufExtension; - -impl ProtobufExtension { - fn language_server_binary( - &self, - _language_server_id: &zed::LanguageServerId, - worktree: &zed::Worktree, - ) -> Result { - let binary_settings = LspSettings::for_worktree("protobuf-language-server", worktree) - .ok() - .and_then(|lsp_settings| lsp_settings.binary); - let binary_args = binary_settings - .as_ref() - .and_then(|binary_settings| binary_settings.arguments.clone()); - - if let Some(path) = binary_settings.and_then(|binary_settings| binary_settings.path) { - return Ok(ProtobufLanguageServerBinary { - path, - args: binary_args, - }); - } - - if let Some(path) = worktree.which(PROTOBUF_LANGUAGE_SERVER_NAME) { - return Ok(ProtobufLanguageServerBinary { - path, - args: binary_args, - }); - } +mod language_servers; - Err(format!("{PROTOBUF_LANGUAGE_SERVER_NAME} not found in PATH",)) - } +struct ProtobufExtension { + protobuf_language_server: Option, + protols: Option, + buf_lsp: Option, } impl zed::Extension for ProtobufExtension { fn new() -> Self { - Self + Self { + protobuf_language_server: None, + protols: None, + buf_lsp: None, + } } fn language_server_command( @@ -50,14 +24,24 @@ impl zed::Extension for ProtobufExtension { language_server_id: &zed_extension_api::LanguageServerId, worktree: &zed_extension_api::Worktree, ) -> zed_extension_api::Result { - let binary = self.language_server_binary(language_server_id, worktree)?; - Ok(zed::Command { - command: binary.path, - args: binary - .args - .unwrap_or_else(|| vec!["-logs".into(), "".into()]), - env: Default::default(), - }) + match language_server_id.as_ref() { + ProtobufLanguageServer::SERVER_NAME => self + .protobuf_language_server + .get_or_insert_with(ProtobufLanguageServer::new) + .language_server_binary(worktree), + + ProtoLs::SERVER_NAME => self + .protols + .get_or_insert_with(ProtoLs::new) + .language_server_binary(worktree), + + BufLsp::SERVER_NAME => self + .buf_lsp + .get_or_insert_with(BufLsp::new) + .language_server_binary(worktree), + + _ => Err(format!("Unknown language server ID {}", language_server_id)), + } } fn language_server_workspace_configuration( @@ -65,10 +49,8 @@ impl zed::Extension for ProtobufExtension { server_id: &zed::LanguageServerId, worktree: &zed::Worktree, ) -> Result> { - let settings = LspSettings::for_worktree(server_id.as_ref(), worktree) - .ok() - .and_then(|lsp_settings| lsp_settings.settings); - Ok(settings) + LspSettings::for_worktree(server_id.as_ref(), worktree) + .map(|lsp_settings| lsp_settings.settings) } fn language_server_initialization_options( @@ -76,10 +58,8 @@ impl zed::Extension for ProtobufExtension { server_id: &zed::LanguageServerId, worktree: &zed::Worktree, ) -> Result> { - let initialization_options = LspSettings::for_worktree(server_id.as_ref(), worktree) - .ok() - .and_then(|lsp_settings| lsp_settings.initialization_options); - Ok(initialization_options) + LspSettings::for_worktree(server_id.as_ref(), worktree) + .map(|lsp_settings| lsp_settings.initialization_options) } } 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/script/bundle-mac b/script/bundle-mac index c6c925f073600336f4aa3114a732609481ade26e..93ea07b162612d27784dbd0eb54598b0aa2252c3 100755 --- a/script/bundle-mac +++ b/script/bundle-mac @@ -106,6 +106,17 @@ mv Cargo.toml.backup Cargo.toml popd echo "Bundled ${app_path}" +# DocumentTypes.plist references CFBundleTypeIconFile "Document", so the bundle must contain Document.icns. +# We use the app icon as a placeholder document icon for now. +document_icon_source="crates/zed/resources/Document.icns" +document_icon_target="${app_path}/Contents/Resources/Document.icns" +if [[ -f "${document_icon_source}" ]]; then + mkdir -p "$(dirname "${document_icon_target}")" + cp "${document_icon_source}" "${document_icon_target}" +else + echo "cargo::warning=Missing ${document_icon_source}; macOS document icons may not appear in Finder." +fi + if [[ -n "${MACOS_CERTIFICATE:-}" && -n "${MACOS_CERTIFICATE_PASSWORD:-}" && -n "${APPLE_NOTARIZATION_KEY:-}" && -n "${APPLE_NOTARIZATION_KEY_ID:-}" && -n "${APPLE_NOTARIZATION_ISSUER_ID:-}" ]]; then can_code_sign=true diff --git a/script/prettier b/script/prettier index b1d28fb66d70c08a6d03b21be6f168fd0b2da5dc..d7a9ba787fca2343cd705ff0d37e502a7aa9f77c 100755 --- a/script/prettier +++ b/script/prettier @@ -3,14 +3,20 @@ set -euxo pipefail PRETTIER_VERSION=3.5.0 -pnpm dlx "prettier@${PRETTIER_VERSION}" assets/settings/default.json --check || { +if [[ "${1:-}" == "--write" ]]; then + MODE="--write" +else + MODE="--check" +fi + +pnpm dlx "prettier@${PRETTIER_VERSION}" assets/settings/default.json --parser=jsonc $MODE || { echo "To fix, run from the root of the Zed repo:" - echo " pnpm dlx prettier@${PRETTIER_VERSION} assets/settings/default.json --write" + echo " pnpm dlx prettier@${PRETTIER_VERSION} assets/settings/default.json --parser=jsonc --write" false } cd docs -pnpm dlx "prettier@${PRETTIER_VERSION}" . --check || { +pnpm dlx "prettier@${PRETTIER_VERSION}" . $MODE || { echo "To fix, run from the root of the Zed repo:" echo " cd docs && pnpm dlx prettier@${PRETTIER_VERSION} . --write && cd .." false diff --git a/script/verify-macos-document-icon b/script/verify-macos-document-icon new file mode 100755 index 0000000000000000000000000000000000000000..de2581c9df764ee2019740048381d6d66dc3499d --- /dev/null +++ b/script/verify-macos-document-icon @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: + script/verify-macos-document-icon /path/to/Zed.app + +Verifies that the given macOS app bundle's Info.plist references a document icon +named "Document" and that the corresponding icon file exists in the bundle. + +Specifically checks: + - CFBundleDocumentTypes[*].CFBundleTypeIconFile includes "Document" + - Contents/Resources/Document.icns exists + +Exit codes: + 0 - success + 1 - verification failed + 2 - invalid usage / missing prerequisites +USAGE +} + +fail() { + echo "error: $*" >&2 + exit 1 +} + +if [[ $# -ne 1 ]]; then + usage >&2 + exit 2 +fi + +app_path="$1" + +if [[ ! -d "${app_path}" ]]; then + fail "app bundle not found: ${app_path}" +fi + +info_plist="${app_path}/Contents/Info.plist" +if [[ ! -f "${info_plist}" ]]; then + fail "missing Info.plist: ${info_plist}" +fi + +if ! command -v plutil >/dev/null 2>&1; then + fail "plutil not found (required on macOS to read Info.plist)" +fi + +# Convert to JSON for robust parsing. plutil outputs JSON to stdout in this mode. +info_json="$(plutil -convert json -o - "${info_plist}")" + +# Check that CFBundleDocumentTypes exists and that at least one entry references "Document". +# We use Python for JSON parsing; macOS ships with Python 3 on many setups, but not all. +# If python3 isn't available, fall back to a simpler grep-based check. +has_document_icon_ref="false" +if command -v python3 >/dev/null 2>&1; then + has_document_icon_ref="$(python3 -c "import json,sys; d=json.load(sys.stdin); types=d.get('CFBundleDocumentTypes', []); vals=[t.get('CFBundleTypeIconFile') for t in types if isinstance(t, dict)]; print('true' if 'Document' in vals else 'false')" <<<"${info_json}")" +else + # This is a best-effort fallback. It may produce false negatives if the JSON formatting differs. + if echo "${info_json}" | grep -q '"CFBundleTypeIconFile"[[:space:]]*:[[:space:]]*"Document"'; then + has_document_icon_ref="true" + fi +fi + +if [[ "${has_document_icon_ref}" != "true" ]]; then + echo "Verification failed for: ${app_path}" >&2 + echo "Expected Info.plist to reference CFBundleTypeIconFile \"Document\" in CFBundleDocumentTypes." >&2 + echo "Tip: This bundle may be missing DocumentTypes.plist extensions or may have different icon naming." >&2 + exit 1 +fi + +document_icon_path="${app_path}/Contents/Resources/Document.icns" +if [[ ! -f "${document_icon_path}" ]]; then + echo "Verification failed for: ${app_path}" >&2 + echo "Expected document icon to exist: ${document_icon_path}" >&2 + echo "Tip: The bundle script should copy crates/zed/resources/Document.icns into Contents/Resources/Document.icns." >&2 + exit 1 +fi + +echo "OK: ${app_path}" +echo " - Info.plist references CFBundleTypeIconFile \"Document\"" +echo " - Found ${document_icon_path}" diff --git a/tooling/xtask/src/tasks/workflows.rs b/tooling/xtask/src/tasks/workflows.rs index 717517402d619e54d30a502fcfe26418910aac35..fe476355203a69c962081c36fe350460b9df6f6b 100644 --- a/tooling/xtask/src/tasks/workflows.rs +++ b/tooling/xtask/src/tasks/workflows.rs @@ -5,6 +5,7 @@ use std::fs; use std::path::{Path, PathBuf}; mod after_release; +mod autofix_pr; mod cherry_pick; mod compare_perf; mod danger; @@ -111,6 +112,7 @@ pub fn run_workflows(_: GenerateWorkflowArgs) -> Result<()> { WorkflowFile::zed(run_tests::run_tests), WorkflowFile::zed(release::release), WorkflowFile::zed(cherry_pick::cherry_pick), + WorkflowFile::zed(autofix_pr::autofix_pr), WorkflowFile::zed(compare_perf::compare_perf), WorkflowFile::zed(run_agent_evals::run_unit_evals), WorkflowFile::zed(run_agent_evals::run_cron_unit_evals), diff --git a/tooling/xtask/src/tasks/workflows/after_release.rs b/tooling/xtask/src/tasks/workflows/after_release.rs index c99173bfe7183b5a3440804a18e0133270744654..c475617197151c5e7227a98d0119c1025c7b7177 100644 --- a/tooling/xtask/src/tasks/workflows/after_release.rs +++ b/tooling/xtask/src/tasks/workflows/after_release.rs @@ -4,10 +4,18 @@ use crate::tasks::workflows::{ release::{self, notify_on_failure}, runners, steps::{CommonJobConditions, NamedJob, checkout_repo, dependant_job, named}, - vars::{self, StepOutput}, + vars::{self, StepOutput, WorkflowInput}, }; +const TAG_NAME: &str = "${{ github.event.release.tag_name || inputs.tag_name }}"; +const IS_PRERELEASE: &str = "${{ github.event.release.prerelease || inputs.prerelease }}"; +const RELEASE_BODY: &str = "${{ github.event.release.body || inputs.body }}"; + pub fn after_release() -> Workflow { + let tag_name = WorkflowInput::string("tag_name", None); + let prerelease = WorkflowInput::bool("prerelease", None); + let body = WorkflowInput::string("body", Some(String::new())); + let refresh_zed_dev = rebuild_releases_page(); let post_to_discord = post_to_discord(&[&refresh_zed_dev]); let publish_winget = publish_winget(); @@ -20,7 +28,14 @@ pub fn after_release() -> Workflow { ]); named::workflow() - .on(Event::default().release(Release::default().types(vec![ReleaseType::Published]))) + .on(Event::default() + .release(Release::default().types(vec![ReleaseType::Published])) + .workflow_dispatch( + WorkflowDispatch::default() + .add_input(tag_name.name, tag_name.input()) + .add_input(prerelease.name, prerelease.input()) + .add_input(body.name, body.input()), + )) .add_job(refresh_zed_dev.name, refresh_zed_dev.job) .add_job(post_to_discord.name, post_to_discord.job) .add_job(publish_winget.name, publish_winget.job) @@ -30,9 +45,9 @@ pub fn after_release() -> Workflow { fn rebuild_releases_page() -> NamedJob { fn refresh_cloud_releases() -> Step { - named::bash( - "curl -fX POST https://cloud.zed.dev/releases/refresh?expect_tag=${{ github.event.release.tag_name }}", - ) + named::bash(format!( + "curl -fX POST https://cloud.zed.dev/releases/refresh?expect_tag={TAG_NAME}" + )) } fn redeploy_zed_dev() -> Step { @@ -51,15 +66,16 @@ fn rebuild_releases_page() -> NamedJob { fn post_to_discord(deps: &[&NamedJob]) -> NamedJob { fn get_release_url() -> Step { - named::bash(indoc::indoc! {r#" - if [ "${{ github.event.release.prerelease }}" == "true" ]; then - URL="https://zed.dev/releases/preview" - else - URL="https://zed.dev/releases/stable" - fi - - echo "URL=$URL" >> "$GITHUB_OUTPUT" - "#}) + named::bash(format!( + r#"if [ "{IS_PRERELEASE}" == "true" ]; then + URL="https://zed.dev/releases/preview" +else + URL="https://zed.dev/releases/stable" +fi + +echo "URL=$URL" >> "$GITHUB_OUTPUT" +"# + )) .id("get-release-url") } @@ -72,11 +88,9 @@ fn post_to_discord(deps: &[&NamedJob]) -> NamedJob { .id("get-content") .add_with(( "stringToTruncate", - indoc::indoc! {r#" - 📣 Zed [${{ github.event.release.tag_name }}](<${{ steps.get-release-url.outputs.URL }}>) was just released! - - ${{ github.event.release.body }} - "#}, + format!( + "📣 Zed [{TAG_NAME}](<${{{{ steps.get-release-url.outputs.URL }}}}>) was just released!\n\n{RELEASE_BODY}\n" + ), )) .add_with(("maxLength", 2000)) .add_with(("truncationSymbol", "...")) @@ -102,16 +116,17 @@ fn post_to_discord(deps: &[&NamedJob]) -> NamedJob { fn publish_winget() -> NamedJob { fn set_package_name() -> (Step, StepOutput) { - let step = named::pwsh(indoc::indoc! {r#" - if ("${{ github.event.release.prerelease }}" -eq "true") { - $PACKAGE_NAME = "ZedIndustries.Zed.Preview" - } else { - $PACKAGE_NAME = "ZedIndustries.Zed" - } - - echo "PACKAGE_NAME=$PACKAGE_NAME" >> $env:GITHUB_OUTPUT - "#}) - .id("set-package-name"); + let script = format!( + r#"if ("{IS_PRERELEASE}" -eq "true") {{ + $PACKAGE_NAME = "ZedIndustries.Zed.Preview" +}} else {{ + $PACKAGE_NAME = "ZedIndustries.Zed" +}} + +echo "PACKAGE_NAME=$PACKAGE_NAME" >> $env:GITHUB_OUTPUT +"# + ); + let step = named::pwsh(&script).id("set-package-name"); let output = StepOutput::new(&step, "PACKAGE_NAME"); (step, output) @@ -124,6 +139,7 @@ fn publish_winget() -> NamedJob { "19e706d4c9121098010096f9c495a70a7518b30f", // v2 ) .add_with(("identifier", package_name.to_string())) + .add_with(("release-tag", TAG_NAME)) .add_with(("max-versions-to-keep", 5)) .add_with(("token", vars::WINGET_TOKEN)) } diff --git a/tooling/xtask/src/tasks/workflows/autofix_pr.rs b/tooling/xtask/src/tasks/workflows/autofix_pr.rs new file mode 100644 index 0000000000000000000000000000000000000000..ab59e735225dfb4f9658960a35a992553642b4c2 --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/autofix_pr.rs @@ -0,0 +1,155 @@ +use gh_workflow::*; + +use crate::tasks::workflows::{ + runners, + steps::{self, FluentBuilder, NamedJob, named}, + vars::{self, StepOutput, WorkflowInput}, +}; + +pub fn autofix_pr() -> Workflow { + let pr_number = WorkflowInput::string("pr_number", None); + 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()) + .add_input(run_clippy.name, run_clippy.input()), + )) + .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) +} + +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", + "upload-artifact", + "330a01c490aca151604b8cf639adc76d48f6c5d4", // v5 + ) + .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 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 { + named::bash("cargo fmt --all") + } + + fn run_clippy_fix() -> Step { + named::bash( + "cargo clippy --workspace --release --all-targets --all-features --fix --allow-dirty --allow-staged", + ) + } + + fn run_prettier_fix() -> Step { + named::bash("./script/prettier --write") + } + + 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 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 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", + "234243425+zed-zippy[bot]@users.noreply.github.com", + )) + .add_env(("GIT_AUTHOR_NAME", "Zed Zippy")) + .add_env(( + "GIT_AUTHOR_EMAIL", + "234243425+zed-zippy[bot]@users.noreply.github.com", + )) + .add_env(("GITHUB_TOKEN", token)) + } + + let (authenticate, token) = steps::authenticate_as_zippy(); + + named::job( + Job::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(download_patch_artifact()) + .add_step(apply_patch()) + .add_step(commit_and_push(&token)), + ) +} diff --git a/tooling/xtask/src/tasks/workflows/cherry_pick.rs b/tooling/xtask/src/tasks/workflows/cherry_pick.rs index 105bf74c4194a46ad4ca62991fae3a945eea150d..eaa786837f84ebf4d4f7e1a579db0c7b4dcc5040 100644 --- a/tooling/xtask/src/tasks/workflows/cherry_pick.rs +++ b/tooling/xtask/src/tasks/workflows/cherry_pick.rs @@ -3,7 +3,7 @@ use gh_workflow::*; use crate::tasks::workflows::{ runners, steps::{self, NamedJob, named}, - vars::{self, StepOutput, WorkflowInput}, + vars::{StepOutput, WorkflowInput}, }; pub fn cherry_pick() -> Workflow { @@ -29,19 +29,6 @@ fn run_cherry_pick( commit: &WorkflowInput, channel: &WorkflowInput, ) -> NamedJob { - fn authenticate_as_zippy() -> (Step, StepOutput) { - let step = named::uses( - "actions", - "create-github-app-token", - "bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1", - ) // v2 - .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 cherry_pick( branch: &WorkflowInput, commit: &WorkflowInput, @@ -54,7 +41,7 @@ fn run_cherry_pick( .add_env(("GITHUB_TOKEN", token)) } - let (authenticate, token) = authenticate_as_zippy(); + let (authenticate, token) = steps::authenticate_as_zippy(); named::job( Job::default() diff --git a/tooling/xtask/src/tasks/workflows/extension_bump.rs b/tooling/xtask/src/tasks/workflows/extension_bump.rs index 34fcf8099031ec9d5562c76f45073a9936c285ff..8772011a2d1f48550095a916ab516cc98ac2d1f7 100644 --- a/tooling/xtask/src/tasks/workflows/extension_bump.rs +++ b/tooling/xtask/src/tasks/workflows/extension_bump.rs @@ -1,4 +1,4 @@ -use gh_workflow::*; +use gh_workflow::{ctx::Context, *}; use indoc::indoc; use crate::tasks::workflows::{ @@ -287,7 +287,8 @@ fn create_pull_request(new_version: StepOutput, generated_token: StepOutput) -> .add("base", "main") .add("delete-branch", true) .add("token", generated_token.to_string()) - .add("sign-commits", true), + .add("sign-commits", true) + .add("assignees", Context::github().actor().to_string()), ) } diff --git a/tooling/xtask/src/tasks/workflows/extensions/mod.rs b/tooling/xtask/src/tasks/workflows/extensions.rs similarity index 100% rename from tooling/xtask/src/tasks/workflows/extensions/mod.rs rename to tooling/xtask/src/tasks/workflows/extensions.rs diff --git a/tooling/xtask/src/tasks/workflows/release.rs b/tooling/xtask/src/tasks/workflows/release.rs index e06a71340192c036d442d65d9572e52ed2983cae..80fb075f7f6445b1a6a078d9defba2018a406851 100644 --- a/tooling/xtask/src/tasks/workflows/release.rs +++ b/tooling/xtask/src/tasks/workflows/release.rs @@ -97,17 +97,20 @@ pub(crate) fn create_sentry_release() -> Step { } fn auto_release_preview(deps: &[&NamedJob; 1]) -> NamedJob { + let (authenticate, token) = steps::authenticate_as_zippy(); + named::job( dependant_job(deps) .runs_on(runners::LINUX_SMALL) .cond(Expression::new(indoc::indoc!( r#"startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-pre') && !endsWith(github.ref, '.0-pre')"# ))) + .add_step(authenticate) .add_step( steps::script( r#"gh release edit "$GITHUB_REF_NAME" --repo=zed-industries/zed --draft=false"#, ) - .add_env(("GITHUB_TOKEN", vars::GITHUB_TOKEN)), + .add_env(("GITHUB_TOKEN", &token)), ) ) } diff --git a/tooling/xtask/src/tasks/workflows/run_tests.rs b/tooling/xtask/src/tasks/workflows/run_tests.rs index e4443ad91313fd4511765fb7be6a8bb092757e9d..d0caab82b057f21735b7f828c8917a358dd548b2 100644 --- a/tooling/xtask/src/tasks/workflows/run_tests.rs +++ b/tooling/xtask/src/tasks/workflows/run_tests.rs @@ -45,11 +45,15 @@ pub(crate) fn run_tests() -> Workflow { &should_run_tests, ]); + let check_style = check_style(); + let run_tests_linux = run_platform_tests(Platform::Linux); + let call_autofix = call_autofix(&check_style, &run_tests_linux); + let mut jobs = vec![ orchestrate, - check_style(), + check_style, should_run_tests.guard(run_platform_tests(Platform::Windows)), - should_run_tests.guard(run_platform_tests(Platform::Linux)), + should_run_tests.guard(run_tests_linux), should_run_tests.guard(run_platform_tests(Platform::Mac)), should_run_tests.guard(doctests()), should_run_tests.guard(check_workspace_binaries()), @@ -106,6 +110,7 @@ pub(crate) fn run_tests() -> Workflow { workflow }) .add_job(tests_pass.name, tests_pass.job) + .add_job(call_autofix.name, call_autofix.job) } // Generates a bash script that checks changed files against regex patterns @@ -221,6 +226,8 @@ pub fn tests_pass(jobs: &[NamedJob]) -> NamedJob { named::job(job) } +pub const STYLE_FAILED_OUTPUT: &str = "style_failed"; + fn check_style() -> NamedJob { fn check_for_typos() -> Step { named::uses( @@ -236,14 +243,58 @@ fn check_style() -> NamedJob { .add_step(steps::checkout_repo()) .add_step(steps::cache_rust_dependencies_namespace()) .add_step(steps::setup_pnpm()) - .add_step(steps::script("./script/prettier")) + .add_step(steps::prettier()) + .add_step(steps::cargo_fmt()) + .add_step(steps::record_style_failure()) .add_step(steps::script("./script/check-todos")) .add_step(steps::script("./script/check-keymaps")) .add_step(check_for_typos()) - .add_step(steps::cargo_fmt()), + .outputs([( + STYLE_FAILED_OUTPUT.to_owned(), + format!( + "${{{{ steps.{}.outputs.failed == 'true' }}}}", + steps::RECORD_STYLE_FAILURE_STEP_ID + ), + )]), ) } +fn call_autofix(check_style: &NamedJob, run_tests_linux: &NamedJob) -> NamedJob { + fn dispatch_autofix(run_tests_linux_name: &str) -> Step { + let clippy_failed_expr = format!( + "needs.{}.outputs.{} == 'true'", + run_tests_linux_name, CLIPPY_FAILED_OUTPUT + ); + named::bash(format!( + "gh workflow run autofix_pr.yml -f pr_number=${{{{ github.event.pull_request.number }}}} -f run_clippy=${{{{ {} }}}}", + clippy_failed_expr + )) + .add_env(("GITHUB_TOKEN", "${{ steps.get-app-token.outputs.token }}")) + } + + let style_failed_expr = format!( + "needs.{}.outputs.{} == 'true'", + check_style.name, STYLE_FAILED_OUTPUT + ); + let clippy_failed_expr = format!( + "needs.{}.outputs.{} == 'true'", + run_tests_linux.name, CLIPPY_FAILED_OUTPUT + ); + let (authenticate, _token) = steps::authenticate_as_zippy(); + + let job = Job::default() + .runs_on(runners::LINUX_SMALL) + .cond(Expression::new(format!( + "always() && ({} || {}) && github.event_name == 'pull_request' && github.actor != 'zed-zippy[bot]'", + style_failed_expr, clippy_failed_expr + ))) + .needs(vec![check_style.name.clone(), run_tests_linux.name.clone()]) + .add_step(authenticate) + .add_step(dispatch_autofix(&run_tests_linux.name)); + + named::job(job) +} + fn check_dependencies() -> NamedJob { fn install_cargo_machete() -> Step { named::uses( @@ -304,6 +355,8 @@ fn check_workspace_binaries() -> NamedJob { ) } +pub const CLIPPY_FAILED_OUTPUT: &str = "clippy_failed"; + pub(crate) fn run_platform_tests(platform: Platform) -> NamedJob { let runner = match platform { Platform::Windows => runners::WINDOWS_DEFAULT, @@ -325,12 +378,24 @@ 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::record_clippy_failure()) + }) .when(platform == Platform::Linux, |job| { job.add_step(steps::cargo_install_nextest()) }) .add_step(steps::clear_target_dir_if_large(platform)) .add_step(steps::cargo_nextest(platform)) - .add_step(steps::cleanup_cargo_config(platform)), + .add_step(steps::cleanup_cargo_config(platform)) + .when(platform == Platform::Linux, |job| { + job.outputs([( + CLIPPY_FAILED_OUTPUT.to_owned(), + format!( + "${{{{ steps.{}.outputs.failed == 'true' }}}}", + steps::RECORD_CLIPPY_FAILURE_STEP_ID + ), + )]) + }), } } @@ -368,6 +433,8 @@ pub(crate) fn check_postgres_and_protobuf_migrations() -> NamedJob { .runs_on(runners::LINUX_DEFAULT) .add_env(("GIT_AUTHOR_NAME", "Protobuf Action")) .add_env(("GIT_AUTHOR_EMAIL", "ci@zed.dev")) + .add_env(("GIT_COMMITTER_NAME", "Protobuf Action")) + .add_env(("GIT_COMMITTER_EMAIL", "ci@zed.dev")) .add_step(steps::checkout_repo().with(("fetch-depth", 0))) // fetch full history .add_step(remove_untracked_files()) .add_step(ensure_fresh_merge()) diff --git a/tooling/xtask/src/tasks/workflows/steps.rs b/tooling/xtask/src/tasks/workflows/steps.rs index 722a5f0704542889703fdbb42c691d01bc50ace6..eaa51dc35205f51e7fe3a56668ed0679e92999f0 100644 --- a/tooling/xtask/src/tasks/workflows/steps.rs +++ b/tooling/xtask/src/tasks/workflows/steps.rs @@ -1,6 +1,6 @@ use gh_workflow::*; -use crate::tasks::workflows::{runners::Platform, vars}; +use crate::tasks::workflows::{runners::Platform, vars, vars::StepOutput}; pub const BASH_SHELL: &str = "bash -euxo pipefail {0}"; // https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idstepsshell @@ -17,6 +17,16 @@ pub fn checkout_repo() -> Step { .add_with(("clean", false)) } +pub fn checkout_repo_with_token(token: &StepOutput) -> Step { + named::uses( + "actions", + "checkout", + "11bd71901bbe5b1630ceea73d27597364c9af683", // v4 + ) + .add_with(("clean", false)) + .add_with(("token", token.to_string())) +} + pub fn setup_pnpm() -> Step { named::uses( "pnpm", @@ -44,8 +54,25 @@ pub fn setup_sentry() -> Step { .add_with(("token", vars::SENTRY_AUTH_TOKEN)) } +pub const PRETTIER_STEP_ID: &str = "prettier"; +pub const CARGO_FMT_STEP_ID: &str = "cargo_fmt"; +pub const RECORD_STYLE_FAILURE_STEP_ID: &str = "record_style_failure"; + +pub fn prettier() -> Step { + named::bash("./script/prettier").id(PRETTIER_STEP_ID) +} + pub fn cargo_fmt() -> Step { - named::bash("cargo fmt --all -- --check") + named::bash("cargo fmt --all -- --check").id(CARGO_FMT_STEP_ID) +} + +pub fn record_style_failure() -> Step { + named::bash(format!( + "echo \"failed=${{{{ steps.{}.outcome == 'failure' || steps.{}.outcome == 'failure' }}}}\" >> \"$GITHUB_OUTPUT\"", + PRETTIER_STEP_ID, CARGO_FMT_STEP_ID + )) + .id(RECORD_STYLE_FAILURE_STEP_ID) + .if_condition(Expression::new("always()")) } pub fn cargo_install_nextest() -> Step { @@ -91,13 +118,25 @@ pub fn clear_target_dir_if_large(platform: Platform) -> Step { } } +pub const CLIPPY_STEP_ID: &str = "clippy"; +pub const RECORD_CLIPPY_FAILURE_STEP_ID: &str = "record_clippy_failure"; + pub fn clippy(platform: Platform) -> Step { match platform { - Platform::Windows => named::pwsh("./script/clippy.ps1"), - _ => named::bash("./script/clippy"), + Platform::Windows => named::pwsh("./script/clippy.ps1").id(CLIPPY_STEP_ID), + _ => named::bash("./script/clippy").id(CLIPPY_STEP_ID), } } +pub fn record_clippy_failure() -> Step { + named::bash(format!( + "echo \"failed=${{{{ steps.{}.outcome == 'failure' }}}}\" >> \"$GITHUB_OUTPUT\"", + CLIPPY_STEP_ID + )) + .id(RECORD_CLIPPY_FAILURE_STEP_ID) + .if_condition(Expression::new("always()")) +} + pub fn cache_rust_dependencies_namespace() -> Step { named::uses("namespacelabs", "nscloud-cache-action", "v1").add_with(("cache", "rust")) } @@ -334,3 +373,16 @@ pub fn git_checkout(ref_name: &dyn std::fmt::Display) -> Step { "git fetch origin {ref_name} && git checkout {ref_name}" )) } + +pub 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) +} diff --git a/typos.toml b/typos.toml index cfc4ec86a853d1aeb16ca41fefd1d9fe368659d1..8e42bd674a64d8adc1e684df181c8e4ce67988e9 100644 --- a/typos.toml +++ b/typos.toml @@ -31,6 +31,9 @@ extend-exclude = [ "crates/rpc/src/auth.rs", # glsl isn't recognized by this tool. "extensions/glsl/languages/glsl/", + # Protols is the name of the language server. + "extensions/proto/extension.toml", + "extensions/proto/src/language_servers/protols.rs", # Windows likes its abbreviations. "crates/gpui/src/platform/windows/directx_renderer.rs", "crates/gpui/src/platform/windows/events.rs", @@ -52,6 +55,8 @@ extend-exclude = [ "crates/project_panel/benches/linux_repo_snapshot.txt", # Some multibuffer test cases have word fragments that register as typos "crates/multi_buffer/src/multi_buffer_tests.rs", + # Macos apis + "crates/gpui/src/platform/mac/dispatcher.rs", ] [default]