diff --git a/.github/CODEOWNERS.hold b/.github/CODEOWNERS.hold index 3d315b36401b2e27e29a2377aeabab8c09c75d39..3b7cbc644768f82646591619e49c4b6a0d6de200 100644 --- a/.github/CODEOWNERS.hold +++ b/.github/CODEOWNERS.hold @@ -48,7 +48,6 @@ /crates/edit_prediction_context/ @zed-industries/ai-team /crates/edit_prediction_types/ @zed-industries/ai-team /crates/edit_prediction_ui/ @zed-industries/ai-team -/crates/eval/ @zed-industries/ai-team /crates/eval_utils/ @zed-industries/ai-team /crates/google_ai/ @zed-industries/ai-team /crates/language_model/ @zed-industries/ai-team diff --git a/.github/actions/run_tests/action.yml b/.github/actions/run_tests/action.yml index a071aba3a87dcf8e8f48f740115cfddf48b9f805..610c334a65c3a3817ab0ee2bb7356a923643092b 100644 --- a/.github/actions/run_tests/action.yml +++ b/.github/actions/run_tests/action.yml @@ -5,7 +5,7 @@ runs: using: "composite" steps: - name: Install nextest - uses: taiki-e/install-action@nextest + uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c # nextest - name: Install Node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 diff --git a/.github/actions/run_tests_windows/action.yml b/.github/actions/run_tests_windows/action.yml index 307b73f363b7d5fd7a3c9e5082c4f17d622ec165..3752cbb50d538459ea58d2219e591d1abbda6247 100644 --- a/.github/actions/run_tests_windows/action.yml +++ b/.github/actions/run_tests_windows/action.yml @@ -12,7 +12,7 @@ runs: steps: - name: Install test runner working-directory: ${{ inputs.working-directory }} - uses: taiki-e/install-action@nextest + uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c # nextest - name: Install Node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index b8b7939813f9cc72da88e75653b6f2933403a239..a56793ad6222e5788621f6c8a430205e9ad848d7 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,28 +1,13 @@ -## Context +Self-Review Checklist: - - -## How to Review - - - -## Self-Review Checklist - - - [ ] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [ ] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [ ] Performance impact has been considered and is acceptable +Closes #ISSUE + Release Notes: - N/A or Added/Fixed/Improved ... diff --git a/.github/workflows/add_commented_closed_issue_to_project.yml b/.github/workflows/add_commented_closed_issue_to_project.yml index bd84eaa9446e57c5482ab818df3dbcfe587e040e..27315e7160200dc323899b58d5c307aae656d5c6 100644 --- a/.github/workflows/add_commented_closed_issue_to_project.yml +++ b/.github/workflows/add_commented_closed_issue_to_project.yml @@ -35,7 +35,7 @@ jobs: - if: steps.is-post-close-comment.outputs.result == 'true' id: get-app-token - uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1 # v2.1.4 + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 with: app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }} private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }} diff --git a/.github/workflows/after_release.yml b/.github/workflows/after_release.yml index 95229f9f46bbd34ffe02832114b2b39da1b7e090..ab2220764861b17317f1fa3971ecf2aa9b645c8d 100644 --- a/.github/workflows/after_release.yml +++ b/.github/workflows/after_release.yml @@ -27,7 +27,7 @@ jobs: - 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 || inputs.tag_name }} - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: after_release::rebuild_releases_page::redeploy_zed_dev @@ -110,7 +110,7 @@ jobs: runs-on: namespace-profile-2x4-ubuntu-2404 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: release::create_sentry_release diff --git a/.github/workflows/assign-reviewers.yml b/.github/workflows/assign-reviewers.yml index 1a21879b639736232f965863a31b9a8d3a2c2b35..2a12a69defdd4f8933f1c549f0624d9bdcc9fd40 100644 --- a/.github/workflows/assign-reviewers.yml +++ b/.github/workflows/assign-reviewers.yml @@ -51,7 +51,7 @@ jobs: steps: - name: Generate app token id: app-token - uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 with: app-id: ${{ vars.COORDINATOR_APP_ID }} private-key: ${{ secrets.COORDINATOR_APP_PRIVATE_KEY }} @@ -60,7 +60,7 @@ jobs: # SECURITY: checks out the coordinator repo at ref: main, NOT the PR branch. # persist-credentials: false prevents the token from leaking into .git/config. - name: Checkout coordinator repo - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: repository: zed-industries/codeowner-coordinator ref: main @@ -69,7 +69,7 @@ jobs: persist-credentials: false - name: Setup Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.11" @@ -83,6 +83,8 @@ jobs: GH_TOKEN: ${{ steps.app-token.outputs.token }} PR_URL: ${{ github.event.pull_request.html_url }} TARGET_REPO: ${{ github.repository }} + ASSIGN_INTERNAL: ${{ vars.ASSIGN_INTERNAL || 'false' }} + ASSIGN_EXTERNAL: ${{ vars.ASSIGN_EXTERNAL || 'true' }} run: | cd codeowner-coordinator python .github/scripts/assign-reviewers.py \ @@ -95,7 +97,7 @@ jobs: - name: Upload output if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: assign-reviewers-output path: /tmp/assign-reviewers-output.txt diff --git a/.github/workflows/autofix_pr.yml b/.github/workflows/autofix_pr.yml index 1f9e6320700d14cab69662e317c30fa7206eb655..f055c078cf4f814e342697e311ad5660f68f4624 100644 --- a/.github/workflows/autofix_pr.yml +++ b/.github/workflows/autofix_pr.yml @@ -18,7 +18,7 @@ jobs: runs-on: namespace-profile-16x32-ubuntu-2204 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: autofix_pr::run_autofix::checkout_pr @@ -31,7 +31,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -91,22 +91,22 @@ jobs: if: needs.run_autofix.outputs.has_changes == 'true' runs-on: namespace-profile-2x4-ubuntu-2404 steps: - - id: get-app-token + - id: generate-token name: steps::authenticate_as_zippy - uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1 + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 with: app-id: ${{ secrets.ZED_ZIPPY_APP_ID }} private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - token: ${{ steps.get-app-token.outputs.token }} + token: ${{ steps.generate-token.outputs.token }} - name: autofix_pr::commit_changes::checkout_pr run: gh pr checkout "$PR_NUMBER" env: PR_NUMBER: ${{ inputs.pr_number }} - GITHUB_TOKEN: ${{ steps.get-app-token.outputs.token }} + GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} - name: autofix_pr::download_patch_artifact uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 with: @@ -122,7 +122,7 @@ jobs: 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 }} + GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} concurrency: group: ${{ github.workflow }}-${{ inputs.pr_number }} cancel-in-progress: true diff --git a/.github/workflows/background_agent_mvp.yml b/.github/workflows/background_agent_mvp.yml index 528600138243cb8aca2e0fe0645eda198fc4f2b2..2f048d572df6fb45368c6d7aece574e83c9e7949 100644 --- a/.github/workflows/background_agent_mvp.yml +++ b/.github/workflows/background_agent_mvp.yml @@ -38,7 +38,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: fetch-depth: 0 @@ -50,7 +50,7 @@ jobs: "${HOME}/.local/bin/droid" --version - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" diff --git a/.github/workflows/bump_collab_staging.yml b/.github/workflows/bump_collab_staging.yml index d400905b4da3304a8b916d3a38ae9d8a2855dbf5..4f9724439f37b276de625e5810c777c12f20e4b9 100644 --- a/.github/workflows/bump_collab_staging.yml +++ b/.github/workflows/bump_collab_staging.yml @@ -11,7 +11,7 @@ jobs: runs-on: namespace-profile-2x4-ubuntu-2404 steps: - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: fetch-depth: 0 diff --git a/.github/workflows/bump_patch_version.yml b/.github/workflows/bump_patch_version.yml index 62540321ed755f2fd3879a7ddfc3a37237d8e7de..6b2fa66147b656efd9c8e28cd43cd2e010930dd1 100644 --- a/.github/workflows/bump_patch_version.yml +++ b/.github/workflows/bump_patch_version.yml @@ -13,18 +13,18 @@ jobs: if: github.repository_owner == 'zed-industries' runs-on: namespace-profile-16x32-ubuntu-2204 steps: - - id: get-app-token + - id: generate-token name: steps::authenticate_as_zippy - uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1 + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 with: app-id: ${{ secrets.ZED_ZIPPY_APP_ID }} private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false ref: ${{ inputs.branch }} - token: ${{ steps.get-app-token.outputs.token }} + token: ${{ steps.generate-token.outputs.token }} - name: bump_patch_version::run_bump_patch_version::bump_patch_version run: | channel="$(cat crates/zed/RELEASE_CHANNEL)" @@ -51,7 +51,7 @@ jobs: 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 }} + GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} concurrency: group: ${{ github.workflow }}-${{ inputs.branch }} cancel-in-progress: true diff --git a/.github/workflows/catch_blank_issues.yml b/.github/workflows/catch_blank_issues.yml index c6f595ef2e0890ce107829f3e91490332567368a..dbceac5a196f2dc9c0963e491bd346dc8c0eff51 100644 --- a/.github/workflows/catch_blank_issues.yml +++ b/.github/workflows/catch_blank_issues.yml @@ -16,7 +16,7 @@ jobs: timeout-minutes: 5 steps: - id: get-app-token - uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1 # v2.1.4 + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 with: app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }} private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }} diff --git a/.github/workflows/cherry_pick.yml b/.github/workflows/cherry_pick.yml index ee0c1d35d0f9825d7c39b81fba0fe35901de2611..4a3bd0e643e027e7feaeac4760797e2a1fb16e11 100644 --- a/.github/workflows/cherry_pick.yml +++ b/.github/workflows/cherry_pick.yml @@ -26,12 +26,12 @@ jobs: runs-on: namespace-profile-2x4-ubuntu-2404 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - - id: get-app-token + - id: generate-token name: steps::authenticate_as_zippy - uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1 + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 with: app-id: ${{ secrets.ZED_ZIPPY_APP_ID }} private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} @@ -43,7 +43,7 @@ jobs: CHANNEL: ${{ inputs.channel }} GIT_COMMITTER_NAME: Zed Zippy GIT_COMMITTER_EMAIL: hi@zed.dev - GITHUB_TOKEN: ${{ steps.get-app-token.outputs.token }} + GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} defaults: run: shell: bash -euxo pipefail {0} diff --git a/.github/workflows/comment_on_potential_duplicate_issues.yml b/.github/workflows/comment_on_potential_duplicate_issues.yml index de51cb1105c98901237ec88d47c34c69ea5c8080..0d7ce3aad3ce9deacfedfe1d237c41127a639da0 100644 --- a/.github/workflows/comment_on_potential_duplicate_issues.yml +++ b/.github/workflows/comment_on_potential_duplicate_issues.yml @@ -27,14 +27,14 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: sparse-checkout: script/github-check-new-issue-for-duplicates.py sparse-checkout-cone-mode: false - name: Get github app token id: get-app-token - uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1 # v1.11.7 + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 with: app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }} private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }} diff --git a/.github/workflows/community_champion_auto_labeler.yml b/.github/workflows/community_champion_auto_labeler.yml index fa44afc16dcaee4c1e1176b9344aed476ac6d8e5..82a9e274d64725b0e55c6ced46ca64ac3890e35e 100644 --- a/.github/workflows/community_champion_auto_labeler.yml +++ b/.github/workflows/community_champion_auto_labeler.yml @@ -12,7 +12,7 @@ jobs: runs-on: namespace-profile-2x4-ubuntu-2404 steps: - name: Check if author is a community champion and apply label - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 env: COMMUNITY_CHAMPIONS: | 0x2CA diff --git a/.github/workflows/community_update_all_top_ranking_issues.yml b/.github/workflows/community_update_all_top_ranking_issues.yml index ef3b4fc39ddb5f0db9b09c5e861547ae8cd7eb08..b8003a69b243c3cafbf40857c653fb03f515eeec 100644 --- a/.github/workflows/community_update_all_top_ranking_issues.yml +++ b/.github/workflows/community_update_all_top_ranking_issues.yml @@ -10,7 +10,7 @@ jobs: runs-on: namespace-profile-2x4-ubuntu-2404 if: github.repository == 'zed-industries/zed' steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Set up uv uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3 with: diff --git a/.github/workflows/community_update_weekly_top_ranking_issues.yml b/.github/workflows/community_update_weekly_top_ranking_issues.yml index 53b548f2bb4286e5de86d3823e67d75c0413a1cb..90d1934ffcb6d5d711896d3902b70599e4b06872 100644 --- a/.github/workflows/community_update_weekly_top_ranking_issues.yml +++ b/.github/workflows/community_update_weekly_top_ranking_issues.yml @@ -10,7 +10,7 @@ jobs: runs-on: namespace-profile-2x4-ubuntu-2404 if: github.repository == 'zed-industries/zed' steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Set up uv uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3 with: diff --git a/.github/workflows/compare_perf.yml b/.github/workflows/compare_perf.yml index 03113f2aa0be4dc794f8f5edec18df22fb0daa31..2b2154ce9bd14c85d0f0d10e95c4065a458006a1 100644 --- a/.github/workflows/compare_perf.yml +++ b/.github/workflows/compare_perf.yml @@ -21,7 +21,7 @@ jobs: runs-on: namespace-profile-16x32-ubuntu-2204 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_cargo_config @@ -33,7 +33,7 @@ jobs: - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: compare_perf::run_perf::install_hyperfine - uses: taiki-e/install-action@hyperfine + uses: taiki-e/install-action@b4f2d5cb8597b15997c8ede873eb6185efc5f0ad - name: steps::git_checkout run: git fetch origin "$REF_NAME" && git checkout "$REF_NAME" env: diff --git a/.github/workflows/congrats.yml b/.github/workflows/congrats.yml index 6a4111a1c5b5143ee9be067911207d5b4ca1448c..4866b3c33bc6bab9f9d20ac1701b7d6535b356ee 100644 --- a/.github/workflows/congrats.yml +++ b/.github/workflows/congrats.yml @@ -13,7 +13,7 @@ jobs: steps: - name: Get PR info and check if author is external id: check - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 with: github-token: ${{ secrets.CONGRATSBOT_GITHUB_TOKEN }} script: | @@ -29,6 +29,13 @@ jobs: } const mergedPR = prs.find(pr => pr.merged_at !== null) || prs[0]; + + if (mergedPR.user.type === "Bot") { + // They are a good bot, but not good enough to be congratulated + core.setOutput('should_congratulate', 'false'); + return; + } + const prAuthor = mergedPR.user.login; try { @@ -50,7 +57,7 @@ jobs: congrats: needs: check-author if: needs.check-author.outputs.should_congratulate == 'true' - uses: withastro/automation/.github/workflows/congratsbot.yml@main + uses: withastro/automation/.github/workflows/congratsbot.yml@a5bd0c5748c4d56e687cdd558064f9ee8adfb1f2 # main with: EMOJIS: 🎉,🎊,🧑‍🚀,🥳,🙌,🚀,🦀,🔥,🚢 secrets: diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml index 62f799baae1fb64a31807030c5700019a3d2c1b7..62739b21675fec2b4289b646fec794846c5fe783 100644 --- a/.github/workflows/danger.yml +++ b/.github/workflows/danger.yml @@ -16,7 +16,7 @@ jobs: runs-on: namespace-profile-2x4-ubuntu-2404 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_pnpm diff --git a/.github/workflows/deploy_cloudflare.yml b/.github/workflows/deploy_cloudflare.yml index 37f23b20d2825e9f3d26c456903962a10c2d0081..4e029c63ccd8a022ac9d6107748f964585058735 100644 --- a/.github/workflows/deploy_cloudflare.yml +++ b/.github/workflows/deploy_cloudflare.yml @@ -13,7 +13,7 @@ jobs: steps: - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: clean: false diff --git a/.github/workflows/deploy_collab.yml b/.github/workflows/deploy_collab.yml index 7fe06460f752599513c79b71bb01636d69d20e6c..5a3eff186814128ebb3973642040d9228f0e87fd 100644 --- a/.github/workflows/deploy_collab.yml +++ b/.github/workflows/deploy_collab.yml @@ -17,7 +17,7 @@ jobs: CXX: clang++ steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false fetch-depth: 0 @@ -26,7 +26,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -48,7 +48,7 @@ jobs: CXX: clang++ steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false fetch-depth: 0 @@ -57,7 +57,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -66,7 +66,7 @@ jobs: - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: steps::cargo_install_nextest - uses: taiki-e/install-action@nextest + uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 250 - name: deploy_collab::tests::run_collab_tests @@ -93,7 +93,7 @@ jobs: - name: deploy_collab::publish::sign_into_registry run: doctl registry login - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: deploy_collab::publish::build_docker_image @@ -113,7 +113,7 @@ jobs: runs-on: namespace-profile-16x32-ubuntu-2204 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: deploy_collab::deploy::install_doctl diff --git a/.github/workflows/docs_suggestions.yml b/.github/workflows/docs_suggestions.yml index c2dc8b4d5197bcbf38dbfb92dac8c23386726d53..c3d04d5780b290c81470dea16d11f473ee7361b1 100644 --- a/.github/workflows/docs_suggestions.yml +++ b/.github/workflows/docs_suggestions.yml @@ -64,7 +64,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} @@ -296,7 +296,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: fetch-depth: 0 ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.ref || '' }} diff --git a/.github/workflows/extension_auto_bump.yml b/.github/workflows/extension_auto_bump.yml index 9388a0a442bf249505aaf51e9b6826d3bb228fb7..d4480194edbcacd24d0dff9bfd807abeb513d8ae 100644 --- a/.github/workflows/extension_auto_bump.yml +++ b/.github/workflows/extension_auto_bump.yml @@ -17,7 +17,7 @@ jobs: runs-on: namespace-profile-2x4-ubuntu-2404 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false fetch-depth: 2 diff --git a/.github/workflows/extension_bump.yml b/.github/workflows/extension_bump.yml index cbe38ee9e5b958eeee80eb5576c93896cc6763e1..b4cbac4ec8c0ab37ebad73eb96c2ee074ca969a6 100644 --- a/.github/workflows/extension_bump.yml +++ b/.github/workflows/extension_bump.yml @@ -5,7 +5,7 @@ env: CARGO_TERM_COLOR: always RUST_BACKTRACE: '1' CARGO_INCREMENTAL: '0' - ZED_EXTENSION_CLI_SHA: 03d8e9aee95ea6117d75a48bcac2e19241f6e667 + ZED_EXTENSION_CLI_SHA: 1fa7f1a3ec28ea1eae6db2e937d7a538fb10c0c7 on: workflow_call: inputs: @@ -34,7 +34,7 @@ jobs: runs-on: namespace-profile-2x4-ubuntu-2404 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false fetch-depth: 0 @@ -74,17 +74,17 @@ jobs: runs-on: namespace-profile-2x4-ubuntu-2404 steps: - id: generate-token - name: extension_bump::generate_token - uses: actions/create-github-app-token@v2 + name: steps::generate_token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 with: app-id: ${{ secrets.app-id }} private-key: ${{ secrets.app-secret }} - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -138,7 +138,7 @@ jobs: BUMP_TYPE: ${{ inputs.bump-type }} WORKING_DIR: ${{ inputs.working-directory }} - name: extension_bump::create_pull_request - uses: peter-evans/create-pull-request@v7 + uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 with: title: ${{ steps.bump-version.outputs.title }} body: ${{ steps.bump-version.outputs.body }} @@ -162,13 +162,13 @@ jobs: runs-on: namespace-profile-2x4-ubuntu-2404 steps: - id: generate-token - name: extension_bump::generate_token - uses: actions/create-github-app-token@v2 + name: steps::generate_token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 with: app-id: ${{ secrets.app-id }} private-key: ${{ secrets.app-secret }} - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - id: determine-tag @@ -187,7 +187,7 @@ jobs: CURRENT_VERSION: ${{ needs.check_version_changed.outputs.current_version }} WORKING_DIR: ${{ inputs.working-directory }} - name: extension_bump::create_version_tag - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b with: script: |- github.rest.git.createRef({ @@ -212,15 +212,15 @@ jobs: runs-on: namespace-profile-2x4-ubuntu-2404 steps: - id: generate-token - name: extension_bump::generate_token - uses: actions/create-github-app-token@v2 + name: steps::generate_token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 with: app-id: ${{ secrets.app-id }} private-key: ${{ secrets.app-secret }} owner: zed-industries repositories: extensions - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - id: get-extension-id @@ -239,7 +239,7 @@ jobs: env: COMMITTER_TOKEN: ${{ steps.generate-token.outputs.token }} - name: extension_bump::enable_automerge_if_staff - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b with: github-token: ${{ steps.generate-token.outputs.token }} script: | diff --git a/.github/workflows/extension_tests.yml b/.github/workflows/extension_tests.yml index 89668c028a6d1fa4baddd417687226dd55a52426..622f4c8f1034b4ec0c7625a361ecdb6fb84d9429 100644 --- a/.github/workflows/extension_tests.yml +++ b/.github/workflows/extension_tests.yml @@ -5,7 +5,7 @@ env: CARGO_TERM_COLOR: always RUST_BACKTRACE: '1' CARGO_INCREMENTAL: '0' - ZED_EXTENSION_CLI_SHA: 03d8e9aee95ea6117d75a48bcac2e19241f6e667 + ZED_EXTENSION_CLI_SHA: 1fa7f1a3ec28ea1eae6db2e937d7a538fb10c0c7 RUSTUP_TOOLCHAIN: stable CARGO_BUILD_TARGET: wasm32-wasip2 on: @@ -21,7 +21,7 @@ jobs: runs-on: namespace-profile-2x4-ubuntu-2404 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false fetch-depth: ${{ github.ref == 'refs/heads/main' && 2 || 350 }} @@ -73,11 +73,11 @@ jobs: runs-on: namespace-profile-8x32-ubuntu-2404 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -97,7 +97,7 @@ jobs: env: PACKAGE_NAME: ${{ steps.get-package-name.outputs.package_name }} - name: steps::cargo_install_nextest - uses: taiki-e/install-action@nextest + uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c - name: extension_tests::run_nextest run: 'cargo nextest run -p "$PACKAGE_NAME" --no-fail-fast --no-tests=warn --target "$(rustc -vV | sed -n ''s|host: ||p'')"' env: @@ -115,7 +115,7 @@ jobs: runs-on: namespace-profile-8x32-ubuntu-2404 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false fetch-depth: 0 @@ -131,7 +131,7 @@ jobs: wget --quiet "https://zed-extension-cli.nyc3.digitaloceanspaces.com/$ZED_EXTENSION_CLI_SHA/x86_64-unknown-linux-gnu/zed-extension" -O "$GITHUB_WORKSPACE/zed-extension" chmod +x "$GITHUB_WORKSPACE/zed-extension" - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup diff --git a/.github/workflows/extension_workflow_rollout.yml b/.github/workflows/extension_workflow_rollout.yml index f695b43ecac47a221bbc795d03e6ddd6259d7014..5bb315a730d8f25f6e1eccbbe5e1734e1cda6d99 100644 --- a/.github/workflows/extension_workflow_rollout.yml +++ b/.github/workflows/extension_workflow_rollout.yml @@ -20,7 +20,7 @@ jobs: runs-on: namespace-profile-2x4-ubuntu-2404 steps: - name: checkout_zed_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false fetch-depth: 0 @@ -57,7 +57,7 @@ jobs: PREV_COMMIT: ${{ steps.prev-tag.outputs.prev_commit }} - id: list-repos name: extension_workflow_rollout::fetch_extension_repos::get_repositories - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b with: script: | const repos = await github.paginate(github.rest.repos.listForOrg, { @@ -81,7 +81,7 @@ jobs: return filteredRepos; result-encoding: json - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -114,8 +114,8 @@ jobs: max-parallel: 10 steps: - id: generate-token - name: extension_bump::generate_token - uses: actions/create-github-app-token@v2 + name: steps::generate_token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 with: app-id: ${{ secrets.ZED_ZIPPY_APP_ID }} private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} @@ -125,7 +125,7 @@ jobs: permission-contents: write permission-workflows: write - name: checkout_extension_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false path: extension @@ -173,7 +173,7 @@ jobs: echo "sha_short=$(echo "$GITHUB_SHA" | cut -c1-7)" >> "$GITHUB_OUTPUT" - id: create-pr name: extension_workflow_rollout::rollout_workflows_to_extension::create_pull_request - uses: peter-evans/create-pull-request@v7 + uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 with: path: extension title: Update CI workflows to `${{ steps.short-sha.outputs.sha_short }}` @@ -207,14 +207,14 @@ jobs: runs-on: namespace-profile-2x4-ubuntu-2404 steps: - id: generate-token - name: extension_bump::generate_token - uses: actions/create-github-app-token@v2 + name: steps::generate_token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 with: app-id: ${{ secrets.ZED_ZIPPY_APP_ID }} private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} permission-contents: write - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false fetch-depth: 0 diff --git a/.github/workflows/good_first_issue_notifier.yml b/.github/workflows/good_first_issue_notifier.yml index f366c671726348f605325576d65e13c6faa5616e..fc1b49424dce248d107d35cd6f228dd297478cad 100644 --- a/.github/workflows/good_first_issue_notifier.yml +++ b/.github/workflows/good_first_issue_notifier.yml @@ -11,7 +11,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Prepare Discord message id: prepare-message diff --git a/.github/workflows/pr-size-check.yml b/.github/workflows/pr-size-check.yml deleted file mode 100644 index 6cbed314e012c66da16fd016dd9b3cdcf9788149..0000000000000000000000000000000000000000 --- a/.github/workflows/pr-size-check.yml +++ /dev/null @@ -1,109 +0,0 @@ -# PR Size Check — Compute -# -# Calculates PR size and saves the result as an artifact. A companion -# workflow (pr-size-label.yml) picks up the artifact via workflow_run -# and applies labels + comments with write permissions. -# -# This two-workflow split is required because fork PRs receive a -# read-only GITHUB_TOKEN. The compute step needs no write access; -# the label/comment step runs via workflow_run on the base repo with -# full write permissions. -# -# Security note: This workflow only reads PR file data via the JS API -# and writes a JSON artifact. No untrusted input is interpolated into -# shell commands. - -name: PR Size Check - -on: - pull_request: - types: [opened, synchronize] - -permissions: - contents: read - pull-requests: read - -jobs: - compute-size: - if: github.repository_owner == 'zed-industries' - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Calculate PR size - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - with: - script: | - const fs = require('fs'); - - const { data: files } = await github.rest.pulls.listFiles({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: context.issue.number, - per_page: 300, - }); - - // Sum additions + deletions, excluding generated/lock files - const IGNORED_PATTERNS = [ - /\.lock$/, - /^Cargo\.lock$/, - /pnpm-lock\.yaml$/, - /\.generated\./, - /\/fixtures\//, - /\/snapshots\//, - ]; - - let totalChanges = 0; - for (const file of files) { - const ignored = IGNORED_PATTERNS.some(p => p.test(file.filename)); - if (!ignored) { - totalChanges += file.additions + file.deletions; - } - } - - // Assign size bracket - const SIZE_BRACKETS = [ - ['Size S', 0, 100, '0e8a16'], - ['Size M', 100, 400, 'fbca04'], - ['Size L', 400, 800, 'e99695'], - ['Size XL', 800, Infinity, 'b60205'], - ]; - - let sizeLabel = 'Size S'; - let labelColor = '0e8a16'; - for (const [label, min, max, color] of SIZE_BRACKETS) { - if (totalChanges >= min && totalChanges < max) { - sizeLabel = label; - labelColor = color; - break; - } - } - - // Check if the author wrote content in the "How to Review" section. - const rawBody = context.payload.pull_request.body || ''; - const howToReview = rawBody.match(/## How to Review\s*\n([\s\S]*?)(?=\n## |$)/i); - const hasReviewGuidance = howToReview - ? howToReview[1].replace(//g, '').trim().length > 0 - : false; - - const result = { - pr_number: context.issue.number, - total_changes: totalChanges, - size_label: sizeLabel, - label_color: labelColor, - has_review_guidance: hasReviewGuidance, - }; - - console.log(`PR #${result.pr_number}: ${totalChanges} LOC, ${sizeLabel}`); - - fs.mkdirSync('pr-size', { recursive: true }); - fs.writeFileSync('pr-size/result.json', JSON.stringify(result)); - - - name: Upload size result - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: pr-size-result - path: pr-size/ - retention-days: 1 -defaults: - run: - shell: bash -euxo pipefail {0} diff --git a/.github/workflows/pr-size-label.yml b/.github/workflows/pr-size-label.yml deleted file mode 100644 index 599daf122aac728c469acd45da865e1079c07fb6..0000000000000000000000000000000000000000 --- a/.github/workflows/pr-size-label.yml +++ /dev/null @@ -1,195 +0,0 @@ -# PR Size Check — Label & Comment -# -# Triggered by workflow_run after pr-size-check.yml completes. -# Downloads the size result artifact and applies labels + comments. -# -# This runs on the base repo with full GITHUB_TOKEN write access, -# so it works for both same-repo and fork PRs. -# -# Security note: The artifact is treated as untrusted data — only -# structured JSON fields (PR number, size label, color, boolean) are -# read. No artifact content is executed or interpolated into shell. - -name: PR Size Label - -on: - workflow_run: - workflows: ["PR Size Check"] - types: [completed] - -jobs: - apply-labels: - if: > - github.repository_owner == 'zed-industries' && - github.event.workflow_run.conclusion == 'success' - permissions: - contents: read - pull-requests: write - issues: write - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Download size result artifact - id: download - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - with: - script: | - const fs = require('fs'); - const path = require('path'); - - const allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({ - owner: context.repo.owner, - repo: context.repo.repo, - run_id: context.payload.workflow_run.id, - }); - - const match = allArtifacts.data.artifacts.find(a => a.name === 'pr-size-result'); - if (!match) { - console.log('No pr-size-result artifact found, skipping'); - core.setOutput('found', 'false'); - return; - } - - const download = await github.rest.actions.downloadArtifact({ - owner: context.repo.owner, - repo: context.repo.repo, - artifact_id: match.id, - archive_format: 'zip', - }); - - const temp = path.join(process.env.RUNNER_TEMP, 'pr-size'); - fs.mkdirSync(temp, { recursive: true }); - fs.writeFileSync(path.join(temp, 'result.zip'), Buffer.from(download.data)); - core.setOutput('found', 'true'); - - - name: Unzip artifact - if: steps.download.outputs.found == 'true' - env: - ARTIFACT_DIR: ${{ runner.temp }}/pr-size - run: unzip "$ARTIFACT_DIR/result.zip" -d "$ARTIFACT_DIR" - - - name: Apply labels and comment - if: steps.download.outputs.found == 'true' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - with: - script: | - const fs = require('fs'); - const path = require('path'); - - const temp = path.join(process.env.RUNNER_TEMP, 'pr-size'); - const resultPath = path.join(temp, 'result.json'); - if (!fs.existsSync(resultPath)) { - console.log('No result.json found, skipping'); - return; - } - - const result = JSON.parse(fs.readFileSync(resultPath, 'utf8')); - - // Validate artifact data (treat as untrusted) - const prNumber = Number(result.pr_number); - const totalChanges = Number(result.total_changes); - const sizeLabel = String(result.size_label); - const labelColor = String(result.label_color); - const hasReviewGuidance = Boolean(result.has_review_guidance); - - if (!prNumber || !sizeLabel.startsWith('Size ')) { - core.setFailed(`Invalid artifact data: pr=${prNumber}, label=${sizeLabel}`); - return; - } - - console.log(`PR #${prNumber}: ${totalChanges} LOC, ${sizeLabel}`); - - // --- Size label (idempotent) --- - const existingLabels = (await github.rest.issues.listLabelsOnIssue({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - })).data.map(l => l.name); - - const existingSizeLabels = existingLabels.filter(l => l.startsWith('Size ')); - const alreadyCorrect = existingSizeLabels.length === 1 && existingSizeLabels[0] === sizeLabel; - - if (!alreadyCorrect) { - for (const label of existingSizeLabels) { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - name: label, - }); - } - - try { - await github.rest.issues.createLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: sizeLabel, - color: labelColor, - }); - } catch (e) { - if (e.status !== 422) throw e; - } - - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - labels: [sizeLabel], - }); - } - - // --- Large PR handling (400+ LOC) --- - if (totalChanges >= 400) { - if (!existingLabels.includes('large-pr')) { - try { - await github.rest.issues.createLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: 'large-pr', - color: 'e99695', - }); - } catch (e) { - if (e.status !== 422) throw e; - } - - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - labels: ['large-pr'], - }); - } - - // Comment once with guidance - const MARKER = ''; - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - }); - - const alreadyCommented = comments.some(c => c.body.includes(MARKER)); - if (!alreadyCommented) { - let body = `${MARKER}\n`; - body += `### :straight_ruler: PR Size: **${totalChanges} lines changed** (${sizeLabel})\n\n`; - body += `Please note: this PR exceeds the 400 LOC soft limit.\n`; - body += `- Consider **splitting** into separate PRs if the changes are separable\n`; - body += `- Ensure the PR description includes a **guided tour** in the "How to Review" section so reviewers know where to start\n`; - - if (hasReviewGuidance) { - body += `\n:white_check_mark: "How to Review" section appears to include guidance — thank you!\n`; - } - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - body: body, - }); - } - } - - console.log(`PR #${prNumber}: labeled ${sizeLabel}, done`); -defaults: - run: - shell: bash -euxo pipefail {0} diff --git a/.github/workflows/pr_labeler.yml b/.github/workflows/pr_labeler.yml index 4a1f9c474c6d00bec137bbfb58ba78acb15440d1..2f09ad681698d008845565c989b26f51c489d500 100644 --- a/.github/workflows/pr_labeler.yml +++ b/.github/workflows/pr_labeler.yml @@ -17,7 +17,7 @@ jobs: timeout-minutes: 5 steps: - id: get-app-token - uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1 # v2.1.4 + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 with: app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }} private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }} diff --git a/.github/workflows/publish_extension_cli.yml b/.github/workflows/publish_extension_cli.yml index 75f1b16b007e33d0c4f346a33a1403648f1cd6c6..17248cea11307d4604b05d5160212a4f38e2874a 100644 --- a/.github/workflows/publish_extension_cli.yml +++ b/.github/workflows/publish_extension_cli.yml @@ -11,14 +11,14 @@ on: jobs: publish_job: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') - runs-on: namespace-profile-2x4-ubuntu-2404 + runs-on: namespace-profile-16x32-ubuntu-2204 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -38,17 +38,17 @@ jobs: runs-on: namespace-profile-8x16-ubuntu-2204 steps: - id: generate-token - name: extension_bump::generate_token - uses: actions/create-github-app-token@v2 + name: steps::generate_token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 with: app-id: ${{ secrets.ZED_ZIPPY_APP_ID }} private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -63,7 +63,7 @@ jobs: - name: publish_extension_cli::update_sha_in_zed::regenerate_workflows run: cargo xtask workflows - name: publish_extension_cli::create_pull_request_zed - uses: peter-evans/create-pull-request@v7 + uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 with: title: 'extension_ci: Bump extension CLI version to `${{ steps.short-sha.outputs.sha_short }}`' body: | @@ -87,8 +87,8 @@ jobs: runs-on: namespace-profile-2x4-ubuntu-2404 steps: - id: generate-token - name: extension_bump::generate_token - uses: actions/create-github-app-token@v2 + name: steps::generate_token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 with: app-id: ${{ secrets.ZED_ZIPPY_APP_ID }} private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} @@ -108,7 +108,7 @@ jobs: sed -i "s/ZED_EXTENSION_CLI_SHA: [a-f0-9]*/ZED_EXTENSION_CLI_SHA: $GITHUB_SHA/" \ .github/workflows/ci.yml - name: publish_extension_cli::create_pull_request_extensions - uses: peter-evans/create-pull-request@v7 + uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 with: title: Bump extension CLI version to `${{ steps.short-sha.outputs.sha_short }}` body: | diff --git a/.github/workflows/randomized_tests.yml b/.github/workflows/randomized_tests.yml index de96c3df78bdb67edd584696f02316478e4446dd..9655a81235d79e1e24ae5185ebce8c8051437392 100644 --- a/.github/workflows/randomized_tests.yml +++ b/.github/workflows/randomized_tests.yml @@ -28,7 +28,7 @@ jobs: node-version: "18" - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: clean: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 07a0a6d672a0a66c9c1609e82a22af9034dc936e..b651e7046bc7d603a7a829ce1b59fcf0468bdd3b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: runs-on: namespace-profile-mac-large steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_cargo_config @@ -22,7 +22,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -31,7 +31,7 @@ jobs: with: node-version: '20' - name: steps::cargo_install_nextest - uses: taiki-e/install-action@nextest + uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 300 - name: steps::setup_sccache @@ -58,7 +58,7 @@ jobs: CXX: clang++ steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_cargo_config @@ -66,7 +66,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -79,7 +79,7 @@ jobs: with: node-version: '20' - name: steps::cargo_install_nextest - uses: taiki-e/install-action@nextest + uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 250 - name: steps::setup_sccache @@ -111,7 +111,7 @@ jobs: runs-on: self-32vcpu-windows-2022 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_cargo_config @@ -151,7 +151,7 @@ jobs: runs-on: namespace-profile-mac-large steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_cargo_config @@ -159,7 +159,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -183,7 +183,7 @@ jobs: CXX: clang++ steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_cargo_config @@ -191,7 +191,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -216,7 +216,7 @@ jobs: runs-on: self-32vcpu-windows-2022 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_cargo_config @@ -244,7 +244,7 @@ jobs: runs-on: namespace-profile-2x4-ubuntu-2404 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: run_tests::check_scripts::run_shellcheck @@ -257,7 +257,7 @@ jobs: env: ACTIONLINT_BIN: ${{ steps.get_actionlint.outputs.executable }} - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -275,7 +275,7 @@ jobs: runs-on: namespace-profile-2x4-ubuntu-2404 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false fetch-depth: 25 @@ -305,7 +305,7 @@ jobs: CXX: clang++-18 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_sentry @@ -345,7 +345,7 @@ jobs: CXX: clang++-18 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_sentry @@ -388,7 +388,7 @@ jobs: APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_node @@ -433,7 +433,7 @@ jobs: APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_node @@ -482,7 +482,7 @@ jobs: TIMESTAMP_SERVER: http://timestamp.acs.microsoft.com steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_sentry @@ -527,7 +527,7 @@ jobs: TIMESTAMP_SERVER: http://timestamp.acs.microsoft.com steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_sentry @@ -617,16 +617,16 @@ 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 + - id: generate-token name: steps::authenticate_as_zippy - uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1 + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 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 env: - GITHUB_TOKEN: ${{ steps.get-app-token.outputs.token }} + GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} push_release_update_notification: needs: - create_draft_release diff --git a/.github/workflows/release_nightly.yml b/.github/workflows/release_nightly.yml index 093a17e8760e52fc4278d56dd6144b6a0432f3c5..30d0e1fbf9c7955d1216e2e3d7ac51a9a51f4416 100644 --- a/.github/workflows/release_nightly.yml +++ b/.github/workflows/release_nightly.yml @@ -16,7 +16,7 @@ jobs: runs-on: namespace-profile-mac-large steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false fetch-depth: 0 @@ -30,7 +30,7 @@ jobs: runs-on: self-32vcpu-windows-2022 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_cargo_config @@ -70,7 +70,7 @@ jobs: runs-on: self-32vcpu-windows-2022 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_cargo_config @@ -107,7 +107,7 @@ jobs: CXX: clang++-18 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: run_bundling::set_release_channel_to_nightly @@ -153,7 +153,7 @@ jobs: CXX: clang++-18 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: run_bundling::set_release_channel_to_nightly @@ -202,7 +202,7 @@ jobs: APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: run_bundling::set_release_channel_to_nightly @@ -253,7 +253,7 @@ jobs: APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: run_bundling::set_release_channel_to_nightly @@ -308,7 +308,7 @@ jobs: TIMESTAMP_SERVER: http://timestamp.acs.microsoft.com steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: run_bundling::set_release_channel_to_nightly @@ -361,7 +361,7 @@ jobs: TIMESTAMP_SERVER: http://timestamp.acs.microsoft.com steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: run_bundling::set_release_channel_to_nightly @@ -406,11 +406,11 @@ jobs: GIT_LFS_SKIP_SMUDGE: '1' steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::cache_nix_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: nix - name: nix_build::build_nix::install_nix @@ -440,11 +440,11 @@ jobs: GIT_LFS_SKIP_SMUDGE: '1' steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::cache_nix_store_macos - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: path: ~/nix-cache - name: nix_build::build_nix::install_nix @@ -488,7 +488,7 @@ jobs: runs-on: namespace-profile-4x8-ubuntu-2204 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false fetch-depth: 0 diff --git a/.github/workflows/run_agent_evals.yml b/.github/workflows/run_agent_evals.yml deleted file mode 100644 index 56cbd17a197200a6764ed1e28c87e90740cd7deb..0000000000000000000000000000000000000000 --- a/.github/workflows/run_agent_evals.yml +++ /dev/null @@ -1,71 +0,0 @@ -# Generated from xtask::workflows::run_agent_evals -# Rebuild with `cargo xtask workflows`. -name: run_agent_evals -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: '0' - RUST_BACKTRACE: '1' - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - GOOGLE_AI_API_KEY: ${{ secrets.GOOGLE_AI_API_KEY }} - GOOGLE_CLOUD_PROJECT: ${{ secrets.GOOGLE_CLOUD_PROJECT }} - ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} - ZED_EVAL_TELEMETRY: '1' - MODEL_NAME: ${{ inputs.model_name }} -on: - workflow_dispatch: - inputs: - model_name: - description: model_name - required: true - type: string -jobs: - agent_evals: - runs-on: namespace-profile-16x32-ubuntu-2204 - steps: - - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - with: - clean: false - - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 - with: - cache: rust - path: ~/.rustup - - name: steps::setup_linux - run: ./script/linux - - name: steps::download_wasi_sdk - run: ./script/download-wasi-sdk - - name: steps::setup_cargo_config - run: | - mkdir -p ./../.cargo - cp ./.cargo/ci-config.toml ./../.cargo/config.toml - - name: steps::setup_sccache - run: ./script/setup-sccache - env: - R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} - R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} - R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} - SCCACHE_BUCKET: sccache-zed - - name: cargo build --package=eval - run: cargo build --package=eval - - name: run_agent_evals::agent_evals::run_eval - run: cargo run --package=eval -- --repetitions=8 --concurrency=1 --model "${MODEL_NAME}" - env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - GOOGLE_AI_API_KEY: ${{ secrets.GOOGLE_AI_API_KEY }} - GOOGLE_CLOUD_PROJECT: ${{ secrets.GOOGLE_CLOUD_PROJECT }} - - name: steps::show_sccache_stats - run: sccache --show-stats || true - - name: steps::cleanup_cargo_config - if: always() - run: | - rm -rf ./../.cargo - timeout-minutes: 600 -concurrency: - group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }} - cancel-in-progress: true -defaults: - run: - shell: bash -euxo pipefail {0} diff --git a/.github/workflows/run_bundling.yml b/.github/workflows/run_bundling.yml index 5a93cf074e2a2d7f2f3cf8418ed508c5ad359d9e..71b2e4d5fa0b386334bb8acab8e732f1c7d0ad93 100644 --- a/.github/workflows/run_bundling.yml +++ b/.github/workflows/run_bundling.yml @@ -23,7 +23,7 @@ jobs: CXX: clang++-18 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_sentry @@ -62,7 +62,7 @@ jobs: CXX: clang++-18 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_sentry @@ -104,7 +104,7 @@ jobs: APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_node @@ -148,7 +148,7 @@ jobs: APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_node @@ -196,7 +196,7 @@ jobs: TIMESTAMP_SERVER: http://timestamp.acs.microsoft.com steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_sentry @@ -240,7 +240,7 @@ jobs: TIMESTAMP_SERVER: http://timestamp.acs.microsoft.com steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_sentry @@ -274,11 +274,11 @@ jobs: GIT_LFS_SKIP_SMUDGE: '1' steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::cache_nix_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: nix - name: nix_build::build_nix::install_nix @@ -306,11 +306,11 @@ jobs: GIT_LFS_SKIP_SMUDGE: '1' steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::cache_nix_store_macos - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: path: ~/nix-cache - name: nix_build::build_nix::install_nix diff --git a/.github/workflows/run_cron_unit_evals.yml b/.github/workflows/run_cron_unit_evals.yml index 6af46e678d3d629cc2f7973b8b31ee99477dfefc..7bb7f79473eb4dae170eb18edd454b7ae35d13e8 100644 --- a/.github/workflows/run_cron_unit_evals.yml +++ b/.github/workflows/run_cron_unit_evals.yml @@ -21,7 +21,7 @@ jobs: fail-fast: false steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_cargo_config @@ -29,7 +29,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -38,7 +38,7 @@ jobs: - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: steps::cargo_install_nextest - uses: taiki-e/install-action@nextest + uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 250 - name: steps::setup_sccache diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index 1906acf9fab7bbaab81b0549328c2e85d732756d..9f335a76beab036d97fe5555cd049ea46b4f87f0 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -19,7 +19,7 @@ jobs: runs-on: namespace-profile-2x4-ubuntu-2404 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false fetch-depth: ${{ github.ref == 'refs/heads/main' && 2 || 350 }} @@ -124,11 +124,11 @@ jobs: runs-on: namespace-profile-4x8-ubuntu-2204 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -171,7 +171,7 @@ jobs: runs-on: self-32vcpu-windows-2022 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_cargo_config @@ -204,7 +204,7 @@ jobs: CXX: clang++ steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_cargo_config @@ -212,7 +212,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -239,7 +239,7 @@ jobs: runs-on: namespace-profile-mac-large steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_cargo_config @@ -247,7 +247,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -270,7 +270,7 @@ jobs: runs-on: namespace-profile-mac-large steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_cargo_config @@ -278,7 +278,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -303,7 +303,7 @@ jobs: runs-on: self-32vcpu-windows-2022 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_cargo_config @@ -348,7 +348,7 @@ jobs: CXX: clang++ steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_cargo_config @@ -356,7 +356,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -369,7 +369,7 @@ jobs: with: node-version: '20' - name: steps::cargo_install_nextest - uses: taiki-e/install-action@nextest + uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 250 - name: steps::setup_sccache @@ -403,7 +403,7 @@ jobs: runs-on: namespace-profile-mac-large steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_cargo_config @@ -411,7 +411,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -420,7 +420,7 @@ jobs: with: node-version: '20' - name: steps::cargo_install_nextest - uses: taiki-e/install-action@nextest + uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 300 - name: steps::setup_sccache @@ -449,11 +449,11 @@ jobs: CXX: clang++ steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -493,7 +493,7 @@ jobs: CXX: clang++ steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_cargo_config @@ -501,7 +501,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -534,7 +534,7 @@ jobs: runs-on: namespace-profile-8x16-ubuntu-2204 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_cargo_config @@ -542,7 +542,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -576,11 +576,11 @@ jobs: CXX: clang++ steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -611,7 +611,7 @@ jobs: CXX: clang++ steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_cargo_config @@ -619,7 +619,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -657,11 +657,11 @@ jobs: runs-on: namespace-profile-2x4-ubuntu-2404 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -676,7 +676,7 @@ jobs: runs-on: namespace-profile-2x4-ubuntu-2404 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: run_tests::check_scripts::run_shellcheck @@ -689,7 +689,7 @@ jobs: env: ACTIONLINT_BIN: ${{ steps.get_actionlint.outputs.executable }} - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -714,7 +714,7 @@ jobs: GIT_COMMITTER_EMAIL: ci@zed.dev steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false fetch-depth: 0 diff --git a/.github/workflows/run_unit_evals.yml b/.github/workflows/run_unit_evals.yml index 44f12a1886bdac2fa1da8c870d223dd358285658..1bf75188832668f40a24c4d3452940bf05fcd3fd 100644 --- a/.github/workflows/run_unit_evals.yml +++ b/.github/workflows/run_unit_evals.yml @@ -24,7 +24,7 @@ jobs: runs-on: namespace-profile-16x32-ubuntu-2204 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_cargo_config @@ -32,7 +32,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -41,7 +41,7 @@ jobs: - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: steps::cargo_install_nextest - uses: taiki-e/install-action@nextest + uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 250 - name: steps::setup_sccache diff --git a/.github/workflows/track_duplicate_bot_effectiveness.yml b/.github/workflows/track_duplicate_bot_effectiveness.yml index fa1c80616cb6133a7a4cad8841bbaad03115ff58..0d41a6070610ce9e9cc3faa06af78145bc9caec1 100644 --- a/.github/workflows/track_duplicate_bot_effectiveness.yml +++ b/.github/workflows/track_duplicate_bot_effectiveness.yml @@ -22,14 +22,14 @@ jobs: timeout-minutes: 5 steps: - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: sparse-checkout: script/github-track-duplicate-bot-effectiveness.py sparse-checkout-cone-mode: false - name: Get github app token id: get-app-token - uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1 # v1.11.7 + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 with: app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }} private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }} @@ -61,14 +61,14 @@ jobs: timeout-minutes: 10 steps: - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: sparse-checkout: script/github-track-duplicate-bot-effectiveness.py sparse-checkout-cone-mode: false - name: Get github app token id: get-app-token - uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1 # v1.11.7 + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 with: app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }} private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }} diff --git a/.github/workflows/update_duplicate_magnets.yml b/.github/workflows/update_duplicate_magnets.yml index c3832b7bdbec13f74a8136cb1120a682f6e53920..d14f4aa92451aab9c36df49d3be128fd4797a4da 100644 --- a/.github/workflows/update_duplicate_magnets.yml +++ b/.github/workflows/update_duplicate_magnets.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest if: github.repository == 'zed-industries/zed' steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 diff --git a/.zed/settings.json b/.zed/settings.json index e9bbe9aa4ffd142ad1733d4c18a4e54230a8b541..2ecbd5623d26bd32d40443f8553bf4062248ec45 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -58,8 +58,7 @@ "ensure_final_newline_on_save": true, "file_scan_exclusions": [ "crates/agent/src/edit_agent/evals/fixtures", - "crates/eval/worktrees/", - "crates/eval/repos/", + "crates/agent/src/tools/evals/fixtures", "**/.git", "**/.svn", "**/.hg", diff --git a/Cargo.lock b/Cargo.lock index a3ddf3f4960224f4ebf46c4850f7214d3fc493d1..1f208e459a7eb0323aa378a168c3648052135200 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -59,7 +59,7 @@ dependencies = [ "serde", "serde_json", "settings", - "theme", + "theme_settings", "ui", "util", "workspace", @@ -312,6 +312,7 @@ dependencies = [ "gpui", "language_model", "log", + "paths", "project", "regex", "schemars", @@ -337,14 +338,12 @@ dependencies = [ "assistant_slash_command", "assistant_slash_commands", "assistant_text_thread", - "async-fs", "audio", "base64 0.22.1", "buffer_diff", "chrono", "client", "cloud_api_types", - "cloud_llm_client", "collections", "command_palette_hooks", "component", @@ -408,6 +407,7 @@ dependencies = [ "terminal_view", "text", "theme", + "theme_settings", "time", "time_format", "tree-sitter-md", @@ -513,21 +513,6 @@ dependencies = [ "equator", ] -[[package]] -name = "alloc-no-stdlib" -version = "2.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" - -[[package]] -name = "alloc-stdlib" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" -dependencies = [ - "alloc-no-stdlib", -] - [[package]] name = "allocator-api2" version = "0.2.21" @@ -865,7 +850,6 @@ dependencies = [ "chrono", "client", "clock", - "cloud_llm_client", "collections", "context_server", "fs", @@ -1278,7 +1262,6 @@ name = "audio" version = "0.1.0" dependencies = [ "anyhow", - "async-tar", "collections", "cpal", "crossbeam", @@ -1290,7 +1273,6 @@ dependencies = [ "rodio", "serde", "settings", - "smol", "thiserror 2.0.17", "util", ] @@ -2252,27 +2234,6 @@ dependencies = [ "workspace", ] -[[package]] -name = "brotli" -version = "8.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", - "brotli-decompressor", -] - -[[package]] -name = "brotli-decompressor" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", -] - [[package]] name = "brush-parser" version = "0.3.0" @@ -2440,6 +2401,15 @@ dependencies = [ "libc", ] +[[package]] +name = "bzip2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" +dependencies = [ + "libbz2-rs-sys", +] + [[package]] name = "bzip2-sys" version = "0.1.13+1.0.8" @@ -3004,6 +2974,7 @@ dependencies = [ "cloud_llm_client", "collections", "credentials_provider", + "db", "derive_more", "feature_flags", "fs", @@ -3289,6 +3260,7 @@ dependencies = [ "telemetry_events", "text", "theme", + "theme_settings", "time", "tokio", "toml 0.8.23", @@ -3334,6 +3306,7 @@ dependencies = [ "smallvec", "telemetry", "theme", + "theme_settings", "time", "time_format", "title_bar", @@ -3406,6 +3379,7 @@ dependencies = [ "settings", "telemetry", "theme", + "theme_settings", "time", "ui", "util", @@ -3458,6 +3432,7 @@ dependencies = [ "session", "settings", "theme", + "theme_settings", "ui", "ui_input", "uuid", @@ -3470,6 +3445,7 @@ version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef8a506ec4b81c460798f572caead636d57d3d7e940f998160f52bd254bf2d23" dependencies = [ + "bzip2 0.6.1", "compression-core", "deflate64", "flate2", @@ -3654,6 +3630,7 @@ dependencies = [ "settings", "sum_tree", "theme", + "theme_settings", "util", "workspace", "zlog", @@ -4724,6 +4701,7 @@ dependencies = [ "terminal_view", "text", "theme", + "theme_settings", "tree-sitter", "tree-sitter-go", "tree-sitter-json", @@ -4911,6 +4889,7 @@ dependencies = [ "settings", "text", "theme", + "theme_settings", "ui", "unindent", "util", @@ -5239,7 +5218,6 @@ version = "0.1.0" dependencies = [ "ai_onboarding", "anyhow", - "brotli", "buffer_diff", "client", "clock", @@ -5428,6 +5406,7 @@ dependencies = [ "telemetry", "text", "theme", + "theme_settings", "time", "ui", "util", @@ -5496,6 +5475,7 @@ dependencies = [ "telemetry", "text", "theme", + "theme_settings", "time", "tracing", "tree-sitter-bash", @@ -5846,62 +5826,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "eval" -version = "0.1.0" -dependencies = [ - "acp_thread", - "agent", - "agent-client-protocol", - "agent_settings", - "agent_ui", - "anyhow", - "async-trait", - "buffer_diff", - "chrono", - "clap", - "client", - "collections", - "debug_adapter_extension", - "dirs 4.0.0", - "dotenvy", - "env_logger 0.11.8", - "extension", - "fs", - "futures 0.3.31", - "gpui", - "gpui_platform", - "gpui_tokio", - "handlebars 4.5.0", - "language", - "language_extension", - "language_model", - "language_models", - "languages", - "markdown", - "node_runtime", - "pathdiff", - "paths", - "pretty_assertions", - "project", - "prompt_store", - "rand 0.9.2", - "regex", - "release_channel", - "reqwest_client", - "serde", - "serde_json", - "settings", - "shellexpand 2.1.2", - "telemetry", - "terminal_view", - "toml 0.8.23", - "unindent", - "util", - "uuid", - "watch", -] - [[package]] name = "eval_cli" version = "0.1.0" @@ -5914,6 +5838,7 @@ dependencies = [ "clap", "client", "ctrlc", + "db", "debug_adapter_extension", "env_logger 0.11.8", "extension", @@ -6074,7 +5999,7 @@ dependencies = [ "settings_content", "snippet_provider", "task", - "theme", + "theme_settings", "tokio", "toml 0.8.23", "tree-sitter", @@ -6123,6 +6048,7 @@ dependencies = [ "tempfile", "theme", "theme_extension", + "theme_settings", "toml 0.8.23", "tracing", "url", @@ -6161,7 +6087,7 @@ dependencies = [ "smallvec", "strum 0.27.2", "telemetry", - "theme", + "theme_settings", "ui", "util", "vim_mode_setting", @@ -6318,6 +6244,7 @@ dependencies = [ "serde_json", "settings", "theme", + "theme_settings", "ui", "util", "workspace", @@ -7320,6 +7247,7 @@ dependencies = [ "anyhow", "collections", "db", + "editor", "feature_flags", "fs", "git", @@ -7329,10 +7257,13 @@ dependencies = [ "menu", "project", "rand 0.9.2", + "search", "serde_json", "settings", "smallvec", + "smol", "theme", + "theme_settings", "time", "ui", "workspace", @@ -7369,7 +7300,6 @@ dependencies = [ "askpass", "buffer_diff", "call", - "cloud_llm_client", "collections", "component", "ctor", @@ -7409,6 +7339,7 @@ dependencies = [ "strum 0.27.2", "telemetry", "theme", + "theme_settings", "time", "time_format", "tracing", @@ -7637,7 +7568,6 @@ dependencies = [ "block", "cbindgen", "chrono", - "circular-buffer", "cocoa 0.26.0", "cocoa-foundation 0.2.0", "collections", @@ -7684,6 +7614,7 @@ dependencies = [ "rand 0.9.2", "raw-window-handle", "refineable", + "regex", "reqwest_client", "resvg", "scheduler", @@ -7699,6 +7630,7 @@ dependencies = [ "sum_tree", "taffy", "thiserror 2.0.17", + "ttf-parser 0.25.1", "unicode-segmentation", "url", "usvg", @@ -7916,6 +7848,35 @@ dependencies = [ "zed-scap", ] +[[package]] +name = "grammars" +version = "0.1.0" +dependencies = [ + "anyhow", + "language_core", + "rust-embed", + "toml 0.8.23", + "tree-sitter", + "tree-sitter-bash", + "tree-sitter-c", + "tree-sitter-cpp", + "tree-sitter-css", + "tree-sitter-diff", + "tree-sitter-gitcommit", + "tree-sitter-go", + "tree-sitter-gomod", + "tree-sitter-gowork", + "tree-sitter-jsdoc", + "tree-sitter-json", + "tree-sitter-md", + "tree-sitter-python", + "tree-sitter-regex", + "tree-sitter-rust", + "tree-sitter-typescript", + "tree-sitter-yaml", + "util", +] + [[package]] name = "grid" version = "0.18.0" @@ -8730,7 +8691,7 @@ dependencies = [ "project", "serde", "settings", - "theme", + "theme_settings", "ui", "util", "workspace", @@ -8846,7 +8807,7 @@ dependencies = [ "project", "serde_json", "serde_json_lenient", - "theme", + "theme_settings", "ui", "util", "util_macros", @@ -9289,6 +9250,7 @@ dependencies = [ "telemetry", "tempfile", "theme", + "theme_settings", "tree-sitter-json", "tree-sitter-rust", "ui", @@ -9363,6 +9325,7 @@ dependencies = [ "async-trait", "clock", "collections", + "criterion", "ctor", "diffy", "ec4rs", @@ -9376,6 +9339,7 @@ dependencies = [ "imara-diff", "indoc", "itertools 0.14.0", + "language_core", "log", "lsp", "parking_lot", @@ -9384,7 +9348,6 @@ dependencies = [ "rand 0.9.2", "regex", "rpc", - "schemars", "semver", "serde", "serde_json", @@ -9398,6 +9361,7 @@ dependencies = [ "task", "text", "theme", + "theme_settings", "toml 0.8.23", "tracing", "tree-sitter", @@ -9419,6 +9383,25 @@ dependencies = [ "ztracing", ] +[[package]] +name = "language_core" +version = "0.1.0" +dependencies = [ + "anyhow", + "collections", + "gpui", + "log", + "lsp", + "parking_lot", + "regex", + "schemars", + "serde", + "serde_json", + "toml 0.8.23", + "tree-sitter", + "util", +] + [[package]] name = "language_extension" version = "0.1.0" @@ -9592,6 +9575,7 @@ dependencies = [ "sysinfo 0.37.2", "telemetry", "theme", + "theme_settings", "tree-sitter", "ui", "util", @@ -9611,9 +9595,11 @@ dependencies = [ "async-trait", "chrono", "collections", + "fs", "futures 0.3.31", "globset", "gpui", + "grammars", "http_client", "itertools 0.14.0", "json_schema_store", @@ -9633,7 +9619,6 @@ dependencies = [ "project", "regex", "rope", - "rust-embed", "semver", "serde", "serde_json", @@ -9645,25 +9630,16 @@ dependencies = [ "task", "terminal", "theme", - "toml 0.8.23", "tree-sitter", "tree-sitter-bash", "tree-sitter-c", "tree-sitter-cpp", "tree-sitter-css", - "tree-sitter-diff", "tree-sitter-gitcommit", "tree-sitter-go", - "tree-sitter-gomod", - "tree-sitter-gowork", - "tree-sitter-jsdoc", - "tree-sitter-json", - "tree-sitter-md", "tree-sitter-python", - "tree-sitter-regex", "tree-sitter-rust", "tree-sitter-typescript", - "tree-sitter-yaml", "unindent", "url", "util", @@ -9711,6 +9687,12 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" +[[package]] +name = "libbz2-rs-sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" + [[package]] name = "libc" version = "0.2.182" @@ -9800,7 +9782,7 @@ dependencies = [ [[package]] name = "libwebrtc" version = "0.3.26" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c1209aa155cbf4543383774f884a46ae7e53ee2e#c1209aa155cbf4543383774f884a46ae7e53ee2e" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1#147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1" dependencies = [ "cxx", "glib", @@ -9898,7 +9880,7 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "livekit" version = "0.7.32" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c1209aa155cbf4543383774f884a46ae7e53ee2e#c1209aa155cbf4543383774f884a46ae7e53ee2e" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1#147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1" dependencies = [ "base64 0.22.1", "bmrng", @@ -9924,7 +9906,7 @@ dependencies = [ [[package]] name = "livekit-api" version = "0.4.14" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c1209aa155cbf4543383774f884a46ae7e53ee2e#c1209aa155cbf4543383774f884a46ae7e53ee2e" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1#147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1" dependencies = [ "base64 0.21.7", "futures-util", @@ -9951,7 +9933,7 @@ dependencies = [ [[package]] name = "livekit-protocol" version = "0.7.1" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c1209aa155cbf4543383774f884a46ae7e53ee2e#c1209aa155cbf4543383774f884a46ae7e53ee2e" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1#147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1" dependencies = [ "futures-util", "livekit-runtime", @@ -9967,7 +9949,7 @@ dependencies = [ [[package]] name = "livekit-runtime" version = "0.4.0" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c1209aa155cbf4543383774f884a46ae7e53ee2e#c1209aa155cbf4543383774f884a46ae7e53ee2e" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1#147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1" dependencies = [ "tokio", "tokio-stream", @@ -10250,6 +10232,7 @@ checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" name = "markdown" version = "0.1.0" dependencies = [ + "anyhow", "assets", "base64 0.22.1", "collections", @@ -10258,15 +10241,20 @@ dependencies = [ "futures 0.3.31", "gpui", "gpui_platform", + "html5ever 0.27.0", "language", "languages", "linkify", "log", + "markup5ever_rcdom", + "mermaid-rs-renderer", "node_runtime", "pulldown-cmark 0.13.0", "settings", + "stacksafe", "sum_tree", "theme", + "theme_settings", "ui", "util", ] @@ -10276,22 +10264,14 @@ name = "markdown_preview" version = "0.1.0" dependencies = [ "anyhow", - "async-recursion", - "collections", "editor", "gpui", - "html5ever 0.27.0", "language", - "linkify", "log", "markdown", - "markup5ever_rcdom", - "mermaid-rs-renderer", - "pretty_assertions", - "pulldown-cmark 0.13.0", "settings", - "stacksafe", - "theme", + "tempfile", + "theme_settings", "ui", "urlencoding", "util", @@ -10651,7 +10631,7 @@ dependencies = [ "rpc", "serde_json", "smol", - "theme", + "theme_settings", "util", "workspace", "zed_actions", @@ -10777,6 +10757,7 @@ dependencies = [ "theme", "tracing", "tree-sitter", + "unicode-segmentation", "util", "zlog", "ztracing", @@ -11541,6 +11522,7 @@ dependencies = [ "settings", "telemetry", "theme", + "theme_settings", "ui", "util", "vim_mode_setting", @@ -11646,6 +11628,7 @@ dependencies = [ "serde_json", "settings", "theme", + "theme_settings", "ui", "util", "workspace", @@ -11824,6 +11807,7 @@ dependencies = [ "settings", "smol", "theme", + "theme_settings", "ui", "util", "workspace", @@ -11856,6 +11840,7 @@ dependencies = [ "smallvec", "smol", "theme", + "theme_settings", "ui", "util", "workspace", @@ -12721,6 +12706,7 @@ dependencies = [ "serde", "settings", "theme", + "theme_settings", "ui", "ui_input", "workspace", @@ -12829,6 +12815,7 @@ dependencies = [ "settings", "smallvec", "theme", + "theme_settings", "ui", "windows 0.61.3", "workspace", @@ -13232,6 +13219,7 @@ dependencies = [ "node_runtime", "parking_lot", "paths", + "percent-encoding", "postage", "prettier", "pretty_assertions", @@ -13303,7 +13291,6 @@ dependencies = [ "collections", "command_palette_hooks", "criterion", - "db", "editor", "feature_flags", "file_icons", @@ -13327,6 +13314,7 @@ dependencies = [ "telemetry", "tempfile", "theme", + "theme_settings", "ui", "util", "workspace", @@ -13353,6 +13341,7 @@ dependencies = [ "serde_json", "settings", "theme", + "theme_settings", "util", "workspace", ] @@ -14345,7 +14334,7 @@ dependencies = [ "remote", "semver", "settings", - "theme", + "theme_settings", "ui", "ui_input", "workspace", @@ -14413,6 +14402,7 @@ dependencies = [ "sysinfo 0.37.2", "task", "theme", + "theme_settings", "thiserror 2.0.17", "toml 0.8.23", "unindent", @@ -14483,6 +14473,7 @@ dependencies = [ "terminal", "terminal_view", "theme", + "theme_settings", "tree-sitter-md", "tree-sitter-python", "tree-sitter-typescript", @@ -14596,12 +14587,15 @@ version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" dependencies = [ + "gif", + "image-webp", "log", "pico-args", "rgb", "svgtypes", "tiny-skia", "usvg", + "zune-jpeg", ] [[package]] @@ -14819,7 +14813,7 @@ dependencies = [ "rope", "serde", "settings", - "theme", + "theme_settings", "ui", "ui_input", "util", @@ -14855,9 +14849,9 @@ dependencies = [ [[package]] name = "rust-embed" -version = "8.7.2" +version = "8.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "025908b8682a26ba8d12f6f2d66b987584a4a87bc024abc5bbc12553a8cd178a" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" dependencies = [ "rust-embed-impl", "rust-embed-utils", @@ -14866,9 +14860,9 @@ dependencies = [ [[package]] name = "rust-embed-impl" -version = "8.7.2" +version = "8.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6065f1a4392b71819ec1ea1df1120673418bf386f50de1d6f54204d836d4349c" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" dependencies = [ "proc-macro2", "quote", @@ -14879,9 +14873,9 @@ dependencies = [ [[package]] name = "rust-embed-utils" -version = "8.7.2" +version = "8.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6cc0c81648b20b70c491ff8cce00c1c3b223bb8ed2b5d41f0e54c6c4c0a3594" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" dependencies = [ "globset", "sha2", @@ -15233,6 +15227,7 @@ dependencies = [ "serde_json", "settings", "theme", + "theme_settings", ] [[package]] @@ -15456,6 +15451,7 @@ dependencies = [ "settings", "smol", "theme", + "theme_settings", "tracing", "ui", "unindent", @@ -15800,6 +15796,7 @@ dependencies = [ "serde_json", "settings", "theme", + "theme_settings", "ui", "workspace", "zed_actions", @@ -15848,6 +15845,7 @@ dependencies = [ "strum 0.27.2", "telemetry", "theme", + "theme_settings", "title_bar", "ui", "util", @@ -15964,10 +15962,12 @@ dependencies = [ "action_log", "agent", "agent-client-protocol", + "agent_settings", "agent_ui", "anyhow", "assistant_text_thread", "chrono", + "collections", "editor", "feature_flags", "fs", @@ -15975,13 +15975,16 @@ dependencies = [ "gpui", "language_model", "menu", + "platform_title_bar", "pretty_assertions", "project", "prompt_store", "recent_projects", + "remote", "serde_json", "settings", "theme", + "theme_settings", "ui", "util", "vim_mode_setting", @@ -16637,6 +16640,7 @@ dependencies = [ "story", "strum 0.27.2", "theme", + "theme_settings", "title_bar", "ui", ] @@ -17287,6 +17291,7 @@ dependencies = [ "settings", "smol", "theme", + "theme_settings", "ui", "util", "workspace", @@ -17471,6 +17476,7 @@ dependencies = [ "sysinfo 0.37.2", "task", "theme", + "theme_settings", "thiserror 2.0.17", "url", "urlencoding", @@ -17518,6 +17524,7 @@ dependencies = [ "task", "terminal", "theme", + "theme_settings", "ui", "util", "workspace", @@ -17552,10 +17559,7 @@ dependencies = [ "anyhow", "collections", "derive_more", - "fs", - "futures 0.3.31", "gpui", - "log", "palette", "parking_lot", "refineable", @@ -17563,10 +17567,8 @@ dependencies = [ "serde", "serde_json", "serde_json_lenient", - "settings", "strum 0.27.2", "thiserror 2.0.17", - "util", "uuid", ] @@ -17579,6 +17581,7 @@ dependencies = [ "fs", "gpui", "theme", + "theme_settings", ] [[package]] @@ -17598,6 +17601,7 @@ dependencies = [ "simplelog", "strum 0.27.2", "theme", + "theme_settings", "vscode_theme", ] @@ -17614,12 +17618,33 @@ dependencies = [ "settings", "telemetry", "theme", + "theme_settings", "ui", "util", "workspace", "zed_actions", ] +[[package]] +name = "theme_settings" +version = "0.1.0" +dependencies = [ + "anyhow", + "collections", + "gpui", + "gpui_util", + "log", + "palette", + "refineable", + "schemars", + "serde", + "serde_json", + "serde_json_lenient", + "settings", + "theme", + "uuid", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -18551,9 +18576,9 @@ dependencies = [ [[package]] name = "tree-sitter-rust" -version = "0.24.0" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b9b18034c684a2420722be8b2a91c9c44f2546b631c039edf575ccba8c61be1" +checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45" dependencies = [ "cc", "tree-sitter-language", @@ -18752,18 +18777,17 @@ dependencies = [ "documented", "gpui", "gpui_macros", + "gpui_util", "icons", "itertools 0.14.0", "menu", "schemars", "serde", - "settings", "smallvec", "story", "strum 0.27.2", "theme", "ui_macros", - "util", "windows 0.61.3", ] @@ -18794,7 +18818,7 @@ dependencies = [ "markdown", "menu", "settings", - "theme", + "theme_settings", "ui", "workspace", ] @@ -19193,6 +19217,7 @@ dependencies = [ "task", "text", "theme", + "theme_settings", "tokio", "ui", "util", @@ -20098,7 +20123,7 @@ dependencies = [ [[package]] name = "webrtc-sys" version = "0.3.23" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c1209aa155cbf4543383774f884a46ae7e53ee2e#c1209aa155cbf4543383774f884a46ae7e53ee2e" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1#147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1" dependencies = [ "cc", "cxx", @@ -20112,7 +20137,7 @@ dependencies = [ [[package]] name = "webrtc-sys-build" version = "0.3.13" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c1209aa155cbf4543383774f884a46ae7e53ee2e#c1209aa155cbf4543383774f884a46ae7e53ee2e" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1#147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1" dependencies = [ "anyhow", "fs2", @@ -20320,7 +20345,7 @@ dependencies = [ "gpui", "serde", "settings", - "theme", + "theme_settings", "ui", "util", "workspace", @@ -21500,6 +21525,7 @@ dependencies = [ name = "workspace" version = "0.1.0" dependencies = [ + "agent_settings", "any_vec", "anyhow", "async-recursion", @@ -21538,6 +21564,7 @@ dependencies = [ "telemetry", "tempfile", "theme", + "theme_settings", "ui", "util", "uuid", @@ -21958,7 +21985,7 @@ dependencies = [ [[package]] name = "zed" -version = "0.230.0" +version = "0.231.0" dependencies = [ "acp_thread", "acp_tools", @@ -22099,6 +22126,7 @@ dependencies = [ "theme", "theme_extension", "theme_selector", + "theme_settings", "time", "time_format", "title_bar", @@ -22453,7 +22481,7 @@ checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" dependencies = [ "aes", "byteorder", - "bzip2", + "bzip2 0.4.4", "constant_time_eq", "crc32fast", "crossbeam-utils", diff --git a/Cargo.toml b/Cargo.toml index dd426748606407aad3fdce359bc4ba0abe64727d..7c6fdb14defc7c060ee162a78f4319b2dff4deef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,7 +65,6 @@ members = [ "crates/editor", "crates/encoding_selector", "crates/etw_tracing", - "crates/eval", "crates/eval_cli", "crates/eval_utils", "crates/explorer_command_injector", @@ -87,6 +86,7 @@ members = [ "crates/git_ui", "crates/go_to_line", "crates/google_ai", + "crates/grammars", "crates/gpui", "crates/gpui_linux", "crates/gpui_macos", @@ -108,6 +108,7 @@ members = [ "crates/json_schema_store", "crates/keymap_editor", "crates/language", + "crates/language_core", "crates/language_extension", "crates/language_model", "crates/language_models", @@ -196,6 +197,7 @@ members = [ "crates/text", "crates/theme", "crates/theme_extension", + "crates/theme_settings", "crates/theme_importer", "crates/theme_selector", "crates/time_format", @@ -330,6 +332,7 @@ git_hosting_providers = { path = "crates/git_hosting_providers" } git_ui = { path = "crates/git_ui" } go_to_line = { path = "crates/go_to_line" } google_ai = { path = "crates/google_ai" } +grammars = { path = "crates/grammars" } gpui = { path = "crates/gpui", default-features = false } gpui_linux = { path = "crates/gpui_linux", default-features = false } gpui_macos = { path = "crates/gpui_macos", default-features = false } @@ -354,6 +357,7 @@ journal = { path = "crates/journal" } json_schema_store = { path = "crates/json_schema_store" } keymap_editor = { path = "crates/keymap_editor" } language = { path = "crates/language" } +language_core = { path = "crates/language_core" } language_extension = { path = "crates/language_extension" } language_model = { path = "crates/language_model" } language_models = { path = "crates/language_models" } @@ -441,6 +445,7 @@ terminal_view = { path = "crates/terminal_view" } text = { path = "crates/text" } theme = { path = "crates/theme" } theme_extension = { path = "crates/theme_extension" } +theme_settings = { path = "crates/theme_settings" } theme_selector = { path = "crates/theme_selector" } time_format = { path = "crates/time_format" } platform_title_bar = { path = "crates/platform_title_bar" } @@ -492,7 +497,7 @@ ashpd = { version = "0.13", default-features = false, features = [ ] } async-channel = "2.5.0" async-compat = "0.2.1" -async-compression = { version = "0.4", features = ["gzip", "futures-io"] } +async-compression = { version = "0.4", features = ["bzip2", "gzip", "futures-io"] } async-dispatcher = "0.1" async-fs = "2.1" async-lock = "2.1" @@ -676,7 +681,7 @@ rsa = "0.9.6" runtimelib = { version = "1.4.0", default-features = false, features = [ "async-dispatcher-runtime", "aws-lc-rs" ] } -rust-embed = { version = "8.4", features = ["include-exclude"] } +rust-embed = { version = "8.11", features = ["include-exclude"] } rustc-hash = "2.1.0" rustls = { version = "0.23.26" } rustls-platform-verifier = "0.5.0" @@ -752,7 +757,7 @@ tree-sitter-md = { git = "https://github.com/tree-sitter-grammars/tree-sitter-ma tree-sitter-python = "0.25" tree-sitter-regex = "0.24" tree-sitter-ruby = "0.23" -tree-sitter-rust = "0.24" +tree-sitter-rust = "0.24.2" tree-sitter-typescript = { git = "https://github.com/zed-industries/tree-sitter-typescript", rev = "e2c53597d6a5d9cf7bbe8dccde576fe1e46c5899" } # https://github.com/tree-sitter/tree-sitter-typescript/pull/347 tree-sitter-yaml = { git = "https://github.com/zed-industries/tree-sitter-yaml", rev = "baff0b51c64ef6a1fb1f8390f3ad6015b83ec13a" } tracing = "0.1.40" @@ -806,6 +811,7 @@ features = [ "Win32_Graphics_Direct3D_Fxc", "Win32_Graphics_DirectComposition", "Win32_Graphics_DirectWrite", + "Win32_Graphics_DirectManipulation", "Win32_Graphics_Dwm", "Win32_Graphics_Dxgi", "Win32_Graphics_Dxgi_Common", @@ -837,6 +843,7 @@ features = [ "Win32_UI_HiDpi", "Win32_UI_Input_Ime", "Win32_UI_Input_KeyboardAndMouse", + "Win32_UI_Input_Pointer", "Win32_UI_Shell", "Win32_UI_Shell_Common", "Win32_UI_Shell_PropertiesSystem", @@ -850,9 +857,9 @@ notify = { git = "https://github.com/zed-industries/notify.git", rev = "ce58c24c notify-types = { git = "https://github.com/zed-industries/notify.git", rev = "ce58c24cad542c28e04ced02e20325a4ec28a31d" } windows-capture = { git = "https://github.com/zed-industries/windows-capture.git", rev = "f0d6c1b6691db75461b732f6d5ff56eed002eeb9" } calloop = { git = "https://github.com/zed-industries/calloop" } -livekit = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "c1209aa155cbf4543383774f884a46ae7e53ee2e" } -libwebrtc = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "c1209aa155cbf4543383774f884a46ae7e53ee2e" } -webrtc-sys = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "c1209aa155cbf4543383774f884a46ae7e53ee2e" } +livekit = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1" } +libwebrtc = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1" } +webrtc-sys = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1" } [profile.dev] split-debuginfo = "unpacked" diff --git a/Dockerfile-collab b/Dockerfile-collab index 50af874200a6ef3bc3c882b7d08257ec41f944de..fbbcb0df0484c26a65823171cc976de8cb838b8c 100644 --- a/Dockerfile-collab +++ b/Dockerfile-collab @@ -1,6 +1,6 @@ # syntax = docker/dockerfile:1.2 -FROM rust:1.93-bookworm as builder +FROM rust:1.94-bookworm as builder WORKDIR app COPY . . diff --git a/assets/icons/ai.svg b/assets/icons/ai.svg deleted file mode 100644 index 4236d50337bef92cb550cdbf71d83843ab35e2f3..0000000000000000000000000000000000000000 --- a/assets/icons/ai.svg +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/assets/icons/box_open.svg b/assets/icons/box_open.svg new file mode 100644 index 0000000000000000000000000000000000000000..5e30fc40c3446485412e2a2607b0d07dc2f68b4b --- /dev/null +++ b/assets/icons/box_open.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/cog.svg b/assets/icons/cog.svg deleted file mode 100644 index 7dd3a8befff59b5aaa0506df9b2cd7140725ab81..0000000000000000000000000000000000000000 --- a/assets/icons/cog.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/ellipsis_vertical.svg b/assets/icons/ellipsis_vertical.svg deleted file mode 100644 index c38437667ebbe095aaa4be27244997a9138bf659..0000000000000000000000000000000000000000 --- a/assets/icons/ellipsis_vertical.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/assets/icons/file_icons/editorconfig.svg b/assets/icons/file_icons/editorconfig.svg new file mode 100644 index 0000000000000000000000000000000000000000..81355bec4603e678c3b1d1097d00ef03da5edf7f --- /dev/null +++ b/assets/icons/file_icons/editorconfig.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/focus.svg b/assets/icons/focus.svg new file mode 100644 index 0000000000000000000000000000000000000000..9003e437cee1afa43e87fa273c9510284bb5ae0b --- /dev/null +++ b/assets/icons/focus.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/icons/menu_alt.svg b/assets/icons/menu_alt.svg deleted file mode 100644 index b9cc19e22febe045ca9ccf4a7e86d69b258f875c..0000000000000000000000000000000000000000 --- a/assets/icons/menu_alt.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/icons/shield_check.svg b/assets/icons/shield_check.svg deleted file mode 100644 index 43b52f43a8d70beb6e69c2271235090db4dc2c00..0000000000000000000000000000000000000000 --- a/assets/icons/shield_check.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/assets/icons/stop.svg b/assets/icons/stop.svg index cc2bbe9207acf5acd44ff13e93140099d222250b..5ca9cd29edf17981500482b81e47aa53a16e2713 100644 --- a/assets/icons/stop.svg +++ b/assets/icons/stop.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/swatch_book.svg b/assets/icons/swatch_book.svg deleted file mode 100644 index b37d5df8c1a5f0f6b9fa9cb46b3004a2ba55da4f..0000000000000000000000000000000000000000 --- a/assets/icons/swatch_book.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/sweep_ai.svg b/assets/icons/sweep_ai.svg deleted file mode 100644 index 9c63c810dd9e164c14c1ad1a1bca9c6ec68fc95e..0000000000000000000000000000000000000000 --- a/assets/icons/sweep_ai.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/sweep_ai_disabled.svg b/assets/icons/sweep_ai_disabled.svg deleted file mode 100644 index b15a8d8526f36f312482effefd3d7538ce5f7a04..0000000000000000000000000000000000000000 --- a/assets/icons/sweep_ai_disabled.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/sweep_ai_down.svg b/assets/icons/sweep_ai_down.svg deleted file mode 100644 index f08dcb171811c761cd13c4efd0ef0acdc78f9951..0000000000000000000000000000000000000000 --- a/assets/icons/sweep_ai_down.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/sweep_ai_error.svg b/assets/icons/sweep_ai_error.svg deleted file mode 100644 index 95285a1273e72ec4f02cb23e3c2fb39460f42761..0000000000000000000000000000000000000000 --- a/assets/icons/sweep_ai_error.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/sweep_ai_up.svg b/assets/icons/sweep_ai_up.svg deleted file mode 100644 index 7c28282a6a14c47561a50ab456c0bec2e05b07cc..0000000000000000000000000000000000000000 --- a/assets/icons/sweep_ai_up.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/terminal_ghost.svg b/assets/icons/terminal_ghost.svg deleted file mode 100644 index 7d0d0e068e8a6f01837e860e8223690a95541769..0000000000000000000000000000000000000000 --- a/assets/icons/terminal_ghost.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/assets/icons/tool_read.svg b/assets/icons/tool_read.svg deleted file mode 100644 index d22e9d8c7da9ba04fe194339d787e40637cf5257..0000000000000000000000000000000000000000 --- a/assets/icons/tool_read.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/assets/icons/tool_regex.svg b/assets/icons/tool_regex.svg deleted file mode 100644 index 818c2ba360bc5aca3d4a7bf8ab65a03a2efe235e..0000000000000000000000000000000000000000 --- a/assets/icons/tool_regex.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index e4183965fa0b798d526ad6d59d0ce936269cab51..d5643e092268470e61b54001ab57d83ec7cd9467 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -31,7 +31,6 @@ "ctrl-+": ["zed::IncreaseBufferFontSize", { "persist": false }], "ctrl--": ["zed::DecreaseBufferFontSize", { "persist": false }], "ctrl-0": ["zed::ResetBufferFontSize", { "persist": false }], - "ctrl-,": "zed::OpenSettings", "ctrl-alt-,": "zed::OpenSettingsFile", "ctrl-q": "zed::Quit", "f4": "debugger::Start", @@ -255,17 +254,20 @@ "alt-tab": "agent::CycleFavoriteModels", // `alt-l` is provided as an alternative to `alt-tab` as the latter breaks on Linux under the `AgentPanel` context "alt-l": "agent::CycleFavoriteModels", - "ctrl-shift-j": "agent::ToggleNavigationMenu", - "ctrl-alt-i": "agent::ToggleOptionsMenu", + "shift-alt-j": "agent::ToggleNavigationMenu", + "shift-alt-i": "agent::ToggleOptionsMenu", "ctrl-alt-shift-n": "agent::ToggleNewThreadMenu", "ctrl-shift-t": "agent::CycleStartThreadIn", "shift-alt-escape": "agent::ExpandMessageEditor", "ctrl->": "agent::AddSelectionToThread", "ctrl-shift-e": "project_panel::ToggleFocus", "ctrl-shift-enter": "agent::ContinueThread", - "ctrl-y": "agent::AllowOnce", + "shift-alt-q": "agent::AllowAlways", + "shift-alt-a": "agent::AllowOnce", "ctrl-alt-a": "agent::OpenPermissionDropdown", - "ctrl-alt-z": "agent::RejectOnce", + "shift-alt-x": "agent::RejectOnce", + "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher", + "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }], }, }, { @@ -339,6 +341,13 @@ "ctrl-alt-.": "agent::ToggleFastMode", }, }, + { + "context": "AcpThread > Editor && mode == full", + "use_key_equivalents": true, + "bindings": { + "alt-enter": "editor::OpenExcerpts", + }, + }, { "context": "AcpThread > Editor && !use_modifier_to_send", "use_key_equivalents": true, @@ -692,12 +701,27 @@ "left": "menu::SelectParent", "right": "menu::SelectChild", "enter": "menu::Confirm", - "space": "menu::Confirm", "ctrl-f": "agents_sidebar::FocusSidebarFilter", "ctrl-g": "agents_sidebar::ToggleArchive", "shift-backspace": "agent::RemoveSelectedThread", + "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher", + "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }], }, }, + { + "context": "ThreadsSidebar && not_searching", + "use_key_equivalents": true, + "bindings": { + "space": "menu::Confirm", + }, + }, + { + "context": "ThreadSwitcher", + "bindings": { + "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher", + "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }] + } + }, { "context": "Workspace && debugger_running", "bindings": { @@ -790,7 +814,7 @@ }, }, { - "context": "Editor && edit_prediction && edit_prediction_mode == eager", + "context": "Editor && edit_prediction && edit_prediction_mode == eager && !showing_completions", "bindings": { "tab": "editor::AcceptEditPrediction", }, @@ -1065,6 +1089,7 @@ "alt-up": "collab_panel::MoveChannelUp", "alt-down": "collab_panel::MoveChannelDown", "alt-enter": "collab_panel::OpenSelectedChannelNotes", + "shift-enter": "collab_panel::ToggleSelectedChannelFavorite", }, }, { @@ -1339,6 +1364,15 @@ "ctrl-shift-backspace": "git::DeleteWorktree", }, }, + { + // Handled under a more specific context to avoid conflicts with the + // `OpenCurrentFile` keybind from the settings UI + "context": "!SettingsWindow", + "use_key_equivalents": true, + "bindings": { + "ctrl-,": "zed::OpenSettings", + }, + }, { "context": "SettingsWindow", "use_key_equivalents": true, diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 27901157e75813109e2b13fb44d6ffe71a04a0f5..e2073c170b375baea22f5018c2c7dba632dd9b05 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -39,7 +39,6 @@ "cmd-+": ["zed::IncreaseBufferFontSize", { "persist": false }], "cmd--": ["zed::DecreaseBufferFontSize", { "persist": false }], "cmd-0": ["zed::ResetBufferFontSize", { "persist": false }], - "cmd-,": "zed::OpenSettings", "cmd-alt-,": "zed::OpenSettingsFile", "cmd-q": "zed::Quit", "cmd-h": "zed::Hide", @@ -305,6 +304,8 @@ "cmd-y": "agent::AllowOnce", "cmd-alt-a": "agent::OpenPermissionDropdown", "cmd-alt-z": "agent::RejectOnce", + "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher", + "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }], }, }, { @@ -383,6 +384,13 @@ "cmd-alt-.": "agent::ToggleFastMode", }, }, + { + "context": "AcpThread > Editor && mode == full", + "use_key_equivalents": true, + "bindings": { + "alt-enter": "editor::OpenExcerpts", + }, + }, { "context": "AcpThread > Editor && !use_modifier_to_send", "use_key_equivalents": true, @@ -758,12 +766,27 @@ "left": "menu::SelectParent", "right": "menu::SelectChild", "enter": "menu::Confirm", - "space": "menu::Confirm", "cmd-f": "agents_sidebar::FocusSidebarFilter", "cmd-g": "agents_sidebar::ToggleArchive", "shift-backspace": "agent::RemoveSelectedThread", + "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher", + "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }], }, }, + { + "context": "ThreadsSidebar && not_searching", + "use_key_equivalents": true, + "bindings": { + "space": "menu::Confirm", + }, + }, + { + "context": "ThreadSwitcher", + "bindings": { + "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher", + "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }] + } + }, { "context": "Workspace && debugger_running", "use_key_equivalents": true, @@ -852,7 +875,7 @@ }, }, { - "context": "Editor && edit_prediction && edit_prediction_mode == eager", + "context": "Editor && edit_prediction && edit_prediction_mode == eager && !showing_completions", "bindings": { "tab": "editor::AcceptEditPrediction", }, @@ -1126,6 +1149,7 @@ "alt-up": "collab_panel::MoveChannelUp", "alt-down": "collab_panel::MoveChannelDown", "alt-enter": "collab_panel::OpenSelectedChannelNotes", + "shift-enter": "collab_panel::ToggleSelectedChannelFavorite", }, }, { @@ -1441,6 +1465,15 @@ "cmd-shift-backspace": "git::DeleteWorktree", }, }, + { + // Handled under a more specific context to avoid conflicts with the + // `OpenCurrentFile` keybind from the settings UI + "context": "!SettingsWindow", + "use_key_equivalents": true, + "bindings": { + "cmd-,": "zed::OpenSettings", + }, + }, { "context": "SettingsWindow", "use_key_equivalents": true, diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 8a071c9043a88868d4b91bdde3791bdd118e7a84..32f827259cbcf8bab39a8bbe45a9010d7239e2a7 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -30,7 +30,6 @@ "ctrl-shift-=": ["zed::IncreaseBufferFontSize", { "persist": false }], "ctrl--": ["zed::DecreaseBufferFontSize", { "persist": false }], "ctrl-0": ["zed::ResetBufferFontSize", { "persist": false }], - "ctrl-,": "zed::OpenSettings", "ctrl-alt-,": "zed::OpenSettingsFile", "ctrl-q": "zed::Quit", "f4": "debugger::Start", @@ -264,9 +263,12 @@ "ctrl-shift-.": "agent::AddSelectionToThread", "ctrl-shift-e": "project_panel::ToggleFocus", "ctrl-shift-enter": "agent::ContinueThread", + "shift-alt-q": "agent::AllowAlways", "shift-alt-a": "agent::AllowOnce", "ctrl-alt-a": "agent::OpenPermissionDropdown", - "shift-alt-z": "agent::RejectOnce", + "shift-alt-x": "agent::RejectOnce", + "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher", + "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }], }, }, { @@ -341,6 +343,13 @@ "ctrl-alt-.": "agent::ToggleFastMode", }, }, + { + "context": "AcpThread > Editor && mode == full", + "use_key_equivalents": true, + "bindings": { + "alt-enter": "editor::OpenExcerpts", + }, + }, { "context": "AcpThread > Editor && !use_modifier_to_send", "use_key_equivalents": true, @@ -694,12 +703,27 @@ "left": "menu::SelectParent", "right": "menu::SelectChild", "enter": "menu::Confirm", - "space": "menu::Confirm", "ctrl-f": "agents_sidebar::FocusSidebarFilter", "ctrl-g": "agents_sidebar::ToggleArchive", "shift-backspace": "agent::RemoveSelectedThread", + "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher", + "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }], }, }, + { + "context": "ThreadsSidebar && not_searching", + "use_key_equivalents": true, + "bindings": { + "space": "menu::Confirm", + }, + }, + { + "context": "ThreadSwitcher", + "bindings": { + "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher", + "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }] + } + }, { "context": "ApplicationMenu", "use_key_equivalents": true, @@ -784,7 +808,7 @@ }, }, { - "context": "Editor && edit_prediction && edit_prediction_mode == eager", + "context": "Editor && edit_prediction && edit_prediction_mode == eager && !showing_completions", "use_key_equivalents": true, "bindings": { "tab": "editor::AcceptEditPrediction", @@ -1070,6 +1094,7 @@ "alt-up": "collab_panel::MoveChannelUp", "alt-down": "collab_panel::MoveChannelDown", "alt-enter": "collab_panel::OpenSelectedChannelNotes", + "shift-enter": "collab_panel::ToggleSelectedChannelFavorite", }, }, { @@ -1357,6 +1382,15 @@ "ctrl-shift-backspace": "git::DeleteWorktree", }, }, + { + // Handled under a more specific context to avoid conflicts with the + // `OpenCurrentFile` keybind from the settings UI + "context": "!SettingsWindow", + "use_key_equivalents": true, + "bindings": { + "ctrl-,": "zed::OpenSettings", + }, + }, { "context": "SettingsWindow", "use_key_equivalents": true, diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 6d1a0cf278d5eb7598ed92e91b7d4ffad90d9c05..ae0a0dd0f1ef3ba99814b39db6ec3932d0ef3730 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -337,6 +337,8 @@ "shift-j": "vim::JoinLines", "i": "vim::InsertBefore", "a": "vim::InsertAfter", + "o": "vim::InsertLineBelow", + "shift-o": "vim::InsertLineAbove", "p": "vim::Paste", "u": "vim::Undo", "r": "vim::PushReplace", @@ -1060,7 +1062,7 @@ }, }, { - "context": "Editor && edit_prediction && edit_prediction_mode == eager", + "context": "Editor && edit_prediction && edit_prediction_mode == eager && !showing_completions", "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. diff --git a/assets/settings/default.json b/assets/settings/default.json index 97c74af5ad6b158a8658a944bdc0e5e16982e91f..d3defb428c68120e89c6bc6cc82488f03a06b320 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -299,6 +299,13 @@ // // Default: split "diff_view_style": "split", + // The minimum width (in em-widths) at which the split diff view is used. + // When the editor is narrower than this, the diff view automatically + // switches to unified mode and switches back when the editor is wide + // enough. Set to 0 to disable automatic switching. + // + // Default: 100 + "minimum_split_diff_width": 100, // Show method signatures in the editor, when inside parentheses. "auto_signature_help": false, // Whether to show the signature help after completion or a bracket pair inserted. @@ -460,12 +467,10 @@ "show_sign_in": true, // Whether to show the menus in the titlebar. "show_menus": false, + // The layout of window control buttons in the title bar (Linux only). + "button_layout": "platform_default", }, "audio": { - // Opt into the new audio system. - "experimental.rodio_audio": false, - // Requires 'rodio_audio: true' - // // Automatically increase or decrease you microphone's volume. This affects how // loud you sound to others. // @@ -474,33 +479,10 @@ // audio and has auto speaker volume on this will make you very loud // compared to other speakers. "experimental.auto_microphone_volume": false, - // Requires 'rodio_audio: true' - // - // Automatically increate or decrease the volume of other call members. - // This only affects how things sound for you. - "experimental.auto_speaker_volume": true, - // Requires 'rodio_audio: true' - // - // Remove background noises. Works great for typing, cars, dogs, AC. Does - // not work well on music. - "experimental.denoise": true, - // Requires 'rodio_audio: true' - // - // Use audio parameters compatible with the previous versions of - // experimental audio and non-experimental audio. When this is false you - // will sound strange to anyone not on the latest experimental audio. In - // 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, - // Requires 'rodio_audio: true' - // // Select specific output audio device. // `null` means use system default. // Any unrecognized output device will fall back to system default. "experimental.output_audio_device": null, - // Requires 'rodio_audio: true' - // // Select specific input audio device. // `null` means use system default. // Any unrecognized input device will fall back to system default. @@ -807,6 +789,8 @@ "sort_mode": "directories_first", // Whether to show error and warning count badges next to file names in the project panel. "diagnostic_badges": false, + // Whether to show the git status indicator next to file names in the project panel. + "git_status_indicator": false, // Whether to enable drag-and-drop operations in the project panel. "drag_and_drop": true, // Whether to hide the root entry when only one folder is open in the window; @@ -966,6 +950,12 @@ "button": true, // Where to dock the agent panel. Can be 'left', 'right' or 'bottom'. "dock": "right", + // Whether the agent panel should use flexible (proportional) sizing. + // + // Default: true + "flexible": true, + // Where to position the sidebar. Can be 'left' or 'right'. + "sidebar_side": "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. @@ -1127,6 +1117,10 @@ // // Default: true "expand_terminal_card": true, + // How thinking blocks should be displayed by default in the agent panel. + // + // Default: automatic + "thinking_display": "automatic", // Whether clicking the stop button on a running terminal tool should also cancel the agent's generation. // Note that this only applies to the stop button, not to ctrl+c inside the terminal. // @@ -1371,6 +1365,12 @@ "hard_tabs": false, // How many columns a tab should occupy. "tab_size": 4, + // Number of lines to search for modelines at the beginning and end of files. + // Modelines contain editor directives (e.g., vim/emacs settings) that configure + // the editor behavior for specific files. + // + // A value of 0 disables modelines support. + "modeline_lines": 5, // What debuggers are preferred by default for all languages. "debuggers": [], // Whether to enable word diff highlighting in the editor. @@ -1602,13 +1602,6 @@ "model": "codestral-latest", "max_tokens": 150, }, - "sweep": { - // When enabled, Sweep will not store edit prediction inputs or outputs. - // When disabled, Sweep may collect data including buffer contents, - // diagnostics, file paths, repository names, and generated predictions - // to improve the service. - "privacy_mode": false, - }, "ollama": { "api_url": "http://localhost:11434", "model": "qwen2.5-coder:7b-base", @@ -1639,6 +1632,8 @@ "status_bar": { // Whether to show the status bar. "experimental.show": true, + // Whether to show the name of the active file in the status bar. + "show_active_file": false, // Whether to show the active language button in the status bar. "active_language_button": true, // Whether to show the cursor position button in the status bar. @@ -1667,6 +1662,10 @@ "shell": "system", // Where to dock terminals panel. Can be `left`, `right`, `bottom`. "dock": "bottom", + // Whether the terminal panel should use flexible (proportional) sizing. + // + // Default: true + "flexible": true, // Default width when the terminal is docked to the left or right. "default_width": 640, // Default height when the terminal is docked to the bottom. @@ -2046,9 +2045,12 @@ "remove_trailing_whitespace_on_save": false, "ensure_final_newline_on_save": false, }, - "Elixir": { + "EEx": { "language_servers": ["elixir-ls", "!expert", "!next-ls", "!lexical", "..."], }, + "Elixir": { + "language_servers": ["elixir-ls", "!expert", "!next-ls", "!lexical", "!emmet-language-server", "..."], + }, "Elm": { "tab_size": 4, }, @@ -2072,7 +2074,7 @@ "allowed": true, }, }, - "HEEX": { + "HEEx": { "language_servers": ["elixir-ls", "!expert", "!next-ls", "!lexical", "..."], }, "HTML": { diff --git a/assets/settings/default_semantic_token_rules.json b/assets/settings/default_semantic_token_rules.json index 65b20a7423aef3c3221f9f80e345fd503627d98d..c070a253d3065feff6647123b5f687e94f5e85d6 100644 --- a/assets/settings/default_semantic_token_rules.json +++ b/assets/settings/default_semantic_token_rules.json @@ -119,6 +119,16 @@ "style": ["type"], }, // References + { + "token_type": "parameter", + "token_modifiers": ["declaration"], + "style": ["variable.parameter"] + }, + { + "token_type": "parameter", + "token_modifiers": ["definition"], + "style": ["variable.parameter"] + }, { "token_type": "parameter", "token_modifiers": [], @@ -201,6 +211,11 @@ "token_modifiers": [], "style": ["comment"], }, + { + "token_type": "string", + "token_modifiers": ["documentation"], + "style": ["string.doc"], + }, { "token_type": "string", "token_modifiers": [], diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 5fb27bcd5289f60bcdfdb6adc9007c86c60fbad7..54565997e15f5f79e4f242680403d2f1f75ca6eb 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -160,6 +160,7 @@ pub enum AgentThreadEntry { UserMessage(UserMessage), AssistantMessage(AssistantMessage), ToolCall(ToolCall), + CompletedPlan(Vec), } impl AgentThreadEntry { @@ -168,6 +169,7 @@ impl AgentThreadEntry { Self::UserMessage(message) => message.indented, Self::AssistantMessage(message) => message.indented, Self::ToolCall(_) => false, + Self::CompletedPlan(_) => false, } } @@ -176,6 +178,14 @@ impl AgentThreadEntry { Self::UserMessage(message) => message.to_markdown(cx), Self::AssistantMessage(message) => message.to_markdown(cx), Self::ToolCall(tool_call) => tool_call.to_markdown(cx), + Self::CompletedPlan(entries) => { + let mut md = String::from("## Plan\n\n"); + for entry in entries { + let source = entry.content.read(cx).source().to_string(); + md.push_str(&format!("- [x] {}\n", source)); + } + md + } } } @@ -502,13 +512,15 @@ pub enum SelectedPermissionParams { #[derive(Debug)] pub struct SelectedPermissionOutcome { pub option_id: acp::PermissionOptionId, + pub option_kind: acp::PermissionOptionKind, pub params: Option, } impl SelectedPermissionOutcome { - pub fn new(option_id: acp::PermissionOptionId) -> Self { + pub fn new(option_id: acp::PermissionOptionId, option_kind: acp::PermissionOptionKind) -> Self { Self { option_id, + option_kind, params: None, } } @@ -519,12 +531,6 @@ impl SelectedPermissionOutcome { } } -impl From for SelectedPermissionOutcome { - fn from(option_id: acp::PermissionOptionId) -> Self { - Self::new(option_id) - } -} - impl From for acp::SelectedPermissionOutcome { fn from(value: SelectedPermissionOutcome) -> Self { Self::new(value.option_id) @@ -1282,6 +1288,10 @@ impl AcpThread { self.work_dirs.as_ref() } + pub fn set_work_dirs(&mut self, work_dirs: PathList) { + self.work_dirs = Some(work_dirs); + } + pub fn status(&self) -> ThreadStatus { if self.running_turn.is_some() { ThreadStatus::Generating @@ -1302,7 +1312,9 @@ impl AcpThread { status: ToolCallStatus::WaitingForConfirmation { .. }, .. }) => return true, - AgentThreadEntry::ToolCall(_) | AgentThreadEntry::AssistantMessage(_) => {} + AgentThreadEntry::ToolCall(_) + | AgentThreadEntry::AssistantMessage(_) + | AgentThreadEntry::CompletedPlan(_) => {} } } false @@ -1324,7 +1336,9 @@ impl AcpThread { ) if call.diffs().next().is_some() => { return true; } - AgentThreadEntry::ToolCall(_) | AgentThreadEntry::AssistantMessage(_) => {} + AgentThreadEntry::ToolCall(_) + | AgentThreadEntry::AssistantMessage(_) + | AgentThreadEntry::CompletedPlan(_) => {} } } @@ -1341,7 +1355,9 @@ impl AcpThread { }) => { return true; } - AgentThreadEntry::ToolCall(_) | AgentThreadEntry::AssistantMessage(_) => {} + AgentThreadEntry::ToolCall(_) + | AgentThreadEntry::AssistantMessage(_) + | AgentThreadEntry::CompletedPlan(_) => {} } } @@ -1352,7 +1368,9 @@ impl AcpThread { for entry in self.entries.iter().rev() { match entry { AgentThreadEntry::UserMessage(..) => return false, - AgentThreadEntry::AssistantMessage(..) => continue, + AgentThreadEntry::AssistantMessage(..) | AgentThreadEntry::CompletedPlan(..) => { + continue; + } AgentThreadEntry::ToolCall(..) => return true, } } @@ -2013,14 +2031,13 @@ impl AcpThread { &mut self, id: acp::ToolCallId, outcome: SelectedPermissionOutcome, - option_kind: acp::PermissionOptionKind, cx: &mut Context, ) { let Some((ix, call)) = self.tool_call_mut(&id) else { return; }; - let new_status = match option_kind { + let new_status = match outcome.option_kind { acp::PermissionOptionKind::RejectOnce | acp::PermissionOptionKind::RejectAlways => { ToolCallStatus::Rejected } @@ -2070,6 +2087,13 @@ impl AcpThread { cx.notify(); } + pub fn snapshot_completed_plan(&mut self, cx: &mut Context) { + if !self.plan.is_empty() && self.plan.stats().pending == 0 { + let completed_entries = std::mem::take(&mut self.plan.entries); + self.push_entry(AgentThreadEntry::CompletedPlan(completed_entries), cx); + } + } + fn clear_completed_plan_entries(&mut self, cx: &mut Context) { self.plan .entries @@ -2077,6 +2101,11 @@ impl AcpThread { cx.notify(); } + pub fn clear_plan(&mut self, cx: &mut Context) { + self.plan.entries.clear(); + cx.notify(); + } + #[cfg(any(test, feature = "test-support"))] pub fn send_raw( &mut self, @@ -2215,7 +2244,24 @@ impl AcpThread { this.had_error = true; cx.emit(AcpThreadEvent::Error); log::error!("Max tokens reached. Usage: {:?}", this.token_usage); - return Err(anyhow!("Max tokens reached")); + + let exceeded_max_output_tokens = + this.token_usage.as_ref().is_some_and(|u| { + u.max_output_tokens + .is_some_and(|max| u.output_tokens >= max) + }); + + let message = if exceeded_max_output_tokens { + log::error!( + "Max output tokens reached. Usage: {:?}", + this.token_usage + ); + "Maximum output tokens reached" + } else { + log::error!("Max tokens reached. Usage: {:?}", this.token_usage); + "Maximum tokens reached" + }; + return Err(anyhow!(message)); } let canceled = matches!(r.stop_reason, acp::StopReason::Cancelled); @@ -2223,6 +2269,10 @@ impl AcpThread { this.mark_pending_tools_as_canceled(); } + if !canceled { + this.snapshot_completed_plan(cx); + } + // Handle refusal - distinguish between user prompt and tool call refusals if let acp::StopReason::Refusal = r.stop_reason { this.had_error = true; @@ -2593,11 +2643,8 @@ impl AcpThread { let format_on_save = buffer.update(cx, |buffer, cx| { buffer.edit(edits, None, cx); - let settings = language::language_settings::language_settings( - buffer.language().map(|l| l.name()), - buffer.file(), - cx, - ); + let settings = + language::language_settings::LanguageSettings::for_buffer(buffer, cx); settings.format_on_save != FormatOnSave::Off }); @@ -3185,9 +3232,27 @@ mod tests { ); }); - // Wait for the printf command to execute and produce output - // Use real time since parking is enabled - cx.executor().timer(Duration::from_millis(500)).await; + // Poll until the printf command produces output, rather than using a + // fixed sleep which is flaky on loaded machines. + let deadline = std::time::Instant::now() + Duration::from_secs(10); + loop { + let has_output = thread.read_with(cx, |thread, cx| { + let term = thread + .terminals + .get(&terminal_id) + .expect("terminal not found"); + let content = term.read(cx).inner().read(cx).get_content(); + content.contains("output_before_kill") + }); + if has_output { + break; + } + assert!( + std::time::Instant::now() < deadline, + "Timed out waiting for printf output to appear in terminal", + ); + cx.executor().timer(Duration::from_millis(50)).await; + } // Get the acp_thread Terminal and kill it let wait_for_exit = thread.update(cx, |thread, cx| { diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index 1c6f2a49f18b4b6c664f49d1a732de9a53c75aab..58a8aa33830f12ffb713490c87c47133cc2ad96f 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -477,6 +477,24 @@ impl PermissionOptionChoice { pub fn label(&self) -> SharedString { self.allow.name.clone().into() } + + /// Build a `SelectedPermissionOutcome` for this choice. + /// + /// If the choice carries `sub_patterns`, they are attached as + /// `SelectedPermissionParams::Terminal`. + pub fn build_outcome(&self, is_allow: bool) -> crate::SelectedPermissionOutcome { + let option = if is_allow { &self.allow } else { &self.deny }; + + let params = if !self.sub_patterns.is_empty() { + Some(crate::SelectedPermissionParams::Terminal { + patterns: self.sub_patterns.clone(), + }) + } else { + None + }; + + crate::SelectedPermissionOutcome::new(option.option_id.clone(), option.kind).params(params) + } } /// Pairs a tool's permission pattern with its display name @@ -548,6 +566,57 @@ impl PermissionOptions { self.first_option_of_kind(acp::PermissionOptionKind::RejectOnce) .map(|option| option.option_id.clone()) } + + /// Build a `SelectedPermissionOutcome` for the `DropdownWithPatterns` + /// variant when the user has checked specific pattern indices. + /// + /// Returns `Some` with the always-allow/deny outcome when at least one + /// pattern is checked. Returns `None` when zero patterns are checked, + /// signaling that the caller should degrade to allow-once / deny-once. + /// + /// Panics (debug) or returns `None` (release) if called on a non- + /// `DropdownWithPatterns` variant. + pub fn build_outcome_for_checked_patterns( + &self, + checked_indices: &[usize], + is_allow: bool, + ) -> Option { + let PermissionOptions::DropdownWithPatterns { + choices, patterns, .. + } = self + else { + debug_assert!( + false, + "build_outcome_for_checked_patterns called on non-DropdownWithPatterns" + ); + return None; + }; + + let checked_patterns: Vec = patterns + .iter() + .enumerate() + .filter(|(index, _)| checked_indices.contains(index)) + .map(|(_, cp)| cp.pattern.clone()) + .collect(); + + if checked_patterns.is_empty() { + return None; + } + + // Use the first choice (the "Always" choice) as the base for the outcome. + let always_choice = choices.first()?; + let option = if is_allow { + &always_choice.allow + } else { + &always_choice.deny + }; + + let outcome = crate::SelectedPermissionOutcome::new(option.option_id.clone(), option.kind) + .params(Some(crate::SelectedPermissionParams::Terminal { + patterns: checked_patterns, + })); + Some(outcome) + } } #[cfg(feature = "test-support")] diff --git a/crates/acp_tools/Cargo.toml b/crates/acp_tools/Cargo.toml index 0720c4b6685ecf7fa20d8cacd2b61baa765c961c..8f14b1f93b32c6df521ea13ebf3f0f73e7ed755c 100644 --- a/crates/acp_tools/Cargo.toml +++ b/crates/acp_tools/Cargo.toml @@ -23,7 +23,7 @@ project.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true -theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true diff --git a/crates/acp_tools/src/acp_tools.rs b/crates/acp_tools/src/acp_tools.rs index 30d13effcb53395972879ef109a253be0c134ec1..52a9d03f893d0b82bf6395b4c96bc9ebe14d3afe 100644 --- a/crates/acp_tools/src/acp_tools.rs +++ b/crates/acp_tools/src/acp_tools.rs @@ -16,7 +16,7 @@ use language::LanguageRegistry; use markdown::{CodeBlockRenderer, Markdown, MarkdownElement, MarkdownStyle}; use project::{AgentId, Project}; use settings::Settings; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{CopyButton, Tooltip, WithScrollbar, prelude::*}; use util::ResultExt as _; use workspace::{ @@ -291,7 +291,6 @@ impl AcpTools { v_flex() .id(index) .group("message") - .cursor_pointer() .font_buffer(cx) .w_full() .py_3() @@ -303,27 +302,29 @@ impl AcpTools { .border_color(colors.border) .border_b_1() .hover(|this| this.bg(colors.element_background.opacity(0.5))) - .on_click(cx.listener(move |this, _, _, cx| { - if this.expanded.contains(&index) { - this.expanded.remove(&index); - } else { - this.expanded.insert(index); - let Some(connection) = &mut this.watched_connection else { - return; - }; - let Some(message) = connection.messages.get_mut(index) else { - return; - }; - message.expanded(this.project.read(cx).languages().clone(), cx); - connection.list_state.scroll_to_reveal_item(index); - } - cx.notify() - })) .child( h_flex() + .id(("acp-log-message-header", index)) .w_full() .gap_2() .flex_shrink_0() + .cursor_pointer() + .on_click(cx.listener(move |this, _, _, cx| { + if this.expanded.contains(&index) { + this.expanded.remove(&index); + } else { + this.expanded.insert(index); + let Some(connection) = &mut this.watched_connection else { + return; + }; + let Some(message) = connection.messages.get_mut(index) else { + return; + }; + message.expanded(this.project.read(cx).languages().clone(), cx); + connection.list_state.scroll_to_reveal_item(index); + } + cx.notify() + })) .child(match message.direction { acp::StreamMessageDirection::Incoming => Icon::new(IconName::ArrowDown) .color(Color::Error) diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index fe2089d94dc2e3fc812f6cbe39c16c5cadc1a1f5..a5a4c2742a444bf2e8b0a12b0bb233c6e51684f2 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -10,7 +10,6 @@ path = "src/agent.rs" [features] test-support = ["db/test-support"] -eval = [] unit-eval = [] e2e = [] diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 77c326feec60514d459e6026a39f1bcd5ed8a896..b7aa9d1e311016f572928993e049798c2b5e3bb2 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -82,7 +82,7 @@ struct Session { /// The ACP thread that handles protocol communication acp_thread: Entity, project_id: EntityId, - pending_save: Task<()>, + pending_save: Task>, _subscriptions: Vec, } @@ -387,7 +387,7 @@ impl NativeAgent { acp_thread: acp_thread.clone(), project_id, _subscriptions: subscriptions, - pending_save: Task::ready(()), + pending_save: Task::ready(Ok(())), }, ); @@ -663,15 +663,18 @@ impl NativeAgent { return; }; - if let Some(title) = thread.read(cx).title() { - let acp_thread = session.acp_thread.downgrade(); - cx.spawn(async move |_, cx| { + let thread = thread.downgrade(); + let acp_thread = session.acp_thread.downgrade(); + cx.spawn(async move |_, cx| { + let title = thread.read_with(cx, |thread, _| thread.title())?; + if let Some(title) = title { let task = acp_thread.update(cx, |acp_thread, cx| acp_thread.set_title(title, cx))?; - task.await - }) - .detach_and_log_err(cx); - } + task.await?; + } + anyhow::Ok(()) + }) + .detach_and_log_err(cx); } fn handle_thread_token_usage_updated( @@ -939,6 +942,9 @@ impl NativeAgent { NativeAgentConnection::handle_thread_events(events, acp_thread.downgrade(), cx) }) .await?; + acp_thread.update(cx, |thread, cx| { + thread.snapshot_completed_plan(cx); + }); Ok(acp_thread) }) } @@ -1000,7 +1006,7 @@ impl NativeAgent { let thread_store = self.thread_store.clone(); session.pending_save = cx.spawn(async move |_, cx| { let Some(database) = database_future.await.map_err(|err| anyhow!(err)).log_err() else { - return; + return Ok(()); }; let db_thread = db_thread.await; database @@ -1008,6 +1014,7 @@ impl NativeAgent { .await .log_err(); thread_store.update(cx, |store, cx| store.reload(cx)); + Ok(()) }); } @@ -1444,18 +1451,23 @@ impl acp_thread::AgentConnection for NativeAgentConnection { cx: &mut App, ) -> Task> { self.0.update(cx, |agent, cx| { + let thread = agent.sessions.get(session_id).map(|s| s.thread.clone()); + if let Some(thread) = thread { + agent.save_thread(thread, cx); + } + let Some(session) = agent.sessions.remove(session_id) else { - return; + return Task::ready(Ok(())); }; let project_id = session.project_id; - agent.save_thread(session.thread, cx); let has_remaining = agent.sessions.values().any(|s| s.project_id == project_id); if !has_remaining { agent.projects.remove(&project_id); } - }); - Task::ready(Ok(())) + + session.pending_save + }) } fn auth_methods(&self) -> &[acp::AuthMethod] { @@ -2830,7 +2842,9 @@ mod internal_tests { cx.run_until_parked(); - // Set a draft prompt with rich content blocks before saving. + // Set a draft prompt with rich content blocks and scroll position + // AFTER run_until_parked, so the only save that captures these + // changes is the one performed by close_session itself. let draft_blocks = vec![ acp::ContentBlock::Text(acp::TextContent::new("Check out ")), acp::ContentBlock::ResourceLink(acp::ResourceLink::new("b.md", uri.to_string())), @@ -2845,8 +2859,6 @@ mod internal_tests { offset_in_item: gpui::px(12.5), })); }); - thread.update(cx, |_thread, cx| cx.notify()); - cx.run_until_parked(); // Close the session so it can be reloaded from disk. cx.update(|cx| connection.clone().close_session(&session_id, cx)) @@ -2912,6 +2924,151 @@ mod internal_tests { }); } + #[gpui::test] + async fn test_close_session_saves_thread(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/", + json!({ + "a": { + "file.txt": "hello" + } + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; + let thread_store = cx.new(|cx| ThreadStore::new(cx)); + let agent = cx.update(|cx| { + NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) + }); + let connection = Rc::new(NativeAgentConnection(agent.clone())); + + let acp_thread = cx + .update(|cx| { + connection + .clone() + .new_session(project.clone(), PathList::new(&[Path::new("")]), cx) + }) + .await + .unwrap(); + let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone()); + let thread = agent.read_with(cx, |agent, _| { + agent.sessions.get(&session_id).unwrap().thread.clone() + }); + + let model = Arc::new(FakeLanguageModel::default()); + thread.update(cx, |thread, cx| { + thread.set_model(model.clone(), cx); + }); + + // Send a message so the thread is non-empty (empty threads aren't saved). + let send = acp_thread.update(cx, |thread, cx| thread.send(vec!["hello".into()], cx)); + let send = cx.foreground_executor().spawn(send); + cx.run_until_parked(); + + model.send_last_completion_stream_text_chunk("world"); + model.end_last_completion_stream(); + send.await.unwrap(); + cx.run_until_parked(); + + // Set a draft prompt WITHOUT calling run_until_parked afterwards. + // This means no observe-triggered save has run for this change. + // The only way this data gets persisted is if close_session + // itself performs the save. + let draft_blocks = vec![acp::ContentBlock::Text(acp::TextContent::new( + "unsaved draft", + ))]; + acp_thread.update(cx, |thread, _cx| { + thread.set_draft_prompt(Some(draft_blocks.clone())); + }); + + // Close the session immediately — no run_until_parked in between. + cx.update(|cx| connection.clone().close_session(&session_id, cx)) + .await + .unwrap(); + cx.run_until_parked(); + + // Reopen and verify the draft prompt was saved. + let reloaded = agent + .update(cx, |agent, cx| { + agent.open_thread(session_id.clone(), project.clone(), cx) + }) + .await + .unwrap(); + reloaded.read_with(cx, |thread, _| { + assert_eq!( + thread.draft_prompt(), + Some(draft_blocks.as_slice()), + "close_session must save the thread; draft prompt was lost" + ); + }); + } + + #[gpui::test] + async fn test_rapid_title_changes_do_not_loop(cx: &mut TestAppContext) { + // Regression test: rapid title changes must not cause a propagation loop + // between Thread and AcpThread via handle_thread_title_updated. + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/", json!({ "a": {} })).await; + let project = Project::test(fs.clone(), [], cx).await; + let thread_store = cx.new(|cx| ThreadStore::new(cx)); + let agent = cx.update(|cx| { + NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) + }); + let connection = Rc::new(NativeAgentConnection(agent.clone())); + + let acp_thread = cx + .update(|cx| { + connection + .clone() + .new_session(project.clone(), PathList::new(&[Path::new("")]), cx) + }) + .await + .unwrap(); + + let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone()); + let thread = agent.read_with(cx, |agent, _| { + agent.sessions.get(&session_id).unwrap().thread.clone() + }); + + let title_updated_count = Rc::new(std::cell::RefCell::new(0usize)); + cx.update(|cx| { + let count = title_updated_count.clone(); + cx.subscribe( + &thread, + move |_entity: Entity, _event: &TitleUpdated, _cx: &mut App| { + let new_count = { + let mut count = count.borrow_mut(); + *count += 1; + *count + }; + assert!( + new_count <= 2, + "TitleUpdated fired {new_count} times; \ + title updates are looping" + ); + }, + ) + .detach(); + }); + + thread.update(cx, |thread, cx| thread.set_title("first".into(), cx)); + thread.update(cx, |thread, cx| thread.set_title("second".into(), cx)); + + cx.run_until_parked(); + + thread.read_with(cx, |thread, _| { + assert_eq!(thread.title(), Some("second".into())); + }); + acp_thread.read_with(cx, |acp_thread, _| { + assert_eq!(acp_thread.title(), Some("second".into())); + }); + + assert_eq!(*title_updated_count.borrow(), 2); + } + fn thread_entries( thread_store: &Entity, cx: &mut TestAppContext, diff --git a/crates/agent/src/edit_agent.rs b/crates/agent/src/edit_agent.rs index e122d6b2884a593daa819457835d3d00690f5a7d..6e6cf9735a922695bf089bdcc78798fb086ad364 100644 --- a/crates/agent/src/edit_agent.rs +++ b/crates/agent/src/edit_agent.rs @@ -1,6 +1,6 @@ mod create_file_parser; mod edit_parser; -#[cfg(test)] +#[cfg(all(test, feature = "unit-eval"))] mod evals; pub mod reindent; pub mod streaming_fuzzy_matcher; @@ -8,7 +8,6 @@ pub mod streaming_fuzzy_matcher; use crate::{Template, Templates}; use action_log::ActionLog; use anyhow::Result; -use cloud_llm_client::CompletionIntent; use create_file_parser::{CreateFileParser, CreateFileParserEvent}; pub use edit_parser::EditFormat; use edit_parser::{EditParser, EditParserEvent, EditParserMetrics}; @@ -21,8 +20,8 @@ use futures::{ use gpui::{AppContext, AsyncApp, Entity, Task}; use language::{Anchor, Buffer, BufferSnapshot, LineIndent, Point, TextBufferSnapshot}; use language_model::{ - LanguageModel, LanguageModelCompletionError, LanguageModelRequest, LanguageModelRequestMessage, - LanguageModelToolChoice, MessageContent, Role, + CompletionIntent, LanguageModel, LanguageModelCompletionError, LanguageModelRequest, + LanguageModelRequestMessage, LanguageModelToolChoice, MessageContent, Role, }; use project::{AgentLocation, Project}; use reindent::{IndentDelta, Reindenter}; diff --git a/crates/agent/src/edit_agent/evals/fixtures/disable_cursor_blinking/before.rs b/crates/agent/src/edit_agent/evals/fixtures/disable_cursor_blinking/before.rs index 607daa8ce3a129e0f4bc53a00d1a62f479da3932..198ab45b13faef814e5964892e02e4c9d60de5b0 100644 --- a/crates/agent/src/edit_agent/evals/fixtures/disable_cursor_blinking/before.rs +++ b/crates/agent/src/edit_agent/evals/fixtures/disable_cursor_blinking/before.rs @@ -550,7 +550,7 @@ impl Default for EditorStyle { } pub fn make_inlay_hints_style(cx: &mut App) -> HighlightStyle { - let show_background = language_settings::language_settings(None, None, cx) + let show_background = language_settings::language_settings(cx).get() .inlay_hints .show_background; @@ -5989,7 +5989,7 @@ impl Editor { let file = buffer.file(); - if !language_settings(buffer.language().map(|l| l.name()), file, cx).show_edit_predictions { + if !language_settings(cx).buffer(buffer).get().show_edit_predictions { return EditPredictionSettings::Disabled; }; @@ -7837,7 +7837,7 @@ impl Editor { h_flex() .px_0p5() .when(is_platform_style_mac, |parent| parent.gap_0p5()) - .font(theme::ThemeSettings::get_global(cx).buffer_font.clone()) + .font(theme_settings::ThemeSettings::get_global(cx).buffer_font.clone()) .text_size(TextSize::XSmall.rems(cx)) .child(h_flex().children(ui::render_modifiers( &accept_keystroke.modifiers, @@ -8149,7 +8149,7 @@ impl Editor { .px_2() .child( h_flex() - .font(theme::ThemeSettings::get_global(cx).buffer_font.clone()) + .font(theme_settings::ThemeSettings::get_global(cx).buffer_font.clone()) .when(is_platform_style_mac, |parent| parent.gap_1()) .child(h_flex().children(ui::render_modifiers( &accept_keystroke.modifiers, @@ -8258,7 +8258,7 @@ impl Editor { .gap_2() .pr_1() .overflow_x_hidden() - .font(theme::ThemeSettings::get_global(cx).buffer_font.clone()) + .font(theme_settings::ThemeSettings::get_global(cx).buffer_font.clone()) .child(left) .child(preview), ) @@ -11922,6 +11922,7 @@ impl Editor { scroll_anchor: scroll_state, scroll_top_row, }), + Some(cursor_position.row), cx, ); cx.emit(EditorEvent::PushedToNavHistory { @@ -18800,7 +18801,7 @@ fn choose_completion_range( } = &completion.source { let completion_mode_setting = - language_settings(buffer.language().map(|l| l.name()), buffer.file(), cx) + language_settings(cx).buffer(buffer).get() .completions .lsp_insert_mode; @@ -19849,7 +19850,7 @@ fn inlay_hint_settings( ) -> InlayHintSettings { let file = snapshot.file_at(location); let language = snapshot.language_at(location).map(|l| l.name()); - language_settings(language, file, cx).inlay_hints + language_settings(cx).language(language).file(file).get().inlay_hints } fn consume_contiguous_rows( diff --git a/crates/agent/src/edit_agent/streaming_fuzzy_matcher.rs b/crates/agent/src/edit_agent/streaming_fuzzy_matcher.rs index 1ce2ca6f361a7e8186711d35d4dc640b8f13ce5a..e6a56099a293215050fa082a0432f216754473af 100644 --- a/crates/agent/src/edit_agent/streaming_fuzzy_matcher.rs +++ b/crates/agent/src/edit_agent/streaming_fuzzy_matcher.rs @@ -72,6 +72,18 @@ impl StreamingFuzzyMatcher { pub fn finish(&mut self) -> Vec> { // Process any remaining incomplete line if !self.incomplete_line.is_empty() { + if self.matches.len() == 1 { + let range = &mut self.matches[0]; + if range.end < self.snapshot.len() + && self + .snapshot + .contains_str_at(range.end + 1, &self.incomplete_line) + { + range.end += 1 + self.incomplete_line.len(); + return self.matches.clone(); + } + } + self.query_lines.push(self.incomplete_line.clone()); self.incomplete_line.clear(); self.matches = self.resolve_location_fuzzy(); @@ -722,6 +734,54 @@ mod tests { ); } + #[gpui::test] + fn test_prefix_of_last_line_resolves_to_correct_range() { + let text = indoc! {r#" + fn on_query_change(&mut self, cx: &mut Context) { + self.filter(cx); + } + + + + fn render_search(&self, cx: &mut Context) -> Div { + div() + } + "#}; + + let buffer = TextBuffer::new( + ReplicaId::LOCAL, + BufferId::new(1).unwrap(), + text.to_string(), + ); + let snapshot = buffer.snapshot(); + + // Query with a partial last line. + let query = "}\n\n\n\nfn render_search"; + + let mut matcher = StreamingFuzzyMatcher::new(snapshot.clone()); + matcher.push(query, None); + let matches = matcher.finish(); + + // The match should include the line containing "fn render_search". + let matched_text = matches + .first() + .map(|range| snapshot.text_for_range(range.clone()).collect::()); + + assert!( + matches.len() == 1, + "Expected exactly one match, got {}: {:?}", + matches.len(), + matched_text, + ); + + let matched_text = matched_text.unwrap(); + pretty_assertions::assert_eq!( + matched_text, + "}\n\n\n\nfn render_search", + "Match should include the render_search line", + ); + } + #[track_caller] fn assert_location_resolution(text_with_expected_range: &str, query: &str, rng: &mut StdRng) { let (text, expected_ranges) = marked_text_ranges(text_with_expected_range, false); diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index 5cb2c99bfae222b13fd978e1bc3eba2fe98ca1d6..036a6f1030c43b16d51f864a1d0176891e90b772 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -7,7 +7,6 @@ use agent_client_protocol::{self as acp}; use agent_settings::AgentProfileId; use anyhow::Result; use client::{Client, UserStore}; -use cloud_llm_client::CompletionIntent; use collections::IndexMap; use context_server::{ContextServer, ContextServerCommand, ContextServerId}; use feature_flags::FeatureFlagAppExt as _; @@ -26,8 +25,8 @@ use gpui::{ }; use indoc::indoc; use language_model::{ - LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, - LanguageModelProviderName, LanguageModelRegistry, LanguageModelRequest, + CompletionIntent, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, + LanguageModelId, LanguageModelProviderName, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, LanguageModelToolResult, LanguageModelToolSchemaFormat, LanguageModelToolUse, MessageContent, Role, StopReason, TokenUsage, fake_provider::FakeLanguageModel, @@ -841,14 +840,20 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { // Approve the first - send "allow" option_id (UI transforms "once" to "allow") tool_call_auth_1 .response - .send(acp::PermissionOptionId::new("allow").into()) + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) .unwrap(); cx.run_until_parked(); // Reject the second - send "deny" option_id directly since Deny is now a button tool_call_auth_2 .response - .send(acp::PermissionOptionId::new("deny").into()) + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("deny"), + acp::PermissionOptionKind::RejectOnce, + )) .unwrap(); cx.run_until_parked(); @@ -892,7 +897,10 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { let tool_call_auth_3 = next_tool_call_authorization(&mut events).await; tool_call_auth_3 .response - .send(acp::PermissionOptionId::new("always_allow:tool_requiring_permission").into()) + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("always_allow:tool_requiring_permission"), + acp::PermissionOptionKind::AllowAlways, + )) .unwrap(); cx.run_until_parked(); let completion = fake_model.pending_completions().pop().unwrap(); @@ -3452,7 +3460,6 @@ async fn test_update_plan_tool_updates_thread_events(cx: &mut TestAppContext) { { "step": "Inspect the code", "status": "completed", - "priority": "high" }, { "step": "Implement the tool", @@ -3461,7 +3468,6 @@ async fn test_update_plan_tool_updates_thread_events(cx: &mut TestAppContext) { { "step": "Run tests", "status": "pending", - "priority": "low" } ] }); @@ -3488,7 +3494,6 @@ async fn test_update_plan_tool_updates_thread_events(cx: &mut TestAppContext) { { "step": "Inspect the code", "status": "completed", - "priority": "high" }, { "step": "Implement the tool", @@ -3497,7 +3502,6 @@ async fn test_update_plan_tool_updates_thread_events(cx: &mut TestAppContext) { { "step": "Run tests", "status": "pending", - "priority": "low" } ] })) @@ -3522,7 +3526,7 @@ async fn test_update_plan_tool_updates_thread_events(cx: &mut TestAppContext) { acp::Plan::new(vec![ acp::PlanEntry::new( "Inspect the code", - acp::PlanEntryPriority::High, + acp::PlanEntryPriority::Medium, acp::PlanEntryStatus::Completed, ), acp::PlanEntry::new( @@ -3532,7 +3536,7 @@ async fn test_update_plan_tool_updates_thread_events(cx: &mut TestAppContext) { ), acp::PlanEntry::new( "Run tests", - acp::PlanEntryPriority::Low, + acp::PlanEntryPriority::Medium, acp::PlanEntryStatus::Pending, ), ]) @@ -5186,6 +5190,11 @@ async fn test_subagent_thread_inherits_parent_thread_properties(cx: &mut TestApp subagent_thread.parent_thread_id(), Some(parent_thread.read(cx).id().clone()) ); + + let request = subagent_thread + .build_completion_request(CompletionIntent::UserPrompt, cx) + .unwrap(); + assert_eq!(request.intent, Some(CompletionIntent::Subagent)); }); } diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 39f5a9df902744875a9faaa1651d65842c1dbf11..627fb37b4d2559e5cda573d849fd0df306c1cc7d 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -20,7 +20,6 @@ use anyhow::{Context as _, Result, anyhow}; use chrono::{DateTime, Utc}; use client::UserStore; use cloud_api_types::Plan; -use cloud_llm_client::CompletionIntent; use collections::{HashMap, HashSet, IndexMap}; use fs::Fs; use futures::stream; @@ -35,12 +34,12 @@ use gpui::{ }; use heck::ToSnakeCase as _; use language_model::{ - LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, - LanguageModelImage, LanguageModelProviderId, LanguageModelRegistry, LanguageModelRequest, - LanguageModelRequestMessage, LanguageModelRequestTool, LanguageModelToolResult, - LanguageModelToolResultContent, LanguageModelToolSchemaFormat, LanguageModelToolUse, - LanguageModelToolUseId, Role, SelectedModel, Speed, StopReason, TokenUsage, - ZED_CLOUD_PROVIDER_ID, + CompletionIntent, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, + LanguageModelId, LanguageModelImage, LanguageModelProviderId, LanguageModelRegistry, + LanguageModelRequest, LanguageModelRequestMessage, LanguageModelRequestTool, + LanguageModelToolResult, LanguageModelToolResultContent, LanguageModelToolSchemaFormat, + LanguageModelToolUse, LanguageModelToolUseId, Role, SelectedModel, Speed, StopReason, + TokenUsage, ZED_CLOUD_PROVIDER_ID, }; use project::Project; use prompt_store::ProjectContext; @@ -1805,14 +1804,6 @@ impl Thread { cx.notify(); } - #[cfg(feature = "eval")] - pub fn proceed( - &mut self, - cx: &mut Context, - ) -> Result>> { - self.run_turn(cx) - } - fn run_turn( &mut self, cx: &mut Context, @@ -2699,6 +2690,13 @@ impl Thread { completion_intent: CompletionIntent, cx: &App, ) -> Result { + let completion_intent = + if self.is_subagent() && completion_intent == CompletionIntent::UserPrompt { + CompletionIntent::Subagent + } else { + completion_intent + }; + let model = self.model().context("No language model configured")?; let tools = if let Some(turn) = self.running_turn.as_ref() { turn.tools diff --git a/crates/agent/src/thread_store.rs b/crates/agent/src/thread_store.rs index 379ae675d4bbf3c2a9570365493317178f38a804..e62ff78871c65311627aab8f6a6e3c00481a0c2b 100644 --- a/crates/agent/src/thread_store.rs +++ b/crates/agent/src/thread_store.rs @@ -113,6 +113,10 @@ impl ThreadStore { pub fn entries(&self) -> impl Iterator + '_ { self.threads.iter().cloned() } + + pub fn entry_ids(&self) -> impl Iterator + '_ { + self.threads.iter().map(|t| t.id.clone()) + } } #[cfg(test)] diff --git a/crates/agent/src/tool_permissions.rs b/crates/agent/src/tool_permissions.rs index 345511c5025b25601c630c572980d44a23f724e7..73b3ff842ab6961b22815c902ce9ae79e60cd2e3 100644 --- a/crates/agent/src/tool_permissions.rs +++ b/crates/agent/src/tool_permissions.rs @@ -571,6 +571,7 @@ mod tests { enabled: true, button: true, dock: DockPosition::Right, + flexible: true, default_width: px(300.), default_height: px(600.), default_model: None, @@ -596,6 +597,8 @@ mod tests { tool_permissions, show_turn_stats: false, new_thread_location: Default::default(), + sidebar_side: Default::default(), + thinking_display: Default::default(), } } diff --git a/crates/agent/src/tools.rs b/crates/agent/src/tools.rs index f172fd3fdbe14babb77e53b63dd79aebf50d2603..f3a6ac7ec6d139a2f464ce5ca4229ffdb4564714 100644 --- a/crates/agent/src/tools.rs +++ b/crates/agent/src/tools.rs @@ -4,6 +4,8 @@ mod create_directory_tool; mod delete_path_tool; mod diagnostics_tool; mod edit_file_tool; +#[cfg(all(test, feature = "unit-eval"))] +mod evals; mod fetch_tool; mod find_path_tool; mod grep_tool; diff --git a/crates/agent/src/tools/copy_path_tool.rs b/crates/agent/src/tools/copy_path_tool.rs index 7955a6cc0755514ba4341e43af980e9b93478134..95688f27dcd8ca04aef72358ce52144f95138e17 100644 --- a/crates/agent/src/tools/copy_path_tool.rs +++ b/crates/agent/src/tools/copy_path_tool.rs @@ -266,7 +266,10 @@ mod tests { ); auth.response - .send(acp::PermissionOptionId::new("allow").into()) + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) .unwrap(); let result = task.await; @@ -372,7 +375,10 @@ mod tests { ); auth.response - .send(acp::PermissionOptionId::new("allow").into()) + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) .unwrap(); assert!( diff --git a/crates/agent/src/tools/create_directory_tool.rs b/crates/agent/src/tools/create_directory_tool.rs index 7052b5dfdc2c7d546f5e477430d6de1a0039b03d..d6c59bcce30ab26991edba0fa7181ec45d10e1b0 100644 --- a/crates/agent/src/tools/create_directory_tool.rs +++ b/crates/agent/src/tools/create_directory_tool.rs @@ -241,7 +241,10 @@ mod tests { ); auth.response - .send(acp::PermissionOptionId::new("allow").into()) + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) .unwrap(); let result = task.await; @@ -359,7 +362,10 @@ mod tests { ); auth.response - .send(acp::PermissionOptionId::new("allow").into()) + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) .unwrap(); assert!( diff --git a/crates/agent/src/tools/delete_path_tool.rs b/crates/agent/src/tools/delete_path_tool.rs index 9b2c0a20b8a26b57ef77bb91004c079265fc80cf..7433975c7b782a145dd3e5a80ee59cd92945a989 100644 --- a/crates/agent/src/tools/delete_path_tool.rs +++ b/crates/agent/src/tools/delete_path_tool.rs @@ -301,7 +301,10 @@ mod tests { ); auth.response - .send(acp::PermissionOptionId::new("allow").into()) + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) .unwrap(); let result = task.await; @@ -428,7 +431,10 @@ mod tests { ); auth.response - .send(acp::PermissionOptionId::new("allow").into()) + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) .unwrap(); assert!( diff --git a/crates/agent/src/tools/edit_file_tool.rs b/crates/agent/src/tools/edit_file_tool.rs index 3325a612a0143070a3fc157976be93276f98cb5f..763efd6724a719b90af93843f203ef8c1c3976bb 100644 --- a/crates/agent/src/tools/edit_file_tool.rs +++ b/crates/agent/src/tools/edit_file_tool.rs @@ -8,14 +8,13 @@ use crate::{ use acp_thread::Diff; use agent_client_protocol::{self as acp, ToolCallLocation, ToolCallUpdateFields}; use anyhow::{Context as _, Result}; -use cloud_llm_client::CompletionIntent; use collections::HashSet; use futures::{FutureExt as _, StreamExt as _}; use gpui::{App, AppContext, AsyncApp, Entity, Task, WeakEntity}; use indoc::formatdoc; use language::language_settings::{self, FormatOnSave}; use language::{LanguageRegistry, ToPoint}; -use language_model::LanguageModelToolResultContent; +use language_model::{CompletionIntent, LanguageModelToolResultContent}; use project::lsp_store::{FormatTrigger, LspFormatTarget}; use project::{Project, ProjectPath}; use schemars::JsonSchema; @@ -419,17 +418,6 @@ impl AgentTool for EditFileTool { EditAgentOutputEvent::AmbiguousEditRange(ranges) => ambiguous_ranges = ranges, EditAgentOutputEvent::ResolvingEditRange(range) => { diff.update(cx, |card, cx| card.reveal_range(range.clone(), cx)); - // if !emitted_location { - // let line = buffer.update(cx, |buffer, _cx| { - // range.start.to_point(&buffer.snapshot()).row - // }).ok(); - // if let Some(abs_path) = abs_path.clone() { - // event_stream.update_fields(ToolCallUpdateFields { - // locations: Some(vec![ToolCallLocation { path: abs_path, line }]), - // ..Default::default() - // }); - // } - // } } } } @@ -437,11 +425,7 @@ impl AgentTool for EditFileTool { output.await?; let format_on_save_enabled = buffer.read_with(cx, |buffer, cx| { - let settings = language_settings::language_settings( - buffer.language().map(|l| l.name()), - buffer.file(), - cx, - ); + let settings = language_settings::LanguageSettings::for_buffer(buffer, cx); settings.format_on_save != FormatOnSave::Off }); @@ -1374,7 +1358,10 @@ mod tests { event .response - .send(acp::PermissionOptionId::new("allow").into()) + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) .unwrap(); authorize_task.await.unwrap(); } diff --git a/crates/agent/src/tools/evals.rs b/crates/agent/src/tools/evals.rs new file mode 100644 index 0000000000000000000000000000000000000000..13b8413de6455c9e5b4f719ba079a136ac857b9d --- /dev/null +++ b/crates/agent/src/tools/evals.rs @@ -0,0 +1,2 @@ +#[cfg(all(test, feature = "unit-eval"))] +mod streaming_edit_file; diff --git a/crates/agent/src/tools/evals/fixtures/add_overwrite_test/before.rs b/crates/agent/src/tools/evals/fixtures/add_overwrite_test/before.rs new file mode 100644 index 0000000000000000000000000000000000000000..0d2a0be1fb889a74d0251e1493e6988aaded068e --- /dev/null +++ b/crates/agent/src/tools/evals/fixtures/add_overwrite_test/before.rs @@ -0,0 +1,1572 @@ +use anyhow::{Context as _, Result}; +use buffer_diff::BufferDiff; +use collections::BTreeMap; +use futures::{StreamExt, channel::mpsc}; +use gpui::{App, AppContext, AsyncApp, Context, Entity, Subscription, Task, WeakEntity}; +use language::{Anchor, Buffer, BufferEvent, DiskState, Point, ToPoint}; +use project::{Project, ProjectItem, lsp_store::OpenLspBufferHandle}; +use std::{cmp, ops::Range, sync::Arc}; +use text::{Edit, Patch, Rope}; +use util::RangeExt; + +/// Tracks actions performed by tools in a thread +pub struct ActionLog { + /// Buffers that we want to notify the model about when they change. + tracked_buffers: BTreeMap, TrackedBuffer>, + /// Has the model edited a file since it last checked diagnostics? + edited_since_project_diagnostics_check: bool, + /// The project this action log is associated with + project: Entity, +} + +impl ActionLog { + /// Creates a new, empty action log associated with the given project. + pub fn new(project: Entity) -> Self { + Self { + tracked_buffers: BTreeMap::default(), + edited_since_project_diagnostics_check: false, + project, + } + } + + pub fn project(&self) -> &Entity { + &self.project + } + + /// Notifies a diagnostics check + pub fn checked_project_diagnostics(&mut self) { + self.edited_since_project_diagnostics_check = false; + } + + /// Returns true if any files have been edited since the last project diagnostics check + pub fn has_edited_files_since_project_diagnostics_check(&self) -> bool { + self.edited_since_project_diagnostics_check + } + + fn track_buffer_internal( + &mut self, + buffer: Entity, + is_created: bool, + cx: &mut Context, + ) -> &mut TrackedBuffer { + let tracked_buffer = self + .tracked_buffers + .entry(buffer.clone()) + .or_insert_with(|| { + let open_lsp_handle = self.project.update(cx, |project, cx| { + project.register_buffer_with_language_servers(&buffer, cx) + }); + + let text_snapshot = buffer.read(cx).text_snapshot(); + let diff = cx.new(|cx| BufferDiff::new(&text_snapshot, cx)); + let (diff_update_tx, diff_update_rx) = mpsc::unbounded(); + let base_text; + let status; + let unreviewed_changes; + if is_created { + base_text = Rope::default(); + status = TrackedBufferStatus::Created; + unreviewed_changes = Patch::new(vec![Edit { + old: 0..1, + new: 0..text_snapshot.max_point().row + 1, + }]) + } else { + base_text = buffer.read(cx).as_rope().clone(); + status = TrackedBufferStatus::Modified; + unreviewed_changes = Patch::default(); + } + TrackedBuffer { + buffer: buffer.clone(), + base_text, + unreviewed_changes, + snapshot: text_snapshot.clone(), + status, + version: buffer.read(cx).version(), + diff, + diff_update: diff_update_tx, + _open_lsp_handle: open_lsp_handle, + _maintain_diff: cx.spawn({ + let buffer = buffer.clone(); + async move |this, cx| { + Self::maintain_diff(this, buffer, diff_update_rx, cx) + .await + .ok(); + } + }), + _subscription: cx.subscribe(&buffer, Self::handle_buffer_event), + } + }); + tracked_buffer.version = buffer.read(cx).version(); + tracked_buffer + } + + fn handle_buffer_event( + &mut self, + buffer: Entity, + event: &BufferEvent, + cx: &mut Context, + ) { + match event { + BufferEvent::Edited { .. } => self.handle_buffer_edited(buffer, cx), + BufferEvent::FileHandleChanged => { + self.handle_buffer_file_changed(buffer, cx); + } + _ => {} + }; + } + + fn handle_buffer_edited(&mut self, buffer: Entity, cx: &mut Context) { + let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) else { + return; + }; + tracked_buffer.schedule_diff_update(ChangeAuthor::User, cx); + } + + fn handle_buffer_file_changed(&mut self, buffer: Entity, cx: &mut Context) { + let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) else { + return; + }; + + match tracked_buffer.status { + TrackedBufferStatus::Created | TrackedBufferStatus::Modified => { + if buffer + .read(cx) + .file() + .map_or(false, |file| file.disk_state() == DiskState::Deleted) + { + // If the buffer had been edited by a tool, but it got + // deleted externally, we want to stop tracking it. + self.tracked_buffers.remove(&buffer); + } + cx.notify(); + } + TrackedBufferStatus::Deleted => { + if buffer + .read(cx) + .file() + .map_or(false, |file| file.disk_state() != DiskState::Deleted) + { + // If the buffer had been deleted by a tool, but it got + // resurrected externally, we want to clear the changes we + // were tracking and reset the buffer's state. + self.tracked_buffers.remove(&buffer); + self.track_buffer_internal(buffer, false, cx); + } + cx.notify(); + } + } + } + + async fn maintain_diff( + this: WeakEntity, + buffer: Entity, + mut diff_update: mpsc::UnboundedReceiver<(ChangeAuthor, text::BufferSnapshot)>, + cx: &mut AsyncApp, + ) -> Result<()> { + while let Some((author, buffer_snapshot)) = diff_update.next().await { + let (rebase, diff, language, language_registry) = + this.read_with(cx, |this, cx| { + let tracked_buffer = this + .tracked_buffers + .get(&buffer) + .context("buffer not tracked")?; + + let rebase = cx.background_spawn({ + let mut base_text = tracked_buffer.base_text.clone(); + let old_snapshot = tracked_buffer.snapshot.clone(); + let new_snapshot = buffer_snapshot.clone(); + let unreviewed_changes = tracked_buffer.unreviewed_changes.clone(); + async move { + let edits = diff_snapshots(&old_snapshot, &new_snapshot); + if let ChangeAuthor::User = author { + apply_non_conflicting_edits( + &unreviewed_changes, + edits, + &mut base_text, + new_snapshot.as_rope(), + ); + } + (Arc::new(base_text.to_string()), base_text) + } + }); + + anyhow::Ok(( + rebase, + tracked_buffer.diff.clone(), + tracked_buffer.buffer.read(cx).language().cloned(), + tracked_buffer.buffer.read(cx).language_registry(), + )) + })??; + + let (new_base_text, new_base_text_rope) = rebase.await; + let diff_snapshot = BufferDiff::update_diff( + diff.clone(), + buffer_snapshot.clone(), + Some(new_base_text), + true, + false, + language, + language_registry, + cx, + ) + .await; + + let mut unreviewed_changes = Patch::default(); + if let Ok(diff_snapshot) = diff_snapshot { + unreviewed_changes = cx + .background_spawn({ + let diff_snapshot = diff_snapshot.clone(); + let buffer_snapshot = buffer_snapshot.clone(); + let new_base_text_rope = new_base_text_rope.clone(); + async move { + let mut unreviewed_changes = Patch::default(); + for hunk in diff_snapshot.hunks_intersecting_range( + Anchor::MIN..Anchor::MAX, + &buffer_snapshot, + ) { + let old_range = new_base_text_rope + .offset_to_point(hunk.diff_base_byte_range.start) + ..new_base_text_rope + .offset_to_point(hunk.diff_base_byte_range.end); + let new_range = hunk.range.start..hunk.range.end; + unreviewed_changes.push(point_to_row_edit( + Edit { + old: old_range, + new: new_range, + }, + &new_base_text_rope, + &buffer_snapshot.as_rope(), + )); + } + unreviewed_changes + } + }) + .await; + + diff.update(cx, |diff, cx| { + diff.set_snapshot(diff_snapshot, &buffer_snapshot, cx) + })?; + } + this.update(cx, |this, cx| { + let tracked_buffer = this + .tracked_buffers + .get_mut(&buffer) + .context("buffer not tracked")?; + tracked_buffer.base_text = new_base_text_rope; + tracked_buffer.snapshot = buffer_snapshot; + tracked_buffer.unreviewed_changes = unreviewed_changes; + cx.notify(); + anyhow::Ok(()) + })??; + } + + Ok(()) + } + + /// Track a buffer as read, so we can notify the model about user edits. + pub fn buffer_read(&mut self, buffer: Entity, cx: &mut Context) { + self.track_buffer_internal(buffer, false, cx); + } + + /// Mark a buffer as edited, so we can refresh it in the context + pub fn buffer_created(&mut self, buffer: Entity, cx: &mut Context) { + self.edited_since_project_diagnostics_check = true; + self.tracked_buffers.remove(&buffer); + self.track_buffer_internal(buffer.clone(), true, cx); + } + + /// Mark a buffer as edited, so we can refresh it in the context + pub fn buffer_edited(&mut self, buffer: Entity, cx: &mut Context) { + self.edited_since_project_diagnostics_check = true; + + let tracked_buffer = self.track_buffer_internal(buffer.clone(), false, cx); + if let TrackedBufferStatus::Deleted = tracked_buffer.status { + tracked_buffer.status = TrackedBufferStatus::Modified; + } + tracked_buffer.schedule_diff_update(ChangeAuthor::Agent, cx); + } + + pub fn will_delete_buffer(&mut self, buffer: Entity, cx: &mut Context) { + let tracked_buffer = self.track_buffer_internal(buffer.clone(), false, cx); + match tracked_buffer.status { + TrackedBufferStatus::Created => { + self.tracked_buffers.remove(&buffer); + cx.notify(); + } + TrackedBufferStatus::Modified => { + buffer.update(cx, |buffer, cx| buffer.set_text("", cx)); + tracked_buffer.status = TrackedBufferStatus::Deleted; + tracked_buffer.schedule_diff_update(ChangeAuthor::Agent, cx); + } + TrackedBufferStatus::Deleted => {} + } + cx.notify(); + } + + pub fn keep_edits_in_range( + &mut self, + buffer: Entity, + buffer_range: Range, + cx: &mut Context, + ) { + let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) else { + return; + }; + + match tracked_buffer.status { + TrackedBufferStatus::Deleted => { + self.tracked_buffers.remove(&buffer); + cx.notify(); + } + _ => { + let buffer = buffer.read(cx); + let buffer_range = + buffer_range.start.to_point(buffer)..buffer_range.end.to_point(buffer); + let mut delta = 0i32; + + tracked_buffer.unreviewed_changes.retain_mut(|edit| { + edit.old.start = (edit.old.start as i32 + delta) as u32; + edit.old.end = (edit.old.end as i32 + delta) as u32; + + if buffer_range.end.row < edit.new.start + || buffer_range.start.row > edit.new.end + { + true + } else { + let old_range = tracked_buffer + .base_text + .point_to_offset(Point::new(edit.old.start, 0)) + ..tracked_buffer.base_text.point_to_offset(cmp::min( + Point::new(edit.old.end, 0), + tracked_buffer.base_text.max_point(), + )); + let new_range = tracked_buffer + .snapshot + .point_to_offset(Point::new(edit.new.start, 0)) + ..tracked_buffer.snapshot.point_to_offset(cmp::min( + Point::new(edit.new.end, 0), + tracked_buffer.snapshot.max_point(), + )); + tracked_buffer.base_text.replace( + old_range, + &tracked_buffer + .snapshot + .text_for_range(new_range) + .collect::(), + ); + delta += edit.new_len() as i32 - edit.old_len() as i32; + false + } + }); + tracked_buffer.schedule_diff_update(ChangeAuthor::User, cx); + } + } + } + + pub fn reject_edits_in_ranges( + &mut self, + buffer: Entity, + buffer_ranges: Vec>, + cx: &mut Context, + ) -> Task> { + let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) else { + return Task::ready(Ok(())); + }; + + match tracked_buffer.status { + TrackedBufferStatus::Created => { + let delete = buffer + .read(cx) + .entry_id(cx) + .and_then(|entry_id| { + self.project + .update(cx, |project, cx| project.delete_entry(entry_id, false, cx)) + }) + .unwrap_or(Task::ready(Ok(()))); + self.tracked_buffers.remove(&buffer); + cx.notify(); + delete + } + TrackedBufferStatus::Deleted => { + buffer.update(cx, |buffer, cx| { + buffer.set_text(tracked_buffer.base_text.to_string(), cx) + }); + let save = self + .project + .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)); + + // Clear all tracked changes for this buffer and start over as if we just read it. + self.tracked_buffers.remove(&buffer); + self.buffer_read(buffer.clone(), cx); + cx.notify(); + save + } + TrackedBufferStatus::Modified => { + buffer.update(cx, |buffer, cx| { + let mut buffer_row_ranges = buffer_ranges + .into_iter() + .map(|range| { + range.start.to_point(buffer).row..range.end.to_point(buffer).row + }) + .peekable(); + + let mut edits_to_revert = Vec::new(); + for edit in tracked_buffer.unreviewed_changes.edits() { + let new_range = tracked_buffer + .snapshot + .anchor_before(Point::new(edit.new.start, 0)) + ..tracked_buffer.snapshot.anchor_after(cmp::min( + Point::new(edit.new.end, 0), + tracked_buffer.snapshot.max_point(), + )); + let new_row_range = new_range.start.to_point(buffer).row + ..new_range.end.to_point(buffer).row; + + let mut revert = false; + while let Some(buffer_row_range) = buffer_row_ranges.peek() { + if buffer_row_range.end < new_row_range.start { + buffer_row_ranges.next(); + } else if buffer_row_range.start > new_row_range.end { + break; + } else { + revert = true; + break; + } + } + + if revert { + let old_range = tracked_buffer + .base_text + .point_to_offset(Point::new(edit.old.start, 0)) + ..tracked_buffer.base_text.point_to_offset(cmp::min( + Point::new(edit.old.end, 0), + tracked_buffer.base_text.max_point(), + )); + let old_text = tracked_buffer + .base_text + .chunks_in_range(old_range) + .collect::(); + edits_to_revert.push((new_range, old_text)); + } + } + + buffer.edit(edits_to_revert, None, cx); + }); + self.project + .update(cx, |project, cx| project.save_buffer(buffer, cx)) + } + } + } + + pub fn keep_all_edits(&mut self, cx: &mut Context) { + self.tracked_buffers + .retain(|_buffer, tracked_buffer| match tracked_buffer.status { + TrackedBufferStatus::Deleted => false, + _ => { + tracked_buffer.unreviewed_changes.clear(); + tracked_buffer.base_text = tracked_buffer.snapshot.as_rope().clone(); + tracked_buffer.schedule_diff_update(ChangeAuthor::User, cx); + true + } + }); + cx.notify(); + } + + /// Returns the set of buffers that contain changes that haven't been reviewed by the user. + pub fn changed_buffers(&self, cx: &App) -> BTreeMap, Entity> { + self.tracked_buffers + .iter() + .filter(|(_, tracked)| tracked.has_changes(cx)) + .map(|(buffer, tracked)| (buffer.clone(), tracked.diff.clone())) + .collect() + } + + /// Iterate over buffers changed since last read or edited by the model + pub fn stale_buffers<'a>(&'a self, cx: &'a App) -> impl Iterator> { + self.tracked_buffers + .iter() + .filter(|(buffer, tracked)| { + let buffer = buffer.read(cx); + + tracked.version != buffer.version + && buffer + .file() + .map_or(false, |file| file.disk_state() != DiskState::Deleted) + }) + .map(|(buffer, _)| buffer) + } +} + +fn apply_non_conflicting_edits( + patch: &Patch, + edits: Vec>, + old_text: &mut Rope, + new_text: &Rope, +) { + let mut old_edits = patch.edits().iter().cloned().peekable(); + let mut new_edits = edits.into_iter().peekable(); + let mut applied_delta = 0i32; + let mut rebased_delta = 0i32; + + while let Some(mut new_edit) = new_edits.next() { + let mut conflict = false; + + // Push all the old edits that are before this new edit or that intersect with it. + while let Some(old_edit) = old_edits.peek() { + if new_edit.old.end < old_edit.new.start + || (!old_edit.new.is_empty() && new_edit.old.end == old_edit.new.start) + { + break; + } else if new_edit.old.start > old_edit.new.end + || (!old_edit.new.is_empty() && new_edit.old.start == old_edit.new.end) + { + let old_edit = old_edits.next().unwrap(); + rebased_delta += old_edit.new_len() as i32 - old_edit.old_len() as i32; + } else { + conflict = true; + if new_edits + .peek() + .map_or(false, |next_edit| next_edit.old.overlaps(&old_edit.new)) + { + new_edit = new_edits.next().unwrap(); + } else { + let old_edit = old_edits.next().unwrap(); + rebased_delta += old_edit.new_len() as i32 - old_edit.old_len() as i32; + } + } + } + + if !conflict { + // This edit doesn't intersect with any old edit, so we can apply it to the old text. + new_edit.old.start = (new_edit.old.start as i32 + applied_delta - rebased_delta) as u32; + new_edit.old.end = (new_edit.old.end as i32 + applied_delta - rebased_delta) as u32; + let old_bytes = old_text.point_to_offset(Point::new(new_edit.old.start, 0)) + ..old_text.point_to_offset(cmp::min( + Point::new(new_edit.old.end, 0), + old_text.max_point(), + )); + let new_bytes = new_text.point_to_offset(Point::new(new_edit.new.start, 0)) + ..new_text.point_to_offset(cmp::min( + Point::new(new_edit.new.end, 0), + new_text.max_point(), + )); + + old_text.replace( + old_bytes, + &new_text.chunks_in_range(new_bytes).collect::(), + ); + applied_delta += new_edit.new_len() as i32 - new_edit.old_len() as i32; + } + } +} + +fn diff_snapshots( + old_snapshot: &text::BufferSnapshot, + new_snapshot: &text::BufferSnapshot, +) -> Vec> { + let mut edits = new_snapshot + .edits_since::(&old_snapshot.version) + .map(|edit| point_to_row_edit(edit, old_snapshot.as_rope(), new_snapshot.as_rope())) + .peekable(); + let mut row_edits = Vec::new(); + while let Some(mut edit) = edits.next() { + while let Some(next_edit) = edits.peek() { + if edit.old.end >= next_edit.old.start { + edit.old.end = next_edit.old.end; + edit.new.end = next_edit.new.end; + edits.next(); + } else { + break; + } + } + row_edits.push(edit); + } + row_edits +} + +fn point_to_row_edit(edit: Edit, old_text: &Rope, new_text: &Rope) -> Edit { + if edit.old.start.column == old_text.line_len(edit.old.start.row) + && new_text + .chars_at(new_text.point_to_offset(edit.new.start)) + .next() + == Some('\n') + && edit.old.start != old_text.max_point() + { + Edit { + old: edit.old.start.row + 1..edit.old.end.row + 1, + new: edit.new.start.row + 1..edit.new.end.row + 1, + } + } else if edit.old.start.column == 0 + && edit.old.end.column == 0 + && edit.new.end.column == 0 + && edit.old.end != old_text.max_point() + { + Edit { + old: edit.old.start.row..edit.old.end.row, + new: edit.new.start.row..edit.new.end.row, + } + } else { + Edit { + old: edit.old.start.row..edit.old.end.row + 1, + new: edit.new.start.row..edit.new.end.row + 1, + } + } +} + +#[derive(Copy, Clone, Debug)] +enum ChangeAuthor { + User, + Agent, +} + +#[derive(Copy, Clone, Eq, PartialEq)] +enum TrackedBufferStatus { + Created, + Modified, + Deleted, +} + +struct TrackedBuffer { + buffer: Entity, + base_text: Rope, + unreviewed_changes: Patch, + status: TrackedBufferStatus, + version: clock::Global, + diff: Entity, + snapshot: text::BufferSnapshot, + diff_update: mpsc::UnboundedSender<(ChangeAuthor, text::BufferSnapshot)>, + _open_lsp_handle: OpenLspBufferHandle, + _maintain_diff: Task<()>, + _subscription: Subscription, +} + +impl TrackedBuffer { + fn has_changes(&self, cx: &App) -> bool { + self.diff + .read(cx) + .hunks(&self.buffer.read(cx), cx) + .next() + .is_some() + } + + fn schedule_diff_update(&self, author: ChangeAuthor, cx: &App) { + self.diff_update + .unbounded_send((author, self.buffer.read(cx).text_snapshot())) + .ok(); + } +} + +pub struct ChangedBuffer { + pub diff: Entity, +} + +#[cfg(test)] +mod tests { + use std::env; + + use super::*; + use buffer_diff::DiffHunkStatusKind; + use gpui::TestAppContext; + use language::Point; + use project::{FakeFs, Fs, Project, RemoveOptions}; + use rand::prelude::*; + use serde_json::json; + use settings::SettingsStore; + use util::{RandomCharIter, path}; + + #[ctor::ctor] + fn init_logger() { + zlog::init_test(); + } + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + language::init(cx); + Project::init_settings(cx); + }); + } + + #[gpui::test(iterations = 10)] + async fn test_keep_edits(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({"file": "abc\ndef\nghi\njkl\nmno"})) + .await; + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let file_path = project + .read_with(cx, |project, cx| project.find_project_path("dir/file", cx)) + .unwrap(); + let buffer = project + .update(cx, |project, cx| project.open_buffer(file_path, cx)) + .await + .unwrap(); + + cx.update(|cx| { + action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx)); + buffer.update(cx, |buffer, cx| { + buffer + .edit([(Point::new(1, 1)..Point::new(1, 2), "E")], None, cx) + .unwrap() + }); + buffer.update(cx, |buffer, cx| { + buffer + .edit([(Point::new(4, 2)..Point::new(4, 3), "O")], None, cx) + .unwrap() + }); + action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); + }); + cx.run_until_parked(); + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.text()), + "abc\ndEf\nghi\njkl\nmnO" + ); + assert_eq!( + unreviewed_hunks(&action_log, cx), + vec![( + buffer.clone(), + vec![ + HunkStatus { + range: Point::new(1, 0)..Point::new(2, 0), + diff_status: DiffHunkStatusKind::Modified, + old_text: "def\n".into(), + }, + HunkStatus { + range: Point::new(4, 0)..Point::new(4, 3), + diff_status: DiffHunkStatusKind::Modified, + old_text: "mno".into(), + } + ], + )] + ); + + action_log.update(cx, |log, cx| { + log.keep_edits_in_range(buffer.clone(), Point::new(3, 0)..Point::new(4, 3), cx) + }); + cx.run_until_parked(); + assert_eq!( + unreviewed_hunks(&action_log, cx), + vec![( + buffer.clone(), + vec![HunkStatus { + range: Point::new(1, 0)..Point::new(2, 0), + diff_status: DiffHunkStatusKind::Modified, + old_text: "def\n".into(), + }], + )] + ); + + action_log.update(cx, |log, cx| { + log.keep_edits_in_range(buffer.clone(), Point::new(0, 0)..Point::new(4, 3), cx) + }); + cx.run_until_parked(); + assert_eq!(unreviewed_hunks(&action_log, cx), vec![]); + } + + #[gpui::test(iterations = 10)] + async fn test_deletions(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/dir"), + json!({"file": "abc\ndef\nghi\njkl\nmno\npqr"}), + ) + .await; + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let file_path = project + .read_with(cx, |project, cx| project.find_project_path("dir/file", cx)) + .unwrap(); + let buffer = project + .update(cx, |project, cx| project.open_buffer(file_path, cx)) + .await + .unwrap(); + + cx.update(|cx| { + action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx)); + buffer.update(cx, |buffer, cx| { + buffer + .edit([(Point::new(1, 0)..Point::new(2, 0), "")], None, cx) + .unwrap(); + buffer.finalize_last_transaction(); + }); + buffer.update(cx, |buffer, cx| { + buffer + .edit([(Point::new(3, 0)..Point::new(4, 0), "")], None, cx) + .unwrap(); + buffer.finalize_last_transaction(); + }); + action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); + }); + cx.run_until_parked(); + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.text()), + "abc\nghi\njkl\npqr" + ); + assert_eq!( + unreviewed_hunks(&action_log, cx), + vec![( + buffer.clone(), + vec![ + HunkStatus { + range: Point::new(1, 0)..Point::new(1, 0), + diff_status: DiffHunkStatusKind::Deleted, + old_text: "def\n".into(), + }, + HunkStatus { + range: Point::new(3, 0)..Point::new(3, 0), + diff_status: DiffHunkStatusKind::Deleted, + old_text: "mno\n".into(), + } + ], + )] + ); + + buffer.update(cx, |buffer, cx| buffer.undo(cx)); + cx.run_until_parked(); + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.text()), + "abc\nghi\njkl\nmno\npqr" + ); + assert_eq!( + unreviewed_hunks(&action_log, cx), + vec![( + buffer.clone(), + vec![HunkStatus { + range: Point::new(1, 0)..Point::new(1, 0), + diff_status: DiffHunkStatusKind::Deleted, + old_text: "def\n".into(), + }], + )] + ); + + action_log.update(cx, |log, cx| { + log.keep_edits_in_range(buffer.clone(), Point::new(1, 0)..Point::new(1, 0), cx) + }); + cx.run_until_parked(); + assert_eq!(unreviewed_hunks(&action_log, cx), vec![]); + } + + #[gpui::test(iterations = 10)] + async fn test_overlapping_user_edits(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({"file": "abc\ndef\nghi\njkl\nmno"})) + .await; + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let file_path = project + .read_with(cx, |project, cx| project.find_project_path("dir/file", cx)) + .unwrap(); + let buffer = project + .update(cx, |project, cx| project.open_buffer(file_path, cx)) + .await + .unwrap(); + + cx.update(|cx| { + action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx)); + buffer.update(cx, |buffer, cx| { + buffer + .edit([(Point::new(1, 2)..Point::new(2, 3), "F\nGHI")], None, cx) + .unwrap() + }); + action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); + }); + cx.run_until_parked(); + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.text()), + "abc\ndeF\nGHI\njkl\nmno" + ); + assert_eq!( + unreviewed_hunks(&action_log, cx), + vec![( + buffer.clone(), + vec![HunkStatus { + range: Point::new(1, 0)..Point::new(3, 0), + diff_status: DiffHunkStatusKind::Modified, + old_text: "def\nghi\n".into(), + }], + )] + ); + + buffer.update(cx, |buffer, cx| { + buffer.edit( + [ + (Point::new(0, 2)..Point::new(0, 2), "X"), + (Point::new(3, 0)..Point::new(3, 0), "Y"), + ], + None, + cx, + ) + }); + cx.run_until_parked(); + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.text()), + "abXc\ndeF\nGHI\nYjkl\nmno" + ); + assert_eq!( + unreviewed_hunks(&action_log, cx), + vec![( + buffer.clone(), + vec![HunkStatus { + range: Point::new(1, 0)..Point::new(3, 0), + diff_status: DiffHunkStatusKind::Modified, + old_text: "def\nghi\n".into(), + }], + )] + ); + + buffer.update(cx, |buffer, cx| { + buffer.edit([(Point::new(1, 1)..Point::new(1, 1), "Z")], None, cx) + }); + cx.run_until_parked(); + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.text()), + "abXc\ndZeF\nGHI\nYjkl\nmno" + ); + assert_eq!( + unreviewed_hunks(&action_log, cx), + vec![( + buffer.clone(), + vec![HunkStatus { + range: Point::new(1, 0)..Point::new(3, 0), + diff_status: DiffHunkStatusKind::Modified, + old_text: "def\nghi\n".into(), + }], + )] + ); + + action_log.update(cx, |log, cx| { + log.keep_edits_in_range(buffer.clone(), Point::new(0, 0)..Point::new(1, 0), cx) + }); + cx.run_until_parked(); + assert_eq!(unreviewed_hunks(&action_log, cx), vec![]); + } + + #[gpui::test(iterations = 10)] + async fn test_creating_files(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({})).await; + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let file_path = project + .read_with(cx, |project, cx| project.find_project_path("dir/file1", cx)) + .unwrap(); + + let buffer = project + .update(cx, |project, cx| project.open_buffer(file_path, cx)) + .await + .unwrap(); + cx.update(|cx| { + action_log.update(cx, |log, cx| log.buffer_created(buffer.clone(), cx)); + buffer.update(cx, |buffer, cx| buffer.set_text("lorem", cx)); + action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); + }); + project + .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)) + .await + .unwrap(); + cx.run_until_parked(); + assert_eq!( + unreviewed_hunks(&action_log, cx), + vec![( + buffer.clone(), + vec![HunkStatus { + range: Point::new(0, 0)..Point::new(0, 5), + diff_status: DiffHunkStatusKind::Added, + old_text: "".into(), + }], + )] + ); + + buffer.update(cx, |buffer, cx| buffer.edit([(0..0, "X")], None, cx)); + cx.run_until_parked(); + assert_eq!( + unreviewed_hunks(&action_log, cx), + vec![( + buffer.clone(), + vec![HunkStatus { + range: Point::new(0, 0)..Point::new(0, 6), + diff_status: DiffHunkStatusKind::Added, + old_text: "".into(), + }], + )] + ); + + action_log.update(cx, |log, cx| { + log.keep_edits_in_range(buffer.clone(), 0..5, cx) + }); + cx.run_until_parked(); + assert_eq!(unreviewed_hunks(&action_log, cx), vec![]); + } + + #[gpui::test(iterations = 10)] + async fn test_deleting_files(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/dir"), + json!({"file1": "lorem\n", "file2": "ipsum\n"}), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let file1_path = project + .read_with(cx, |project, cx| project.find_project_path("dir/file1", cx)) + .unwrap(); + let file2_path = project + .read_with(cx, |project, cx| project.find_project_path("dir/file2", cx)) + .unwrap(); + + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let buffer1 = project + .update(cx, |project, cx| { + project.open_buffer(file1_path.clone(), cx) + }) + .await + .unwrap(); + let buffer2 = project + .update(cx, |project, cx| { + project.open_buffer(file2_path.clone(), cx) + }) + .await + .unwrap(); + + action_log.update(cx, |log, cx| log.will_delete_buffer(buffer1.clone(), cx)); + action_log.update(cx, |log, cx| log.will_delete_buffer(buffer2.clone(), cx)); + project + .update(cx, |project, cx| { + project.delete_file(file1_path.clone(), false, cx) + }) + .unwrap() + .await + .unwrap(); + project + .update(cx, |project, cx| { + project.delete_file(file2_path.clone(), false, cx) + }) + .unwrap() + .await + .unwrap(); + cx.run_until_parked(); + assert_eq!( + unreviewed_hunks(&action_log, cx), + vec![ + ( + buffer1.clone(), + vec![HunkStatus { + range: Point::new(0, 0)..Point::new(0, 0), + diff_status: DiffHunkStatusKind::Deleted, + old_text: "lorem\n".into(), + }] + ), + ( + buffer2.clone(), + vec![HunkStatus { + range: Point::new(0, 0)..Point::new(0, 0), + diff_status: DiffHunkStatusKind::Deleted, + old_text: "ipsum\n".into(), + }], + ) + ] + ); + + // Simulate file1 being recreated externally. + fs.insert_file(path!("/dir/file1"), "LOREM".as_bytes().to_vec()) + .await; + + // Simulate file2 being recreated by a tool. + let buffer2 = project + .update(cx, |project, cx| project.open_buffer(file2_path, cx)) + .await + .unwrap(); + action_log.update(cx, |log, cx| log.buffer_read(buffer2.clone(), cx)); + buffer2.update(cx, |buffer, cx| buffer.set_text("IPSUM", cx)); + action_log.update(cx, |log, cx| log.buffer_edited(buffer2.clone(), cx)); + project + .update(cx, |project, cx| project.save_buffer(buffer2.clone(), cx)) + .await + .unwrap(); + + cx.run_until_parked(); + assert_eq!( + unreviewed_hunks(&action_log, cx), + vec![( + buffer2.clone(), + vec![HunkStatus { + range: Point::new(0, 0)..Point::new(0, 5), + diff_status: DiffHunkStatusKind::Modified, + old_text: "ipsum\n".into(), + }], + )] + ); + + // Simulate file2 being deleted externally. + fs.remove_file(path!("/dir/file2").as_ref(), RemoveOptions::default()) + .await + .unwrap(); + cx.run_until_parked(); + assert_eq!(unreviewed_hunks(&action_log, cx), vec![]); + } + + #[gpui::test(iterations = 10)] + async fn test_reject_edits(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({"file": "abc\ndef\nghi\njkl\nmno"})) + .await; + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let file_path = project + .read_with(cx, |project, cx| project.find_project_path("dir/file", cx)) + .unwrap(); + let buffer = project + .update(cx, |project, cx| project.open_buffer(file_path, cx)) + .await + .unwrap(); + + cx.update(|cx| { + action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx)); + buffer.update(cx, |buffer, cx| { + buffer + .edit([(Point::new(1, 1)..Point::new(1, 2), "E\nXYZ")], None, cx) + .unwrap() + }); + buffer.update(cx, |buffer, cx| { + buffer + .edit([(Point::new(5, 2)..Point::new(5, 3), "O")], None, cx) + .unwrap() + }); + action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); + }); + cx.run_until_parked(); + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.text()), + "abc\ndE\nXYZf\nghi\njkl\nmnO" + ); + assert_eq!( + unreviewed_hunks(&action_log, cx), + vec![( + buffer.clone(), + vec![ + HunkStatus { + range: Point::new(1, 0)..Point::new(3, 0), + diff_status: DiffHunkStatusKind::Modified, + old_text: "def\n".into(), + }, + HunkStatus { + range: Point::new(5, 0)..Point::new(5, 3), + diff_status: DiffHunkStatusKind::Modified, + old_text: "mno".into(), + } + ], + )] + ); + + // If the rejected range doesn't overlap with any hunk, we ignore it. + action_log + .update(cx, |log, cx| { + log.reject_edits_in_ranges( + buffer.clone(), + vec![Point::new(4, 0)..Point::new(4, 0)], + cx, + ) + }) + .await + .unwrap(); + cx.run_until_parked(); + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.text()), + "abc\ndE\nXYZf\nghi\njkl\nmnO" + ); + assert_eq!( + unreviewed_hunks(&action_log, cx), + vec![( + buffer.clone(), + vec![ + HunkStatus { + range: Point::new(1, 0)..Point::new(3, 0), + diff_status: DiffHunkStatusKind::Modified, + old_text: "def\n".into(), + }, + HunkStatus { + range: Point::new(5, 0)..Point::new(5, 3), + diff_status: DiffHunkStatusKind::Modified, + old_text: "mno".into(), + } + ], + )] + ); + + action_log + .update(cx, |log, cx| { + log.reject_edits_in_ranges( + buffer.clone(), + vec![Point::new(0, 0)..Point::new(1, 0)], + cx, + ) + }) + .await + .unwrap(); + cx.run_until_parked(); + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.text()), + "abc\ndef\nghi\njkl\nmnO" + ); + assert_eq!( + unreviewed_hunks(&action_log, cx), + vec![( + buffer.clone(), + vec![HunkStatus { + range: Point::new(4, 0)..Point::new(4, 3), + diff_status: DiffHunkStatusKind::Modified, + old_text: "mno".into(), + }], + )] + ); + + action_log + .update(cx, |log, cx| { + log.reject_edits_in_ranges( + buffer.clone(), + vec![Point::new(4, 0)..Point::new(4, 0)], + cx, + ) + }) + .await + .unwrap(); + cx.run_until_parked(); + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.text()), + "abc\ndef\nghi\njkl\nmno" + ); + assert_eq!(unreviewed_hunks(&action_log, cx), vec![]); + } + + #[gpui::test(iterations = 10)] + async fn test_reject_multiple_edits(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({"file": "abc\ndef\nghi\njkl\nmno"})) + .await; + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let file_path = project + .read_with(cx, |project, cx| project.find_project_path("dir/file", cx)) + .unwrap(); + let buffer = project + .update(cx, |project, cx| project.open_buffer(file_path, cx)) + .await + .unwrap(); + + cx.update(|cx| { + action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx)); + buffer.update(cx, |buffer, cx| { + buffer + .edit([(Point::new(1, 1)..Point::new(1, 2), "E\nXYZ")], None, cx) + .unwrap() + }); + buffer.update(cx, |buffer, cx| { + buffer + .edit([(Point::new(5, 2)..Point::new(5, 3), "O")], None, cx) + .unwrap() + }); + action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); + }); + cx.run_until_parked(); + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.text()), + "abc\ndE\nXYZf\nghi\njkl\nmnO" + ); + assert_eq!( + unreviewed_hunks(&action_log, cx), + vec![( + buffer.clone(), + vec![ + HunkStatus { + range: Point::new(1, 0)..Point::new(3, 0), + diff_status: DiffHunkStatusKind::Modified, + old_text: "def\n".into(), + }, + HunkStatus { + range: Point::new(5, 0)..Point::new(5, 3), + diff_status: DiffHunkStatusKind::Modified, + old_text: "mno".into(), + } + ], + )] + ); + + action_log.update(cx, |log, cx| { + let range_1 = buffer.read(cx).anchor_before(Point::new(0, 0)) + ..buffer.read(cx).anchor_before(Point::new(1, 0)); + let range_2 = buffer.read(cx).anchor_before(Point::new(5, 0)) + ..buffer.read(cx).anchor_before(Point::new(5, 3)); + + log.reject_edits_in_ranges(buffer.clone(), vec![range_1, range_2], cx) + .detach(); + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.text()), + "abc\ndef\nghi\njkl\nmno" + ); + }); + cx.run_until_parked(); + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.text()), + "abc\ndef\nghi\njkl\nmno" + ); + assert_eq!(unreviewed_hunks(&action_log, cx), vec![]); + } + + #[gpui::test(iterations = 10)] + async fn test_reject_deleted_file(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({"file": "content"})) + .await; + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let file_path = project + .read_with(cx, |project, cx| project.find_project_path("dir/file", cx)) + .unwrap(); + let buffer = project + .update(cx, |project, cx| project.open_buffer(file_path.clone(), cx)) + .await + .unwrap(); + + cx.update(|cx| { + action_log.update(cx, |log, cx| log.will_delete_buffer(buffer.clone(), cx)); + }); + project + .update(cx, |project, cx| { + project.delete_file(file_path.clone(), false, cx) + }) + .unwrap() + .await + .unwrap(); + cx.run_until_parked(); + assert!(!fs.is_file(path!("/dir/file").as_ref()).await); + assert_eq!( + unreviewed_hunks(&action_log, cx), + vec![( + buffer.clone(), + vec![HunkStatus { + range: Point::new(0, 0)..Point::new(0, 0), + diff_status: DiffHunkStatusKind::Deleted, + old_text: "content".into(), + }] + )] + ); + + action_log + .update(cx, |log, cx| { + log.reject_edits_in_ranges( + buffer.clone(), + vec![Point::new(0, 0)..Point::new(0, 0)], + cx, + ) + }) + .await + .unwrap(); + cx.run_until_parked(); + assert_eq!(buffer.read_with(cx, |buffer, _| buffer.text()), "content"); + assert!(fs.is_file(path!("/dir/file").as_ref()).await); + assert_eq!(unreviewed_hunks(&action_log, cx), vec![]); + } + + #[gpui::test(iterations = 10)] + async fn test_reject_created_file(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let file_path = project + .read_with(cx, |project, cx| { + project.find_project_path("dir/new_file", cx) + }) + .unwrap(); + + let buffer = project + .update(cx, |project, cx| project.open_buffer(file_path, cx)) + .await + .unwrap(); + cx.update(|cx| { + action_log.update(cx, |log, cx| log.buffer_created(buffer.clone(), cx)); + buffer.update(cx, |buffer, cx| buffer.set_text("content", cx)); + action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); + }); + project + .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)) + .await + .unwrap(); + assert!(fs.is_file(path!("/dir/new_file").as_ref()).await); + cx.run_until_parked(); + assert_eq!( + unreviewed_hunks(&action_log, cx), + vec![( + buffer.clone(), + vec![HunkStatus { + range: Point::new(0, 0)..Point::new(0, 7), + diff_status: DiffHunkStatusKind::Added, + old_text: "".into(), + }], + )] + ); + + action_log + .update(cx, |log, cx| { + log.reject_edits_in_ranges( + buffer.clone(), + vec![Point::new(0, 0)..Point::new(0, 11)], + cx, + ) + }) + .await + .unwrap(); + cx.run_until_parked(); + assert!(!fs.is_file(path!("/dir/new_file").as_ref()).await); + assert_eq!(unreviewed_hunks(&action_log, cx), vec![]); + } + + #[gpui::test(iterations = 100)] + async fn test_random_diffs(mut rng: StdRng, cx: &mut TestAppContext) { + init_test(cx); + + let operations = env::var("OPERATIONS") + .map(|i| i.parse().expect("invalid `OPERATIONS` variable")) + .unwrap_or(20); + + let text = RandomCharIter::new(&mut rng).take(50).collect::(); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({"file": text})).await; + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let file_path = project + .read_with(cx, |project, cx| project.find_project_path("dir/file", cx)) + .unwrap(); + let buffer = project + .update(cx, |project, cx| project.open_buffer(file_path, cx)) + .await + .unwrap(); + + action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx)); + + for _ in 0..operations { + match rng.gen_range(0..100) { + 0..25 => { + action_log.update(cx, |log, cx| { + let range = buffer.read(cx).random_byte_range(0, &mut rng); + log::info!("keeping edits in range {:?}", range); + log.keep_edits_in_range(buffer.clone(), range, cx) + }); + } + 25..50 => { + action_log + .update(cx, |log, cx| { + let range = buffer.read(cx).random_byte_range(0, &mut rng); + log::info!("rejecting edits in range {:?}", range); + log.reject_edits_in_ranges(buffer.clone(), vec![range], cx) + }) + .await + .unwrap(); + } + _ => { + let is_agent_change = rng.gen_bool(0.5); + if is_agent_change { + log::info!("agent edit"); + } else { + log::info!("user edit"); + } + cx.update(|cx| { + buffer.update(cx, |buffer, cx| buffer.randomly_edit(&mut rng, 1, cx)); + if is_agent_change { + action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); + } + }); + } + } + + if rng.gen_bool(0.2) { + quiesce(&action_log, &buffer, cx); + } + } + + quiesce(&action_log, &buffer, cx); + + fn quiesce( + action_log: &Entity, + buffer: &Entity, + cx: &mut TestAppContext, + ) { + log::info!("quiescing..."); + cx.run_until_parked(); + action_log.update(cx, |log, cx| { + let tracked_buffer = log.tracked_buffers.get(&buffer).unwrap(); + let mut old_text = tracked_buffer.base_text.clone(); + let new_text = buffer.read(cx).as_rope(); + for edit in tracked_buffer.unreviewed_changes.edits() { + let old_start = old_text.point_to_offset(Point::new(edit.new.start, 0)); + let old_end = old_text.point_to_offset(cmp::min( + Point::new(edit.new.start + edit.old_len(), 0), + old_text.max_point(), + )); + old_text.replace( + old_start..old_end, + &new_text.slice_rows(edit.new.clone()).to_string(), + ); + } + pretty_assertions::assert_eq!(old_text.to_string(), new_text.to_string()); + }) + } + } + + #[derive(Debug, Clone, PartialEq, Eq)] + struct HunkStatus { + range: Range, + diff_status: DiffHunkStatusKind, + old_text: String, + } + + fn unreviewed_hunks( + action_log: &Entity, + cx: &TestAppContext, + ) -> Vec<(Entity, Vec)> { + cx.read(|cx| { + action_log + .read(cx) + .changed_buffers(cx) + .into_iter() + .map(|(buffer, diff)| { + let snapshot = buffer.read(cx).snapshot(); + ( + buffer, + diff.read(cx) + .hunks(&snapshot, cx) + .map(|hunk| HunkStatus { + diff_status: hunk.status().kind, + range: hunk.range, + old_text: diff + .read(cx) + .base_text() + .text_for_range(hunk.diff_base_byte_range) + .collect(), + }) + .collect(), + ) + }) + .collect() + }) + } +} diff --git a/crates/agent/src/tools/evals/fixtures/delete_run_git_blame/after.rs b/crates/agent/src/tools/evals/fixtures/delete_run_git_blame/after.rs new file mode 100644 index 0000000000000000000000000000000000000000..89277be4436bf000f4b061d8b89fef5f489f9fea --- /dev/null +++ b/crates/agent/src/tools/evals/fixtures/delete_run_git_blame/after.rs @@ -0,0 +1,328 @@ +use crate::commit::get_messages; +use crate::{GitRemote, Oid}; +use anyhow::{Context as _, Result, anyhow}; +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 time::OffsetDateTime; +use time::UtcOffset; +use time::macros::format_description; + +pub use git2 as libgit; + +#[derive(Debug, Clone, Default)] +pub struct Blame { + pub entries: Vec, + pub messages: HashMap, + pub remote_url: Option, +} + +#[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: &Path, + content: &Rope, + remote_url: Option, + ) -> Result { + let output = run_git_blame(git_binary, working_directory, path, content).await?; + let mut entries = parse_git_blame(&output)?; + entries.sort_unstable_by(|a, b| a.range.start.cmp(&b.range.start)); + + let mut unique_shas = HashSet::default(); + + for entry in entries.iter_mut() { + unique_shas.insert(entry.sha); + } + + let shas = unique_shas.into_iter().collect::>(); + let messages = get_messages(working_directory, &shas) + .await + .context("failed to get commit messages")?; + + Ok(Self { + entries, + messages, + remote_url, + }) + } +} + +const GIT_BLAME_NO_COMMIT_ERROR: &str = "fatal: no such ref: HEAD"; +const GIT_BLAME_NO_PATH: &str = "fatal: no such path"; + +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] +pub struct BlameEntry { + pub sha: Oid, + + pub range: Range, + + pub original_line_number: u32, + + pub author: Option, + pub author_mail: Option, + pub author_time: Option, + pub author_tz: Option, + + pub committer_name: Option, + pub committer_email: Option, + pub committer_time: Option, + pub committer_tz: Option, + + pub summary: Option, + + pub previous: Option, + pub filename: String, +} + +impl BlameEntry { + // Returns a BlameEntry by parsing the first line of a `git blame --incremental` + // entry. The line MUST have this format: + // + // <40-byte-hex-sha1> + fn new_from_blame_line(line: &str) -> Result { + let mut parts = line.split_whitespace(); + + let sha = parts + .next() + .and_then(|line| line.parse::().ok()) + .with_context(|| format!("parsing sha from {line}"))?; + + let original_line_number = parts + .next() + .and_then(|line| line.parse::().ok()) + .with_context(|| format!("parsing original line number from {line}"))?; + let final_line_number = parts + .next() + .and_then(|line| line.parse::().ok()) + .with_context(|| format!("parsing final line number from {line}"))?; + + let line_count = parts + .next() + .and_then(|line| line.parse::().ok()) + .with_context(|| format!("parsing line count from {line}"))?; + + let start_line = final_line_number.saturating_sub(1); + let end_line = start_line + line_count; + let range = start_line..end_line; + + Ok(Self { + sha, + range, + original_line_number, + ..Default::default() + }) + } + + pub fn author_offset_date_time(&self) -> Result { + if let (Some(author_time), Some(author_tz)) = (self.author_time, &self.author_tz) { + let format = format_description!("[offset_hour][offset_minute]"); + let offset = UtcOffset::parse(author_tz, &format)?; + let date_time_utc = OffsetDateTime::from_unix_timestamp(author_time)?; + + Ok(date_time_utc.to_offset(offset)) + } else { + // Directly return current time in UTC if there's no committer time or timezone + Ok(time::OffsetDateTime::now_utc()) + } + } +} + +// parse_git_blame parses the output of `git blame --incremental`, which returns +// all the blame-entries for a given path incrementally, as it finds them. +// +// Each entry *always* starts with: +// +// <40-byte-hex-sha1> +// +// Each entry *always* ends with: +// +// filename +// +// Line numbers are 1-indexed. +// +// A `git blame --incremental` entry looks like this: +// +// 6ad46b5257ba16d12c5ca9f0d4900320959df7f4 2 2 1 +// author Joe Schmoe +// author-mail +// author-time 1709741400 +// author-tz +0100 +// committer Joe Schmoe +// committer-mail +// committer-time 1709741400 +// committer-tz +0100 +// summary Joe's cool commit +// previous 486c2409237a2c627230589e567024a96751d475 index.js +// filename index.js +// +// If the entry has the same SHA as an entry that was already printed then no +// signature information is printed: +// +// 6ad46b5257ba16d12c5ca9f0d4900320959df7f4 3 4 1 +// previous 486c2409237a2c627230589e567024a96751d475 index.js +// filename index.js +// +// More about `--incremental` output: https://mirrors.edge.kernel.org/pub/software/scm/git/docs/git-blame.html +fn parse_git_blame(output: &str) -> Result> { + let mut entries: Vec = Vec::new(); + let mut index: HashMap = HashMap::default(); + + let mut current_entry: Option = None; + + for line in output.lines() { + let mut done = false; + + match &mut current_entry { + None => { + let mut new_entry = BlameEntry::new_from_blame_line(line)?; + + if let Some(existing_entry) = index + .get(&new_entry.sha) + .and_then(|slot| entries.get(*slot)) + { + new_entry.author.clone_from(&existing_entry.author); + new_entry + .author_mail + .clone_from(&existing_entry.author_mail); + new_entry.author_time = existing_entry.author_time; + new_entry.author_tz.clone_from(&existing_entry.author_tz); + new_entry + .committer_name + .clone_from(&existing_entry.committer_name); + new_entry + .committer_email + .clone_from(&existing_entry.committer_email); + new_entry.committer_time = existing_entry.committer_time; + new_entry + .committer_tz + .clone_from(&existing_entry.committer_tz); + new_entry.summary.clone_from(&existing_entry.summary); + } + + current_entry.replace(new_entry); + } + Some(entry) => { + let Some((key, value)) = line.split_once(' ') else { + continue; + }; + let is_committed = !entry.sha.is_zero(); + match key { + "filename" => { + entry.filename = value.into(); + done = true; + } + "previous" => entry.previous = Some(value.into()), + + "summary" if is_committed => entry.summary = Some(value.into()), + "author" if is_committed => entry.author = Some(value.into()), + "author-mail" if is_committed => entry.author_mail = Some(value.into()), + "author-time" if is_committed => { + entry.author_time = Some(value.parse::()?) + } + "author-tz" if is_committed => entry.author_tz = Some(value.into()), + + "committer" if is_committed => entry.committer_name = Some(value.into()), + "committer-mail" if is_committed => entry.committer_email = Some(value.into()), + "committer-time" if is_committed => { + entry.committer_time = Some(value.parse::()?) + } + "committer-tz" if is_committed => entry.committer_tz = Some(value.into()), + _ => {} + } + } + }; + + if done { + if let Some(entry) = current_entry.take() { + index.insert(entry.sha, entries.len()); + + // We only want annotations that have a commit. + if !entry.sha.is_zero() { + entries.push(entry); + } + } + } + } + + Ok(entries) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::BlameEntry; + use super::parse_git_blame; + + fn read_test_data(filename: &str) -> String { + let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + path.push("test_data"); + path.push(filename); + + std::fs::read_to_string(&path) + .unwrap_or_else(|_| panic!("Could not read test data at {:?}. Is it generated?", path)) + } + + fn assert_eq_golden(entries: &Vec, golden_filename: &str) { + let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + path.push("test_data"); + path.push("golden"); + path.push(format!("{}.json", golden_filename)); + + let mut have_json = + serde_json::to_string_pretty(&entries).expect("could not serialize entries to JSON"); + // We always want to save with a trailing newline. + have_json.push('\n'); + + let update = std::env::var("UPDATE_GOLDEN") + .map(|val| val.eq_ignore_ascii_case("true")) + .unwrap_or(false); + + if update { + std::fs::create_dir_all(path.parent().unwrap()) + .expect("could not create golden test data directory"); + std::fs::write(&path, have_json).expect("could not write out golden data"); + } else { + let want_json = + std::fs::read_to_string(&path).unwrap_or_else(|_| { + panic!("could not read golden test data file at {:?}. Did you run the test with UPDATE_GOLDEN=true before?", path); + }).replace("\r\n", "\n"); + + pretty_assertions::assert_eq!(have_json, want_json, "wrong blame entries"); + } + } + + #[test] + fn test_parse_git_blame_not_committed() { + let output = read_test_data("blame_incremental_not_committed"); + let entries = parse_git_blame(&output).unwrap(); + assert_eq_golden(&entries, "blame_incremental_not_committed"); + } + + #[test] + fn test_parse_git_blame_simple() { + let output = read_test_data("blame_incremental_simple"); + let entries = parse_git_blame(&output).unwrap(); + assert_eq_golden(&entries, "blame_incremental_simple"); + } + + #[test] + fn test_parse_git_blame_complex() { + let output = read_test_data("blame_incremental_complex"); + let entries = parse_git_blame(&output).unwrap(); + assert_eq_golden(&entries, "blame_incremental_complex"); + } +} diff --git a/crates/agent/src/tools/evals/fixtures/delete_run_git_blame/before.rs b/crates/agent/src/tools/evals/fixtures/delete_run_git_blame/before.rs new file mode 100644 index 0000000000000000000000000000000000000000..36fccb513271265ff7ae3d54b6f974beeb809737 --- /dev/null +++ b/crates/agent/src/tools/evals/fixtures/delete_run_git_blame/before.rs @@ -0,0 +1,371 @@ +use crate::commit::get_messages; +use crate::{GitRemote, Oid}; +use anyhow::{Context as _, Result, anyhow}; +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 time::OffsetDateTime; +use time::UtcOffset; +use time::macros::format_description; + +pub use git2 as libgit; + +#[derive(Debug, Clone, Default)] +pub struct Blame { + pub entries: Vec, + pub messages: HashMap, + pub remote_url: Option, +} + +#[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: &Path, + content: &Rope, + remote_url: Option, + ) -> Result { + let output = run_git_blame(git_binary, working_directory, path, content).await?; + let mut entries = parse_git_blame(&output)?; + entries.sort_unstable_by(|a, b| a.range.start.cmp(&b.range.start)); + + let mut unique_shas = HashSet::default(); + + for entry in entries.iter_mut() { + unique_shas.insert(entry.sha); + } + + let shas = unique_shas.into_iter().collect::>(); + let messages = get_messages(working_directory, &shas) + .await + .context("failed to get commit messages")?; + + Ok(Self { + entries, + messages, + remote_url, + }) + } +} + +const GIT_BLAME_NO_COMMIT_ERROR: &str = "fatal: no such ref: HEAD"; +const GIT_BLAME_NO_PATH: &str = "fatal: no such path"; + +async fn run_git_blame( + git_binary: &Path, + working_directory: &Path, + path: &Path, + contents: &Rope, +) -> Result { + let mut child = util::command::new_smol_command(git_binary) + .current_dir(working_directory) + .arg("blame") + .arg("--incremental") + .arg("--contents") + .arg("-") + .arg(path.as_os_str()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("starting git blame process")?; + + let stdin = child + .stdin + .as_mut() + .context("failed to get pipe to stdin of git blame command")?; + + for chunk in contents.chunks() { + stdin.write_all(chunk.as_bytes()).await?; + } + stdin.flush().await?; + + let output = child.output().await.context("reading git blame output")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let trimmed = stderr.trim(); + if trimmed == GIT_BLAME_NO_COMMIT_ERROR || trimmed.contains(GIT_BLAME_NO_PATH) { + return Ok(String::new()); + } + anyhow::bail!("git blame process failed: {stderr}"); + } + + Ok(String::from_utf8(output.stdout)?) +} + +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] +pub struct BlameEntry { + pub sha: Oid, + + pub range: Range, + + pub original_line_number: u32, + + pub author: Option, + pub author_mail: Option, + pub author_time: Option, + pub author_tz: Option, + + pub committer_name: Option, + pub committer_email: Option, + pub committer_time: Option, + pub committer_tz: Option, + + pub summary: Option, + + pub previous: Option, + pub filename: String, +} + +impl BlameEntry { + // Returns a BlameEntry by parsing the first line of a `git blame --incremental` + // entry. The line MUST have this format: + // + // <40-byte-hex-sha1> + fn new_from_blame_line(line: &str) -> Result { + let mut parts = line.split_whitespace(); + + let sha = parts + .next() + .and_then(|line| line.parse::().ok()) + .with_context(|| format!("parsing sha from {line}"))?; + + let original_line_number = parts + .next() + .and_then(|line| line.parse::().ok()) + .with_context(|| format!("parsing original line number from {line}"))?; + let final_line_number = parts + .next() + .and_then(|line| line.parse::().ok()) + .with_context(|| format!("parsing final line number from {line}"))?; + + let line_count = parts + .next() + .and_then(|line| line.parse::().ok()) + .with_context(|| format!("parsing line count from {line}"))?; + + let start_line = final_line_number.saturating_sub(1); + let end_line = start_line + line_count; + let range = start_line..end_line; + + Ok(Self { + sha, + range, + original_line_number, + ..Default::default() + }) + } + + pub fn author_offset_date_time(&self) -> Result { + if let (Some(author_time), Some(author_tz)) = (self.author_time, &self.author_tz) { + let format = format_description!("[offset_hour][offset_minute]"); + let offset = UtcOffset::parse(author_tz, &format)?; + let date_time_utc = OffsetDateTime::from_unix_timestamp(author_time)?; + + Ok(date_time_utc.to_offset(offset)) + } else { + // Directly return current time in UTC if there's no committer time or timezone + Ok(time::OffsetDateTime::now_utc()) + } + } +} + +// parse_git_blame parses the output of `git blame --incremental`, which returns +// all the blame-entries for a given path incrementally, as it finds them. +// +// Each entry *always* starts with: +// +// <40-byte-hex-sha1> +// +// Each entry *always* ends with: +// +// filename +// +// Line numbers are 1-indexed. +// +// A `git blame --incremental` entry looks like this: +// +// 6ad46b5257ba16d12c5ca9f0d4900320959df7f4 2 2 1 +// author Joe Schmoe +// author-mail +// author-time 1709741400 +// author-tz +0100 +// committer Joe Schmoe +// committer-mail +// committer-time 1709741400 +// committer-tz +0100 +// summary Joe's cool commit +// previous 486c2409237a2c627230589e567024a96751d475 index.js +// filename index.js +// +// If the entry has the same SHA as an entry that was already printed then no +// signature information is printed: +// +// 6ad46b5257ba16d12c5ca9f0d4900320959df7f4 3 4 1 +// previous 486c2409237a2c627230589e567024a96751d475 index.js +// filename index.js +// +// More about `--incremental` output: https://mirrors.edge.kernel.org/pub/software/scm/git/docs/git-blame.html +fn parse_git_blame(output: &str) -> Result> { + let mut entries: Vec = Vec::new(); + let mut index: HashMap = HashMap::default(); + + let mut current_entry: Option = None; + + for line in output.lines() { + let mut done = false; + + match &mut current_entry { + None => { + let mut new_entry = BlameEntry::new_from_blame_line(line)?; + + if let Some(existing_entry) = index + .get(&new_entry.sha) + .and_then(|slot| entries.get(*slot)) + { + new_entry.author.clone_from(&existing_entry.author); + new_entry + .author_mail + .clone_from(&existing_entry.author_mail); + new_entry.author_time = existing_entry.author_time; + new_entry.author_tz.clone_from(&existing_entry.author_tz); + new_entry + .committer_name + .clone_from(&existing_entry.committer_name); + new_entry + .committer_email + .clone_from(&existing_entry.committer_email); + new_entry.committer_time = existing_entry.committer_time; + new_entry + .committer_tz + .clone_from(&existing_entry.committer_tz); + new_entry.summary.clone_from(&existing_entry.summary); + } + + current_entry.replace(new_entry); + } + Some(entry) => { + let Some((key, value)) = line.split_once(' ') else { + continue; + }; + let is_committed = !entry.sha.is_zero(); + match key { + "filename" => { + entry.filename = value.into(); + done = true; + } + "previous" => entry.previous = Some(value.into()), + + "summary" if is_committed => entry.summary = Some(value.into()), + "author" if is_committed => entry.author = Some(value.into()), + "author-mail" if is_committed => entry.author_mail = Some(value.into()), + "author-time" if is_committed => { + entry.author_time = Some(value.parse::()?) + } + "author-tz" if is_committed => entry.author_tz = Some(value.into()), + + "committer" if is_committed => entry.committer_name = Some(value.into()), + "committer-mail" if is_committed => entry.committer_email = Some(value.into()), + "committer-time" if is_committed => { + entry.committer_time = Some(value.parse::()?) + } + "committer-tz" if is_committed => entry.committer_tz = Some(value.into()), + _ => {} + } + } + }; + + if done { + if let Some(entry) = current_entry.take() { + index.insert(entry.sha, entries.len()); + + // We only want annotations that have a commit. + if !entry.sha.is_zero() { + entries.push(entry); + } + } + } + } + + Ok(entries) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::BlameEntry; + use super::parse_git_blame; + + fn read_test_data(filename: &str) -> String { + let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + path.push("test_data"); + path.push(filename); + + std::fs::read_to_string(&path) + .unwrap_or_else(|_| panic!("Could not read test data at {:?}. Is it generated?", path)) + } + + fn assert_eq_golden(entries: &Vec, golden_filename: &str) { + let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + path.push("test_data"); + path.push("golden"); + path.push(format!("{}.json", golden_filename)); + + let mut have_json = + serde_json::to_string_pretty(&entries).expect("could not serialize entries to JSON"); + // We always want to save with a trailing newline. + have_json.push('\n'); + + let update = std::env::var("UPDATE_GOLDEN") + .map(|val| val.eq_ignore_ascii_case("true")) + .unwrap_or(false); + + if update { + std::fs::create_dir_all(path.parent().unwrap()) + .expect("could not create golden test data directory"); + std::fs::write(&path, have_json).expect("could not write out golden data"); + } else { + let want_json = + std::fs::read_to_string(&path).unwrap_or_else(|_| { + panic!("could not read golden test data file at {:?}. Did you run the test with UPDATE_GOLDEN=true before?", path); + }).replace("\r\n", "\n"); + + pretty_assertions::assert_eq!(have_json, want_json, "wrong blame entries"); + } + } + + #[test] + fn test_parse_git_blame_not_committed() { + let output = read_test_data("blame_incremental_not_committed"); + let entries = parse_git_blame(&output).unwrap(); + assert_eq_golden(&entries, "blame_incremental_not_committed"); + } + + #[test] + fn test_parse_git_blame_simple() { + let output = read_test_data("blame_incremental_simple"); + let entries = parse_git_blame(&output).unwrap(); + assert_eq_golden(&entries, "blame_incremental_simple"); + } + + #[test] + fn test_parse_git_blame_complex() { + let output = read_test_data("blame_incremental_complex"); + let entries = parse_git_blame(&output).unwrap(); + assert_eq_golden(&entries, "blame_incremental_complex"); + } +} diff --git a/crates/agent/src/tools/evals/fixtures/disable_cursor_blinking/before.rs b/crates/agent/src/tools/evals/fixtures/disable_cursor_blinking/before.rs new file mode 100644 index 0000000000000000000000000000000000000000..bdf160d8ffe2c605a9e995d6efe7227dce34eaab --- /dev/null +++ b/crates/agent/src/tools/evals/fixtures/disable_cursor_blinking/before.rs @@ -0,0 +1,21343 @@ +#![allow(rustdoc::private_intra_doc_links)] +//! This is the place where everything editor-related is stored (data-wise) and displayed (ui-wise). +//! The main point of interest in this crate is [`Editor`] type, which is used in every other Zed part as a user input element. +//! It comes in different flavors: single line, multiline and a fixed height one. +//! +//! Editor contains of multiple large submodules: +//! * [`element`] — the place where all rendering happens +//! * [`display_map`] - chunks up text in the editor into the logical blocks, establishes coordinates and mapping between each of them. +//! Contains all metadata related to text transformations (folds, fake inlay text insertions, soft wraps, tab markup, etc.). +//! * [`inlay_hint_cache`] - is a storage of inlay hints out of LSP requests, responsible for querying LSP and updating `display_map`'s state accordingly. +//! +//! All other submodules and structs are mostly concerned with holding editor data about the way it displays current buffer region(s). +//! +//! If you're looking to improve Vim mode, you should check out Vim crate that wraps Editor and overrides its behavior. +pub mod actions; +mod blink_manager; +mod clangd_ext; +mod code_context_menus; +pub mod display_map; +mod editor_settings; +mod editor_settings_controls; +mod element; +mod git; +mod highlight_matching_bracket; +mod hover_links; +pub mod hover_popover; +mod indent_guides; +mod inlay_hint_cache; +pub mod items; +mod jsx_tag_auto_close; +mod linked_editing_ranges; +mod lsp_ext; +mod mouse_context_menu; +pub mod movement; +mod persistence; +mod proposed_changes_editor; +mod rust_analyzer_ext; +pub mod scroll; +mod selections_collection; +pub mod tasks; + +#[cfg(test)] +mod code_completion_tests; +#[cfg(test)] +mod editor_tests; +#[cfg(test)] +mod inline_completion_tests; +mod signature_help; +#[cfg(any(test, feature = "test-support"))] +pub mod test; + +pub(crate) use actions::*; +pub use actions::{AcceptEditPrediction, OpenExcerpts, OpenExcerptsSplit}; +use aho_corasick::AhoCorasick; +use anyhow::{Context as _, Result, anyhow}; +use blink_manager::BlinkManager; +use buffer_diff::DiffHunkStatus; +use client::{Collaborator, ParticipantIndex}; +use clock::ReplicaId; +use collections::{BTreeMap, HashMap, HashSet, VecDeque}; +use convert_case::{Case, Casing}; +use display_map::*; +pub use display_map::{ChunkRenderer, ChunkRendererContext, DisplayPoint, FoldPlaceholder}; +use editor_settings::GoToDefinitionFallback; +pub use editor_settings::{ + CurrentLineHighlight, EditorSettings, HideMouseMode, ScrollBeyondLastLine, SearchSettings, + ShowScrollbar, +}; +pub use editor_settings_controls::*; +use element::{AcceptEditPredictionBinding, LineWithInvisibles, PositionMap, layout_line}; +pub use element::{ + CursorLayout, EditorElement, HighlightedRange, HighlightedRangeLine, PointForPosition, +}; +use feature_flags::{DebuggerFeatureFlag, FeatureFlagAppExt}; +use futures::{ + FutureExt, + future::{self, Shared, join}, +}; +use fuzzy::StringMatchCandidate; + +use ::git::blame::BlameEntry; +use ::git::{Restore, blame::ParsedCommitMessage}; +use code_context_menus::{ + AvailableCodeAction, CodeActionContents, CodeActionsItem, CodeActionsMenu, CodeContextMenu, + CompletionsMenu, ContextMenuOrigin, +}; +use git::blame::{GitBlame, GlobalBlameRenderer}; +use gpui::{ + Action, Animation, AnimationExt, AnyElement, App, AppContext, AsyncWindowContext, + AvailableSpace, Background, Bounds, ClickEvent, ClipboardEntry, ClipboardItem, Context, + DispatchPhase, Edges, Entity, EntityInputHandler, EventEmitter, FocusHandle, FocusOutEvent, + Focusable, FontId, FontWeight, Global, HighlightStyle, Hsla, KeyContext, Modifiers, + MouseButton, MouseDownEvent, PaintQuad, ParentElement, Pixels, Render, ScrollHandle, + SharedString, Size, Stateful, Styled, Subscription, Task, TextStyle, TextStyleRefinement, + UTF16Selection, UnderlineStyle, UniformListScrollHandle, WeakEntity, WeakFocusHandle, Window, + div, impl_actions, point, prelude::*, pulsating_between, px, relative, size, +}; +use highlight_matching_bracket::refresh_matching_bracket_highlights; +use hover_links::{HoverLink, HoveredLinkState, InlayHighlight, find_file}; +pub use hover_popover::hover_markdown_style; +use hover_popover::{HoverState, hide_hover}; +use indent_guides::ActiveIndentGuidesState; +use inlay_hint_cache::{InlayHintCache, InlaySplice, InvalidationStrategy}; +pub use inline_completion::Direction; +use inline_completion::{EditPredictionProvider, InlineCompletionProviderHandle}; +pub use items::MAX_TAB_TITLE_LEN; +use itertools::Itertools; +use language::{ + AutoindentMode, BracketMatch, BracketPair, Buffer, Capability, CharKind, CodeLabel, + CursorShape, DiagnosticEntry, DiffOptions, EditPredictionsMode, EditPreview, HighlightedText, + IndentKind, IndentSize, Language, OffsetRangeExt, Point, Selection, SelectionGoal, TextObject, + TransactionId, TreeSitterOptions, WordsQuery, + language_settings::{ + self, InlayHintSettings, LspInsertMode, RewrapBehavior, WordsCompletionMode, + all_language_settings, language_settings, + }, + point_from_lsp, text_diff_with_options, +}; +use language::{BufferRow, CharClassifier, Runnable, RunnableRange, point_to_lsp}; +use linked_editing_ranges::refresh_linked_ranges; +use markdown::Markdown; +use mouse_context_menu::MouseContextMenu; +use persistence::DB; +use project::{ + ProjectPath, + debugger::{ + breakpoint_store::{ + BreakpointEditAction, BreakpointState, BreakpointStore, BreakpointStoreEvent, + }, + session::{Session, SessionEvent}, + }, +}; + +pub use git::blame::BlameRenderer; +pub use proposed_changes_editor::{ + ProposedChangeLocation, ProposedChangesEditor, ProposedChangesEditorToolbar, +}; +use smallvec::smallvec; +use std::{cell::OnceCell, iter::Peekable}; +use task::{ResolvedTask, RunnableTag, TaskTemplate, TaskVariables}; + +pub use lsp::CompletionContext; +use lsp::{ + CodeActionKind, CompletionItemKind, CompletionTriggerKind, DiagnosticSeverity, + InsertTextFormat, InsertTextMode, LanguageServerId, LanguageServerName, +}; + +use language::BufferSnapshot; +pub use lsp_ext::lsp_tasks; +use movement::TextLayoutDetails; +pub use multi_buffer::{ + Anchor, AnchorRangeExt, ExcerptId, ExcerptRange, MultiBuffer, MultiBufferSnapshot, PathKey, + RowInfo, ToOffset, ToPoint, +}; +use multi_buffer::{ + ExcerptInfo, ExpandExcerptDirection, MultiBufferDiffHunk, MultiBufferPoint, MultiBufferRow, + MultiOrSingleBufferOffsetRange, ToOffsetUtf16, +}; +use parking_lot::Mutex; +use project::{ + CodeAction, Completion, CompletionIntent, CompletionSource, DocumentHighlight, InlayHint, + Location, LocationLink, PrepareRenameResponse, Project, ProjectItem, ProjectTransaction, + TaskSourceKind, + debugger::breakpoint_store::Breakpoint, + lsp_store::{CompletionDocumentation, FormatTrigger, LspFormatTarget, OpenLspBufferHandle}, + project_settings::{GitGutterSetting, ProjectSettings}, +}; +use rand::prelude::*; +use rpc::{ErrorExt, proto::*}; +use scroll::{Autoscroll, OngoingScroll, ScrollAnchor, ScrollManager, ScrollbarAutoHide}; +use selections_collection::{ + MutableSelectionsCollection, SelectionsCollection, resolve_selections, +}; +use serde::{Deserialize, Serialize}; +use settings::{Settings, SettingsLocation, SettingsStore, update_settings_file}; +use smallvec::SmallVec; +use snippet::Snippet; +use std::sync::Arc; +use std::{ + any::TypeId, + borrow::Cow, + cell::RefCell, + cmp::{self, Ordering, Reverse}, + mem, + num::NonZeroU32, + ops::{ControlFlow, Deref, DerefMut, Not as _, Range, RangeInclusive}, + path::{Path, PathBuf}, + rc::Rc, + time::{Duration, Instant}, +}; +pub use sum_tree::Bias; +use sum_tree::TreeMap; +use text::{BufferId, FromAnchor, OffsetUtf16, Rope}; +use theme::{ + ActiveTheme, PlayerColor, StatusColors, SyntaxTheme, ThemeColors, ThemeSettings, + observe_buffer_font_size_adjustment, +}; +use ui::{ + ButtonSize, ButtonStyle, ContextMenu, Disclosure, IconButton, IconButtonShape, IconName, + IconSize, Key, Tooltip, h_flex, prelude::*, +}; +use util::{RangeExt, ResultExt, TryFutureExt, maybe, post_inc}; +use workspace::{ + Item as WorkspaceItem, ItemId, ItemNavHistory, OpenInTerminal, OpenTerminal, + RestoreOnStartupBehavior, SERIALIZATION_THROTTLE_TIME, SplitDirection, TabBarSettings, Toast, + ViewId, Workspace, WorkspaceId, WorkspaceSettings, + item::{ItemHandle, PreviewTabsSettings}, + notifications::{DetachAndPromptErr, NotificationId, NotifyTaskExt}, + searchable::SearchEvent, +}; + +use crate::hover_links::{find_url, find_url_from_range}; +use crate::signature_help::{SignatureHelpHiddenBy, SignatureHelpState}; + +pub const FILE_HEADER_HEIGHT: u32 = 2; +pub const MULTI_BUFFER_EXCERPT_HEADER_HEIGHT: u32 = 1; +pub const DEFAULT_MULTIBUFFER_CONTEXT: u32 = 2; +const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500); +const MAX_LINE_LEN: usize = 1024; +const MIN_NAVIGATION_HISTORY_ROW_DELTA: i64 = 10; +const MAX_SELECTION_HISTORY_LEN: usize = 1024; +pub(crate) const CURSORS_VISIBLE_FOR: Duration = Duration::from_millis(2000); +#[doc(hidden)] +pub const CODE_ACTIONS_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(250); +const SELECTION_HIGHLIGHT_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(100); + +pub(crate) const CODE_ACTION_TIMEOUT: Duration = Duration::from_secs(5); +pub(crate) const FORMAT_TIMEOUT: Duration = Duration::from_secs(5); +pub(crate) const SCROLL_CENTER_TOP_BOTTOM_DEBOUNCE_TIMEOUT: Duration = Duration::from_secs(1); + +pub(crate) const EDIT_PREDICTION_KEY_CONTEXT: &str = "edit_prediction"; +pub(crate) const EDIT_PREDICTION_CONFLICT_KEY_CONTEXT: &str = "edit_prediction_conflict"; +pub(crate) const MIN_LINE_NUMBER_DIGITS: u32 = 4; + +pub type RenderDiffHunkControlsFn = Arc< + dyn Fn( + u32, + &DiffHunkStatus, + Range, + bool, + Pixels, + &Entity, + &mut Window, + &mut App, + ) -> AnyElement, +>; + +const COLUMNAR_SELECTION_MODIFIERS: Modifiers = Modifiers { + alt: true, + shift: true, + control: false, + platform: false, + function: false, +}; + +struct InlineValueCache { + enabled: bool, + inlays: Vec, + refresh_task: Task>, +} + +impl InlineValueCache { + fn new(enabled: bool) -> Self { + Self { + enabled, + inlays: Vec::new(), + refresh_task: Task::ready(None), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum InlayId { + InlineCompletion(usize), + Hint(usize), + DebuggerValue(usize), +} + +impl InlayId { + fn id(&self) -> usize { + match self { + Self::InlineCompletion(id) => *id, + Self::Hint(id) => *id, + Self::DebuggerValue(id) => *id, + } + } +} + +pub enum ActiveDebugLine {} +enum DocumentHighlightRead {} +enum DocumentHighlightWrite {} +enum InputComposition {} +enum SelectedTextHighlight {} + +pub enum ConflictsOuter {} +pub enum ConflictsOurs {} +pub enum ConflictsTheirs {} +pub enum ConflictsOursMarker {} +pub enum ConflictsTheirsMarker {} + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum Navigated { + Yes, + No, +} + +impl Navigated { + pub fn from_bool(yes: bool) -> Navigated { + if yes { Navigated::Yes } else { Navigated::No } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum DisplayDiffHunk { + Folded { + display_row: DisplayRow, + }, + Unfolded { + is_created_file: bool, + diff_base_byte_range: Range, + display_row_range: Range, + multi_buffer_range: Range, + status: DiffHunkStatus, + }, +} + +pub enum HideMouseCursorOrigin { + TypingAction, + MovementAction, +} + +pub fn init_settings(cx: &mut App) { + EditorSettings::register(cx); +} + +pub fn init(cx: &mut App) { + init_settings(cx); + + cx.set_global(GlobalBlameRenderer(Arc::new(()))); + + workspace::register_project_item::(cx); + workspace::FollowableViewRegistry::register::(cx); + workspace::register_serializable_item::(cx); + + cx.observe_new( + |workspace: &mut Workspace, _: Option<&mut Window>, _cx: &mut Context| { + workspace.register_action(Editor::new_file); + workspace.register_action(Editor::new_file_vertical); + workspace.register_action(Editor::new_file_horizontal); + workspace.register_action(Editor::cancel_language_server_work); + }, + ) + .detach(); + + cx.on_action(move |_: &workspace::NewFile, cx| { + let app_state = workspace::AppState::global(cx); + if let Some(app_state) = app_state.upgrade() { + workspace::open_new( + Default::default(), + app_state, + cx, + |workspace, window, cx| { + Editor::new_file(workspace, &Default::default(), window, cx) + }, + ) + .detach(); + } + }); + 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( + Default::default(), + app_state, + cx, + |workspace, window, cx| { + cx.activate(true); + Editor::new_file(workspace, &Default::default(), window, cx) + }, + ) + .detach(); + } + }); +} + +pub fn set_blame_renderer(renderer: impl BlameRenderer + 'static, cx: &mut App) { + cx.set_global(GlobalBlameRenderer(Arc::new(renderer))); +} + +pub trait DiagnosticRenderer { + fn render_group( + &self, + diagnostic_group: Vec>, + buffer_id: BufferId, + snapshot: EditorSnapshot, + editor: WeakEntity, + cx: &mut App, + ) -> Vec>; + + fn render_hover( + &self, + diagnostic_group: Vec>, + range: Range, + buffer_id: BufferId, + cx: &mut App, + ) -> Option>; + + fn open_link( + &self, + editor: &mut Editor, + link: SharedString, + window: &mut Window, + cx: &mut Context, + ); +} + +pub(crate) struct GlobalDiagnosticRenderer(pub Arc); + +impl GlobalDiagnosticRenderer { + fn global(cx: &App) -> Option> { + cx.try_global::().map(|g| g.0.clone()) + } +} + +impl gpui::Global for GlobalDiagnosticRenderer {} +pub fn set_diagnostic_renderer(renderer: impl DiagnosticRenderer + 'static, cx: &mut App) { + cx.set_global(GlobalDiagnosticRenderer(Arc::new(renderer))); +} + +pub struct SearchWithinRange; + +trait InvalidationRegion { + fn ranges(&self) -> &[Range]; +} + +#[derive(Clone, Debug, PartialEq)] +pub enum SelectPhase { + Begin { + position: DisplayPoint, + add: bool, + click_count: usize, + }, + BeginColumnar { + position: DisplayPoint, + reset: bool, + goal_column: u32, + }, + Extend { + position: DisplayPoint, + click_count: usize, + }, + Update { + position: DisplayPoint, + goal_column: u32, + scroll_delta: gpui::Point, + }, + End, +} + +#[derive(Clone, Debug)] +pub enum SelectMode { + Character, + Word(Range), + Line(Range), + All, +} + +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub enum EditorMode { + SingleLine { + auto_width: bool, + }, + AutoHeight { + max_lines: usize, + }, + Full { + /// When set to `true`, the editor will scale its UI elements with the buffer font size. + scale_ui_elements_with_buffer_font_size: bool, + /// When set to `true`, the editor will render a background for the active line. + show_active_line_background: bool, + /// When set to `true`, the editor's height will be determined by its content. + sized_by_content: bool, + }, +} + +impl EditorMode { + pub fn full() -> Self { + Self::Full { + scale_ui_elements_with_buffer_font_size: true, + show_active_line_background: true, + sized_by_content: false, + } + } + + pub fn is_full(&self) -> bool { + matches!(self, Self::Full { .. }) + } +} + +#[derive(Copy, Clone, Debug)] +pub enum SoftWrap { + /// Prefer not to wrap at all. + /// + /// Note: this is currently internal, as actually limited by [`crate::MAX_LINE_LEN`] until it wraps. + /// The mode is used inside git diff hunks, where it's seems currently more useful to not wrap as much as possible. + GitDiff, + /// Prefer a single line generally, unless an overly long line is encountered. + None, + /// Soft wrap lines that exceed the editor width. + EditorWidth, + /// Soft wrap lines at the preferred line length. + Column(u32), + /// Soft wrap line at the preferred line length or the editor width (whichever is smaller). + Bounded(u32), +} + +#[derive(Clone)] +pub struct EditorStyle { + pub background: Hsla, + pub local_player: PlayerColor, + pub text: TextStyle, + pub scrollbar_width: Pixels, + pub syntax: Arc, + pub status: StatusColors, + pub inlay_hints_style: HighlightStyle, + pub inline_completion_styles: InlineCompletionStyles, + pub unnecessary_code_fade: f32, +} + +impl Default for EditorStyle { + fn default() -> Self { + Self { + background: Hsla::default(), + local_player: PlayerColor::default(), + text: TextStyle::default(), + scrollbar_width: Pixels::default(), + syntax: Default::default(), + // HACK: Status colors don't have a real default. + // We should look into removing the status colors from the editor + // style and retrieve them directly from the theme. + status: StatusColors::dark(), + inlay_hints_style: HighlightStyle::default(), + inline_completion_styles: InlineCompletionStyles { + insertion: HighlightStyle::default(), + whitespace: HighlightStyle::default(), + }, + unnecessary_code_fade: Default::default(), + } + } +} + +pub fn make_inlay_hints_style(cx: &mut App) -> HighlightStyle { + let show_background = language_settings::language_settings(None, None, cx) + .inlay_hints + .show_background; + + HighlightStyle { + color: Some(cx.theme().status().hint), + background_color: show_background.then(|| cx.theme().status().hint_background), + ..HighlightStyle::default() + } +} + +pub fn make_suggestion_styles(cx: &mut App) -> InlineCompletionStyles { + InlineCompletionStyles { + insertion: HighlightStyle { + color: Some(cx.theme().status().predictive), + ..HighlightStyle::default() + }, + whitespace: HighlightStyle { + background_color: Some(cx.theme().status().created_background), + ..HighlightStyle::default() + }, + } +} + +type CompletionId = usize; + +pub(crate) enum EditDisplayMode { + TabAccept, + DiffPopover, + Inline, +} + +enum InlineCompletion { + Edit { + edits: Vec<(Range, String)>, + edit_preview: Option, + display_mode: EditDisplayMode, + snapshot: BufferSnapshot, + }, + Move { + target: Anchor, + snapshot: BufferSnapshot, + }, +} + +struct InlineCompletionState { + inlay_ids: Vec, + completion: InlineCompletion, + completion_id: Option, + invalidation_range: Range, +} + +enum EditPredictionSettings { + Disabled, + Enabled { + show_in_menu: bool, + preview_requires_modifier: bool, + }, +} + +enum InlineCompletionHighlight {} + +#[derive(Debug, Clone)] +struct InlineDiagnostic { + message: SharedString, + group_id: usize, + is_primary: bool, + start: Point, + severity: DiagnosticSeverity, +} + +pub enum MenuInlineCompletionsPolicy { + Never, + ByProvider, +} + +pub enum EditPredictionPreview { + /// Modifier is not pressed + Inactive { released_too_fast: bool }, + /// Modifier pressed + Active { + since: Instant, + previous_scroll_position: Option, + }, +} + +impl EditPredictionPreview { + pub fn released_too_fast(&self) -> bool { + match self { + EditPredictionPreview::Inactive { released_too_fast } => *released_too_fast, + EditPredictionPreview::Active { .. } => false, + } + } + + pub fn set_previous_scroll_position(&mut self, scroll_position: Option) { + if let EditPredictionPreview::Active { + previous_scroll_position, + .. + } = self + { + *previous_scroll_position = scroll_position; + } + } +} + +pub struct ContextMenuOptions { + pub min_entries_visible: usize, + pub max_entries_visible: usize, + pub placement: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ContextMenuPlacement { + Above, + Below, +} + +#[derive(Copy, Clone, Eq, PartialEq, PartialOrd, Ord, Debug, Default)] +struct EditorActionId(usize); + +impl EditorActionId { + pub fn post_inc(&mut self) -> Self { + let answer = self.0; + + *self = Self(answer + 1); + + Self(answer) + } +} + +// type GetFieldEditorTheme = dyn Fn(&theme::Theme) -> theme::FieldEditor; +// type OverrideTextStyle = dyn Fn(&EditorStyle) -> Option; + +type BackgroundHighlight = (fn(&ThemeColors) -> Hsla, Arc<[Range]>); +type GutterHighlight = (fn(&App) -> Hsla, Arc<[Range]>); + +#[derive(Default)] +struct ScrollbarMarkerState { + scrollbar_size: Size, + dirty: bool, + markers: Arc<[PaintQuad]>, + pending_refresh: Option>>, +} + +impl ScrollbarMarkerState { + fn should_refresh(&self, scrollbar_size: Size) -> bool { + self.pending_refresh.is_none() && (self.scrollbar_size != scrollbar_size || self.dirty) + } +} + +#[derive(Clone, Debug)] +struct RunnableTasks { + templates: Vec<(TaskSourceKind, TaskTemplate)>, + offset: multi_buffer::Anchor, + // We need the column at which the task context evaluation should take place (when we're spawning it via gutter). + column: u32, + // Values of all named captures, including those starting with '_' + extra_variables: HashMap, + // Full range of the tagged region. We use it to determine which `extra_variables` to grab for context resolution in e.g. a modal. + context_range: Range, +} + +impl RunnableTasks { + fn resolve<'a>( + &'a self, + cx: &'a task::TaskContext, + ) -> impl Iterator + 'a { + self.templates.iter().filter_map(|(kind, template)| { + template + .resolve_task(&kind.to_id_base(), cx) + .map(|task| (kind.clone(), task)) + }) + } +} + +#[derive(Clone)] +struct ResolvedTasks { + templates: SmallVec<[(TaskSourceKind, ResolvedTask); 1]>, + position: Anchor, +} + +#[derive(Copy, Clone, Debug, PartialEq, PartialOrd)] +struct BufferOffset(usize); + +// Addons allow storing per-editor state in other crates (e.g. Vim) +pub trait Addon: 'static { + fn extend_key_context(&self, _: &mut KeyContext, _: &App) {} + + fn render_buffer_header_controls( + &self, + _: &ExcerptInfo, + _: &Window, + _: &App, + ) -> Option { + None + } + + fn to_any(&self) -> &dyn std::any::Any; + + fn to_any_mut(&mut self) -> Option<&mut dyn std::any::Any> { + None + } +} + +/// A set of caret positions, registered when the editor was edited. +pub struct ChangeList { + changes: Vec>, + /// Currently "selected" change. + position: Option, +} + +impl ChangeList { + pub fn new() -> Self { + Self { + changes: Vec::new(), + position: None, + } + } + + /// Moves to the next change in the list (based on the direction given) and returns the caret positions for the next change. + /// If reaches the end of the list in the direction, returns the corresponding change until called for a different direction. + pub fn next_change(&mut self, count: usize, direction: Direction) -> Option<&[Anchor]> { + if self.changes.is_empty() { + return None; + } + + let prev = self.position.unwrap_or(self.changes.len()); + let next = if direction == Direction::Prev { + prev.saturating_sub(count) + } else { + (prev + count).min(self.changes.len() - 1) + }; + self.position = Some(next); + self.changes.get(next).map(|anchors| anchors.as_slice()) + } + + /// Adds a new change to the list, resetting the change list position. + pub fn push_to_change_list(&mut self, pop_state: bool, new_positions: Vec) { + self.position.take(); + if pop_state { + self.changes.pop(); + } + self.changes.push(new_positions.clone()); + } + + pub fn last(&self) -> Option<&[Anchor]> { + self.changes.last().map(|anchors| anchors.as_slice()) + } +} + +#[derive(Clone)] +struct InlineBlamePopoverState { + scroll_handle: ScrollHandle, + commit_message: Option, + markdown: Entity, +} + +struct InlineBlamePopover { + position: gpui::Point, + show_task: Option>, + hide_task: Option>, + popover_bounds: Option>, + popover_state: InlineBlamePopoverState, +} + +/// Represents a breakpoint indicator that shows up when hovering over lines in the gutter that don't have +/// a breakpoint on them. +#[derive(Clone, Copy, Debug)] +struct PhantomBreakpointIndicator { + display_row: DisplayRow, + /// There's a small debounce between hovering over the line and showing the indicator. + /// We don't want to show the indicator when moving the mouse from editor to e.g. project panel. + is_active: bool, + collides_with_existing_breakpoint: bool, +} +/// Zed's primary implementation of text input, allowing users to edit a [`MultiBuffer`]. +/// +/// See the [module level documentation](self) for more information. +pub struct Editor { + focus_handle: FocusHandle, + last_focused_descendant: Option, + /// The text buffer being edited + buffer: Entity, + /// Map of how text in the buffer should be displayed. + /// Handles soft wraps, folds, fake inlay text insertions, etc. + pub display_map: Entity, + pub selections: SelectionsCollection, + pub scroll_manager: ScrollManager, + /// When inline assist editors are linked, they all render cursors because + /// typing enters text into each of them, even the ones that aren't focused. + pub(crate) show_cursor_when_unfocused: bool, + columnar_selection_tail: Option, + add_selections_state: Option, + select_next_state: Option, + select_prev_state: Option, + selection_history: SelectionHistory, + autoclose_regions: Vec, + snippet_stack: InvalidationStack, + select_syntax_node_history: SelectSyntaxNodeHistory, + ime_transaction: Option, + active_diagnostics: ActiveDiagnostic, + show_inline_diagnostics: bool, + inline_diagnostics_update: Task<()>, + inline_diagnostics_enabled: bool, + inline_diagnostics: Vec<(Anchor, InlineDiagnostic)>, + soft_wrap_mode_override: Option, + hard_wrap: Option, + + // TODO: make this a access method + pub project: Option>, + semantics_provider: Option>, + completion_provider: Option>, + collaboration_hub: Option>, + blink_manager: Entity, + show_cursor_names: bool, + hovered_cursors: HashMap>, + pub show_local_selections: bool, + mode: EditorMode, + show_breadcrumbs: bool, + show_gutter: bool, + show_scrollbars: bool, + disable_scrolling: bool, + disable_expand_excerpt_buttons: bool, + show_line_numbers: Option, + use_relative_line_numbers: Option, + show_git_diff_gutter: Option, + show_code_actions: Option, + show_runnables: Option, + show_breakpoints: Option, + show_wrap_guides: Option, + show_indent_guides: Option, + placeholder_text: Option>, + highlight_order: usize, + highlighted_rows: HashMap>, + background_highlights: TreeMap, + gutter_highlights: TreeMap, + scrollbar_marker_state: ScrollbarMarkerState, + active_indent_guides_state: ActiveIndentGuidesState, + nav_history: Option, + context_menu: RefCell>, + context_menu_options: Option, + mouse_context_menu: Option, + completion_tasks: Vec<(CompletionId, Task>)>, + inline_blame_popover: Option, + signature_help_state: SignatureHelpState, + auto_signature_help: Option, + find_all_references_task_sources: Vec, + next_completion_id: CompletionId, + available_code_actions: Option<(Location, Rc<[AvailableCodeAction]>)>, + code_actions_task: Option>>, + quick_selection_highlight_task: Option<(Range, Task<()>)>, + debounced_selection_highlight_task: Option<(Range, Task<()>)>, + document_highlights_task: Option>, + linked_editing_range_task: Option>>, + linked_edit_ranges: linked_editing_ranges::LinkedEditingRanges, + pending_rename: Option, + searchable: bool, + cursor_shape: CursorShape, + current_line_highlight: Option, + collapse_matches: bool, + autoindent_mode: Option, + workspace: Option<(WeakEntity, Option)>, + input_enabled: bool, + use_modal_editing: bool, + read_only: bool, + leader_peer_id: Option, + remote_id: Option, + pub hover_state: HoverState, + pending_mouse_down: Option>>>, + gutter_hovered: bool, + hovered_link_state: Option, + edit_prediction_provider: Option, + code_action_providers: Vec>, + active_inline_completion: Option, + /// Used to prevent flickering as the user types while the menu is open + stale_inline_completion_in_menu: Option, + edit_prediction_settings: EditPredictionSettings, + inline_completions_hidden_for_vim_mode: bool, + show_inline_completions_override: Option, + menu_inline_completions_policy: MenuInlineCompletionsPolicy, + edit_prediction_preview: EditPredictionPreview, + edit_prediction_indent_conflict: bool, + edit_prediction_requires_modifier_in_indent_conflict: bool, + inlay_hint_cache: InlayHintCache, + next_inlay_id: usize, + _subscriptions: Vec, + pixel_position_of_newest_cursor: Option>, + gutter_dimensions: GutterDimensions, + style: Option, + text_style_refinement: Option, + next_editor_action_id: EditorActionId, + editor_actions: + Rc)>>>>, + use_autoclose: bool, + use_auto_surround: bool, + auto_replace_emoji_shortcode: bool, + jsx_tag_auto_close_enabled_in_any_buffer: bool, + show_git_blame_gutter: bool, + show_git_blame_inline: bool, + show_git_blame_inline_delay_task: Option>, + git_blame_inline_enabled: bool, + render_diff_hunk_controls: RenderDiffHunkControlsFn, + serialize_dirty_buffers: bool, + show_selection_menu: Option, + blame: Option>, + blame_subscription: Option, + custom_context_menu: Option< + Box< + dyn 'static + + Fn( + &mut Self, + DisplayPoint, + &mut Window, + &mut Context, + ) -> Option>, + >, + >, + last_bounds: Option>, + last_position_map: Option>, + expect_bounds_change: Option>, + tasks: BTreeMap<(BufferId, BufferRow), RunnableTasks>, + tasks_update_task: Option>, + breakpoint_store: Option>, + gutter_breakpoint_indicator: (Option, Option>), + in_project_search: bool, + previous_search_ranges: Option]>>, + breadcrumb_header: Option, + focused_block: Option, + next_scroll_position: NextScrollCursorCenterTopBottom, + addons: HashMap>, + registered_buffers: HashMap, + load_diff_task: Option>>, + selection_mark_mode: bool, + toggle_fold_multiple_buffers: Task<()>, + _scroll_cursor_center_top_bottom_task: Task<()>, + serialize_selections: Task<()>, + serialize_folds: Task<()>, + mouse_cursor_hidden: bool, + hide_mouse_mode: HideMouseMode, + pub change_list: ChangeList, + inline_value_cache: InlineValueCache, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)] +enum NextScrollCursorCenterTopBottom { + #[default] + Center, + Top, + Bottom, +} + +impl NextScrollCursorCenterTopBottom { + fn next(&self) -> Self { + match self { + Self::Center => Self::Top, + Self::Top => Self::Bottom, + Self::Bottom => Self::Center, + } + } +} + +#[derive(Clone)] +pub struct EditorSnapshot { + pub mode: EditorMode, + show_gutter: bool, + show_line_numbers: Option, + show_git_diff_gutter: Option, + show_code_actions: Option, + show_runnables: Option, + show_breakpoints: Option, + git_blame_gutter_max_author_length: Option, + pub display_snapshot: DisplaySnapshot, + pub placeholder_text: Option>, + is_focused: bool, + scroll_anchor: ScrollAnchor, + ongoing_scroll: OngoingScroll, + current_line_highlight: CurrentLineHighlight, + gutter_hovered: bool, +} + +#[derive(Default, Debug, Clone, Copy)] +pub struct GutterDimensions { + pub left_padding: Pixels, + pub right_padding: Pixels, + pub width: Pixels, + pub margin: Pixels, + pub git_blame_entries_width: Option, +} + +impl GutterDimensions { + /// The full width of the space taken up by the gutter. + pub fn full_width(&self) -> Pixels { + self.margin + self.width + } + + /// The width of the space reserved for the fold indicators, + /// use alongside 'justify_end' and `gutter_width` to + /// right align content with the line numbers + pub fn fold_area_width(&self) -> Pixels { + self.margin + self.right_padding + } +} + +#[derive(Debug)] +pub struct RemoteSelection { + pub replica_id: ReplicaId, + pub selection: Selection, + pub cursor_shape: CursorShape, + pub peer_id: PeerId, + pub line_mode: bool, + pub participant_index: Option, + pub user_name: Option, +} + +#[derive(Clone, Debug)] +struct SelectionHistoryEntry { + selections: Arc<[Selection]>, + select_next_state: Option, + select_prev_state: Option, + add_selections_state: Option, +} + +enum SelectionHistoryMode { + Normal, + Undoing, + Redoing, +} + +#[derive(Clone, PartialEq, Eq, Hash)] +struct HoveredCursor { + replica_id: u16, + selection_id: usize, +} + +impl Default for SelectionHistoryMode { + fn default() -> Self { + Self::Normal + } +} + +#[derive(Default)] +struct SelectionHistory { + #[allow(clippy::type_complexity)] + selections_by_transaction: + HashMap]>, Option]>>)>, + mode: SelectionHistoryMode, + undo_stack: VecDeque, + redo_stack: VecDeque, +} + +impl SelectionHistory { + fn insert_transaction( + &mut self, + transaction_id: TransactionId, + selections: Arc<[Selection]>, + ) { + self.selections_by_transaction + .insert(transaction_id, (selections, None)); + } + + #[allow(clippy::type_complexity)] + fn transaction( + &self, + transaction_id: TransactionId, + ) -> Option<&(Arc<[Selection]>, Option]>>)> { + self.selections_by_transaction.get(&transaction_id) + } + + #[allow(clippy::type_complexity)] + fn transaction_mut( + &mut self, + transaction_id: TransactionId, + ) -> Option<&mut (Arc<[Selection]>, Option]>>)> { + self.selections_by_transaction.get_mut(&transaction_id) + } + + fn push(&mut self, entry: SelectionHistoryEntry) { + if !entry.selections.is_empty() { + match self.mode { + SelectionHistoryMode::Normal => { + self.push_undo(entry); + self.redo_stack.clear(); + } + SelectionHistoryMode::Undoing => self.push_redo(entry), + SelectionHistoryMode::Redoing => self.push_undo(entry), + } + } + } + + fn push_undo(&mut self, entry: SelectionHistoryEntry) { + if self + .undo_stack + .back() + .map_or(true, |e| e.selections != entry.selections) + { + self.undo_stack.push_back(entry); + if self.undo_stack.len() > MAX_SELECTION_HISTORY_LEN { + self.undo_stack.pop_front(); + } + } + } + + fn push_redo(&mut self, entry: SelectionHistoryEntry) { + if self + .redo_stack + .back() + .map_or(true, |e| e.selections != entry.selections) + { + self.redo_stack.push_back(entry); + if self.redo_stack.len() > MAX_SELECTION_HISTORY_LEN { + self.redo_stack.pop_front(); + } + } + } +} + +#[derive(Clone, Copy)] +pub struct RowHighlightOptions { + pub autoscroll: bool, + pub include_gutter: bool, +} + +impl Default for RowHighlightOptions { + fn default() -> Self { + Self { + autoscroll: Default::default(), + include_gutter: true, + } + } +} + +struct RowHighlight { + index: usize, + range: Range, + color: Hsla, + options: RowHighlightOptions, + type_id: TypeId, +} + +#[derive(Clone, Debug)] +struct AddSelectionsState { + above: bool, + stack: Vec, +} + +#[derive(Clone)] +struct SelectNextState { + query: AhoCorasick, + wordwise: bool, + done: bool, +} + +impl std::fmt::Debug for SelectNextState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct(std::any::type_name::()) + .field("wordwise", &self.wordwise) + .field("done", &self.done) + .finish() + } +} + +#[derive(Debug)] +struct AutocloseRegion { + selection_id: usize, + range: Range, + pair: BracketPair, +} + +#[derive(Debug)] +struct SnippetState { + ranges: Vec>>, + active_index: usize, + choices: Vec>>, +} + +#[doc(hidden)] +pub struct RenameState { + pub range: Range, + pub old_name: Arc, + pub editor: Entity, + block_id: CustomBlockId, +} + +struct InvalidationStack(Vec); + +struct RegisteredInlineCompletionProvider { + provider: Arc, + _subscription: Subscription, +} + +#[derive(Debug, PartialEq, Eq)] +pub struct ActiveDiagnosticGroup { + pub active_range: Range, + pub active_message: String, + pub group_id: usize, + pub blocks: HashSet, +} + +#[derive(Debug, PartialEq, Eq)] + +pub(crate) enum ActiveDiagnostic { + None, + All, + Group(ActiveDiagnosticGroup), +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct ClipboardSelection { + /// The number of bytes in this selection. + pub len: usize, + /// Whether this was a full-line selection. + pub is_entire_line: bool, + /// The indentation of the first line when this content was originally copied. + pub first_line_indent: u32, +} + +// selections, scroll behavior, was newest selection reversed +type SelectSyntaxNodeHistoryState = ( + Box<[Selection]>, + SelectSyntaxNodeScrollBehavior, + bool, +); + +#[derive(Default)] +struct SelectSyntaxNodeHistory { + stack: Vec, + // disable temporarily to allow changing selections without losing the stack + pub disable_clearing: bool, +} + +impl SelectSyntaxNodeHistory { + pub fn try_clear(&mut self) { + if !self.disable_clearing { + self.stack.clear(); + } + } + + pub fn push(&mut self, selection: SelectSyntaxNodeHistoryState) { + self.stack.push(selection); + } + + pub fn pop(&mut self) -> Option { + self.stack.pop() + } +} + +enum SelectSyntaxNodeScrollBehavior { + CursorTop, + FitSelection, + CursorBottom, +} + +#[derive(Debug)] +pub(crate) struct NavigationData { + cursor_anchor: Anchor, + cursor_position: Point, + scroll_anchor: ScrollAnchor, + scroll_top_row: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GotoDefinitionKind { + Symbol, + Declaration, + Type, + Implementation, +} + +#[derive(Debug, Clone)] +enum InlayHintRefreshReason { + ModifiersChanged(bool), + Toggle(bool), + SettingsChange(InlayHintSettings), + NewLinesShown, + BufferEdited(HashSet>), + RefreshRequested, + ExcerptsRemoved(Vec), +} + +impl InlayHintRefreshReason { + fn description(&self) -> &'static str { + match self { + Self::ModifiersChanged(_) => "modifiers changed", + Self::Toggle(_) => "toggle", + Self::SettingsChange(_) => "settings change", + Self::NewLinesShown => "new lines shown", + Self::BufferEdited(_) => "buffer edited", + Self::RefreshRequested => "refresh requested", + Self::ExcerptsRemoved(_) => "excerpts removed", + } + } +} + +pub enum FormatTarget { + Buffers, + Ranges(Vec>), +} + +pub(crate) struct FocusedBlock { + id: BlockId, + focus_handle: WeakFocusHandle, +} + +#[derive(Clone)] +enum JumpData { + MultiBufferRow { + row: MultiBufferRow, + line_offset_from_top: u32, + }, + MultiBufferPoint { + excerpt_id: ExcerptId, + position: Point, + anchor: text::Anchor, + line_offset_from_top: u32, + }, +} + +pub enum MultibufferSelectionMode { + First, + All, +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct RewrapOptions { + pub override_language_settings: bool, + pub preserve_existing_whitespace: bool, +} + +impl Editor { + pub fn single_line(window: &mut Window, cx: &mut Context) -> Self { + let buffer = cx.new(|cx| Buffer::local("", cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + Self::new( + EditorMode::SingleLine { auto_width: false }, + buffer, + None, + window, + cx, + ) + } + + pub fn multi_line(window: &mut Window, cx: &mut Context) -> Self { + let buffer = cx.new(|cx| Buffer::local("", cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + Self::new(EditorMode::full(), buffer, None, window, cx) + } + + pub fn auto_width(window: &mut Window, cx: &mut Context) -> Self { + let buffer = cx.new(|cx| Buffer::local("", cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + Self::new( + EditorMode::SingleLine { auto_width: true }, + buffer, + None, + window, + cx, + ) + } + + pub fn auto_height(max_lines: usize, window: &mut Window, cx: &mut Context) -> Self { + let buffer = cx.new(|cx| Buffer::local("", cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + Self::new( + EditorMode::AutoHeight { max_lines }, + buffer, + None, + window, + cx, + ) + } + + pub fn for_buffer( + buffer: Entity, + project: Option>, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + Self::new(EditorMode::full(), buffer, project, window, cx) + } + + pub fn for_multibuffer( + buffer: Entity, + project: Option>, + window: &mut Window, + cx: &mut Context, + ) -> Self { + Self::new(EditorMode::full(), buffer, project, window, cx) + } + + pub fn clone(&self, window: &mut Window, cx: &mut Context) -> Self { + let mut clone = Self::new( + self.mode, + self.buffer.clone(), + self.project.clone(), + window, + cx, + ); + self.display_map.update(cx, |display_map, cx| { + let snapshot = display_map.snapshot(cx); + clone.display_map.update(cx, |display_map, cx| { + display_map.set_state(&snapshot, cx); + }); + }); + clone.folds_did_change(cx); + clone.selections.clone_state(&self.selections); + clone.scroll_manager.clone_state(&self.scroll_manager); + clone.searchable = self.searchable; + clone.read_only = self.read_only; + clone + } + + pub fn new( + mode: EditorMode, + buffer: Entity, + project: Option>, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let style = window.text_style(); + let font_size = style.font_size.to_pixels(window.rem_size()); + let editor = cx.entity().downgrade(); + let fold_placeholder = FoldPlaceholder { + constrain_width: true, + render: Arc::new(move |fold_id, fold_range, cx| { + let editor = editor.clone(); + div() + .id(fold_id) + .bg(cx.theme().colors().ghost_element_background) + .hover(|style| style.bg(cx.theme().colors().ghost_element_hover)) + .active(|style| style.bg(cx.theme().colors().ghost_element_active)) + .rounded_xs() + .size_full() + .cursor_pointer() + .child("⋯") + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_click(move |_, _window, cx| { + editor + .update(cx, |editor, cx| { + editor.unfold_ranges( + &[fold_range.start..fold_range.end], + true, + false, + cx, + ); + cx.stop_propagation(); + }) + .ok(); + }) + .into_any() + }), + merge_adjacent: true, + ..Default::default() + }; + let display_map = cx.new(|cx| { + DisplayMap::new( + buffer.clone(), + style.font(), + font_size, + None, + FILE_HEADER_HEIGHT, + MULTI_BUFFER_EXCERPT_HEADER_HEIGHT, + fold_placeholder, + cx, + ) + }); + + let selections = SelectionsCollection::new(display_map.clone(), buffer.clone()); + + let blink_manager = cx.new(|cx| BlinkManager::new(CURSOR_BLINK_INTERVAL, cx)); + + let soft_wrap_mode_override = matches!(mode, EditorMode::SingleLine { .. }) + .then(|| language_settings::SoftWrap::None); + + let mut project_subscriptions = Vec::new(); + if mode.is_full() { + if let Some(project) = project.as_ref() { + project_subscriptions.push(cx.subscribe_in( + project, + window, + |editor, _, event, window, cx| match event { + project::Event::RefreshCodeLens => { + // we always query lens with actions, without storing them, always refreshing them + } + project::Event::RefreshInlayHints => { + editor + .refresh_inlay_hints(InlayHintRefreshReason::RefreshRequested, cx); + } + project::Event::SnippetEdit(id, snippet_edits) => { + if let Some(buffer) = editor.buffer.read(cx).buffer(*id) { + let focus_handle = editor.focus_handle(cx); + if focus_handle.is_focused(window) { + let snapshot = buffer.read(cx).snapshot(); + for (range, snippet) in snippet_edits { + let editor_range = + language::range_from_lsp(*range).to_offset(&snapshot); + editor + .insert_snippet( + &[editor_range], + snippet.clone(), + window, + cx, + ) + .ok(); + } + } + } + } + _ => {} + }, + )); + if let Some(task_inventory) = project + .read(cx) + .task_store() + .read(cx) + .task_inventory() + .cloned() + { + project_subscriptions.push(cx.observe_in( + &task_inventory, + window, + |editor, _, window, cx| { + editor.tasks_update_task = Some(editor.refresh_runnables(window, cx)); + }, + )); + }; + + project_subscriptions.push(cx.subscribe_in( + &project.read(cx).breakpoint_store(), + window, + |editor, _, event, window, cx| match event { + BreakpointStoreEvent::ClearDebugLines => { + editor.clear_row_highlights::(); + editor.refresh_inline_values(cx); + } + BreakpointStoreEvent::SetDebugLine => { + if editor.go_to_active_debug_line(window, cx) { + cx.stop_propagation(); + } + + editor.refresh_inline_values(cx); + } + _ => {} + }, + )); + } + } + + let buffer_snapshot = buffer.read(cx).snapshot(cx); + + let inlay_hint_settings = + inlay_hint_settings(selections.newest_anchor().head(), &buffer_snapshot, cx); + let focus_handle = cx.focus_handle(); + cx.on_focus(&focus_handle, window, Self::handle_focus) + .detach(); + cx.on_focus_in(&focus_handle, window, Self::handle_focus_in) + .detach(); + cx.on_focus_out(&focus_handle, window, Self::handle_focus_out) + .detach(); + cx.on_blur(&focus_handle, window, Self::handle_blur) + .detach(); + + let show_indent_guides = if matches!(mode, EditorMode::SingleLine { .. }) { + Some(false) + } else { + None + }; + + let breakpoint_store = match (mode, project.as_ref()) { + (EditorMode::Full { .. }, Some(project)) => Some(project.read(cx).breakpoint_store()), + _ => None, + }; + + let mut code_action_providers = Vec::new(); + let mut load_uncommitted_diff = None; + if let Some(project) = project.clone() { + load_uncommitted_diff = Some( + get_uncommitted_diff_for_buffer( + &project, + buffer.read(cx).all_buffers(), + buffer.clone(), + cx, + ) + .shared(), + ); + code_action_providers.push(Rc::new(project) as Rc<_>); + } + + let mut this = Self { + focus_handle, + show_cursor_when_unfocused: false, + last_focused_descendant: None, + buffer: buffer.clone(), + display_map: display_map.clone(), + selections, + scroll_manager: ScrollManager::new(cx), + columnar_selection_tail: None, + add_selections_state: None, + select_next_state: None, + select_prev_state: None, + selection_history: Default::default(), + autoclose_regions: Default::default(), + snippet_stack: Default::default(), + select_syntax_node_history: SelectSyntaxNodeHistory::default(), + ime_transaction: Default::default(), + active_diagnostics: ActiveDiagnostic::None, + show_inline_diagnostics: ProjectSettings::get_global(cx).diagnostics.inline.enabled, + inline_diagnostics_update: Task::ready(()), + inline_diagnostics: Vec::new(), + soft_wrap_mode_override, + hard_wrap: None, + completion_provider: project.clone().map(|project| Box::new(project) as _), + semantics_provider: project.clone().map(|project| Rc::new(project) as _), + collaboration_hub: project.clone().map(|project| Box::new(project) as _), + project, + blink_manager: blink_manager.clone(), + show_local_selections: true, + show_scrollbars: true, + disable_scrolling: false, + mode, + show_breadcrumbs: EditorSettings::get_global(cx).toolbar.breadcrumbs, + show_gutter: mode.is_full(), + show_line_numbers: None, + use_relative_line_numbers: None, + disable_expand_excerpt_buttons: false, + show_git_diff_gutter: None, + show_code_actions: None, + show_runnables: None, + show_breakpoints: None, + show_wrap_guides: None, + show_indent_guides, + placeholder_text: None, + highlight_order: 0, + highlighted_rows: HashMap::default(), + background_highlights: Default::default(), + gutter_highlights: TreeMap::default(), + scrollbar_marker_state: ScrollbarMarkerState::default(), + active_indent_guides_state: ActiveIndentGuidesState::default(), + nav_history: None, + context_menu: RefCell::new(None), + context_menu_options: None, + mouse_context_menu: None, + completion_tasks: Default::default(), + inline_blame_popover: Default::default(), + signature_help_state: SignatureHelpState::default(), + auto_signature_help: None, + find_all_references_task_sources: Vec::new(), + next_completion_id: 0, + next_inlay_id: 0, + code_action_providers, + available_code_actions: Default::default(), + code_actions_task: Default::default(), + quick_selection_highlight_task: Default::default(), + debounced_selection_highlight_task: Default::default(), + document_highlights_task: Default::default(), + linked_editing_range_task: Default::default(), + pending_rename: Default::default(), + searchable: true, + cursor_shape: EditorSettings::get_global(cx) + .cursor_shape + .unwrap_or_default(), + current_line_highlight: None, + autoindent_mode: Some(AutoindentMode::EachLine), + collapse_matches: false, + workspace: None, + input_enabled: true, + use_modal_editing: mode.is_full(), + read_only: false, + use_autoclose: true, + use_auto_surround: true, + auto_replace_emoji_shortcode: false, + jsx_tag_auto_close_enabled_in_any_buffer: false, + leader_peer_id: None, + remote_id: None, + hover_state: Default::default(), + pending_mouse_down: None, + hovered_link_state: Default::default(), + edit_prediction_provider: None, + active_inline_completion: None, + stale_inline_completion_in_menu: None, + edit_prediction_preview: EditPredictionPreview::Inactive { + released_too_fast: false, + }, + inline_diagnostics_enabled: mode.is_full(), + inline_value_cache: InlineValueCache::new(inlay_hint_settings.show_value_hints), + inlay_hint_cache: InlayHintCache::new(inlay_hint_settings), + + gutter_hovered: false, + pixel_position_of_newest_cursor: None, + last_bounds: None, + last_position_map: None, + expect_bounds_change: None, + gutter_dimensions: GutterDimensions::default(), + style: None, + show_cursor_names: false, + hovered_cursors: Default::default(), + next_editor_action_id: EditorActionId::default(), + editor_actions: Rc::default(), + inline_completions_hidden_for_vim_mode: false, + show_inline_completions_override: None, + menu_inline_completions_policy: MenuInlineCompletionsPolicy::ByProvider, + edit_prediction_settings: EditPredictionSettings::Disabled, + edit_prediction_indent_conflict: false, + edit_prediction_requires_modifier_in_indent_conflict: true, + custom_context_menu: None, + show_git_blame_gutter: false, + show_git_blame_inline: false, + show_selection_menu: None, + show_git_blame_inline_delay_task: None, + git_blame_inline_enabled: ProjectSettings::get_global(cx).git.inline_blame_enabled(), + render_diff_hunk_controls: Arc::new(render_diff_hunk_controls), + serialize_dirty_buffers: ProjectSettings::get_global(cx) + .session + .restore_unsaved_buffers, + blame: None, + blame_subscription: None, + tasks: Default::default(), + + breakpoint_store, + gutter_breakpoint_indicator: (None, None), + _subscriptions: vec![ + cx.observe(&buffer, Self::on_buffer_changed), + cx.subscribe_in(&buffer, window, Self::on_buffer_event), + cx.observe_in(&display_map, window, Self::on_display_map_changed), + cx.observe(&blink_manager, |_, _, cx| cx.notify()), + cx.observe_global_in::(window, Self::settings_changed), + observe_buffer_font_size_adjustment(cx, |_, cx| cx.notify()), + cx.observe_window_activation(window, |editor, window, cx| { + let active = window.is_window_active(); + editor.blink_manager.update(cx, |blink_manager, cx| { + if active { + blink_manager.enable(cx); + } else { + blink_manager.disable(cx); + } + }); + }), + ], + tasks_update_task: None, + linked_edit_ranges: Default::default(), + in_project_search: false, + previous_search_ranges: None, + breadcrumb_header: None, + focused_block: None, + next_scroll_position: NextScrollCursorCenterTopBottom::default(), + addons: HashMap::default(), + registered_buffers: HashMap::default(), + _scroll_cursor_center_top_bottom_task: Task::ready(()), + selection_mark_mode: false, + toggle_fold_multiple_buffers: Task::ready(()), + serialize_selections: Task::ready(()), + serialize_folds: Task::ready(()), + text_style_refinement: None, + load_diff_task: load_uncommitted_diff, + mouse_cursor_hidden: false, + hide_mouse_mode: EditorSettings::get_global(cx) + .hide_mouse + .unwrap_or_default(), + change_list: ChangeList::new(), + }; + if let Some(breakpoints) = this.breakpoint_store.as_ref() { + this._subscriptions + .push(cx.observe(breakpoints, |_, _, cx| { + cx.notify(); + })); + } + this.tasks_update_task = Some(this.refresh_runnables(window, cx)); + this._subscriptions.extend(project_subscriptions); + + this._subscriptions.push(cx.subscribe_in( + &cx.entity(), + window, + |editor, _, e: &EditorEvent, window, cx| match e { + EditorEvent::ScrollPositionChanged { local, .. } => { + if *local { + let new_anchor = editor.scroll_manager.anchor(); + let snapshot = editor.snapshot(window, cx); + editor.update_restoration_data(cx, move |data| { + data.scroll_position = ( + new_anchor.top_row(&snapshot.buffer_snapshot), + new_anchor.offset, + ); + }); + editor.hide_signature_help(cx, SignatureHelpHiddenBy::Escape); + editor.inline_blame_popover.take(); + } + } + EditorEvent::Edited { .. } => { + if !vim_enabled(cx) { + let (map, selections) = editor.selections.all_adjusted_display(cx); + let pop_state = editor + .change_list + .last() + .map(|previous| { + previous.len() == selections.len() + && previous.iter().enumerate().all(|(ix, p)| { + p.to_display_point(&map).row() + == selections[ix].head().row() + }) + }) + .unwrap_or(false); + let new_positions = selections + .into_iter() + .map(|s| map.display_point_to_anchor(s.head(), Bias::Left)) + .collect(); + editor + .change_list + .push_to_change_list(pop_state, new_positions); + } + } + _ => (), + }, + )); + + if let Some(dap_store) = this + .project + .as_ref() + .map(|project| project.read(cx).dap_store()) + { + let weak_editor = cx.weak_entity(); + + this._subscriptions + .push( + cx.observe_new::(move |_, _, cx| { + let session_entity = cx.entity(); + weak_editor + .update(cx, |editor, cx| { + editor._subscriptions.push( + cx.subscribe(&session_entity, Self::on_debug_session_event), + ); + }) + .ok(); + }), + ); + + for session in dap_store.read(cx).sessions().cloned().collect::>() { + this._subscriptions + .push(cx.subscribe(&session, Self::on_debug_session_event)); + } + } + + this.end_selection(window, cx); + this.scroll_manager.show_scrollbars(window, cx); + jsx_tag_auto_close::refresh_enabled_in_any_buffer(&mut this, &buffer, cx); + + if mode.is_full() { + let should_auto_hide_scrollbars = cx.should_auto_hide_scrollbars(); + cx.set_global(ScrollbarAutoHide(should_auto_hide_scrollbars)); + + if this.git_blame_inline_enabled { + this.git_blame_inline_enabled = true; + this.start_git_blame_inline(false, window, cx); + } + + this.go_to_active_debug_line(window, cx); + + if let Some(buffer) = buffer.read(cx).as_singleton() { + if let Some(project) = this.project.as_ref() { + let handle = project.update(cx, |project, cx| { + project.register_buffer_with_language_servers(&buffer, cx) + }); + this.registered_buffers + .insert(buffer.read(cx).remote_id(), handle); + } + } + } + + this.report_editor_event("Editor Opened", None, cx); + this + } + + pub fn deploy_mouse_context_menu( + &mut self, + position: gpui::Point, + context_menu: Entity, + window: &mut Window, + cx: &mut Context, + ) { + self.mouse_context_menu = Some(MouseContextMenu::new( + self, + crate::mouse_context_menu::MenuPosition::PinnedToScreen(position), + context_menu, + window, + cx, + )); + } + + pub fn mouse_menu_is_focused(&self, window: &Window, cx: &App) -> bool { + self.mouse_context_menu + .as_ref() + .is_some_and(|menu| menu.context_menu.focus_handle(cx).is_focused(window)) + } + + fn key_context(&self, window: &Window, cx: &App) -> KeyContext { + self.key_context_internal(self.has_active_inline_completion(), window, cx) + } + + fn key_context_internal( + &self, + has_active_edit_prediction: bool, + window: &Window, + cx: &App, + ) -> KeyContext { + let mut key_context = KeyContext::new_with_defaults(); + key_context.add("Editor"); + let mode = match self.mode { + EditorMode::SingleLine { .. } => "single_line", + EditorMode::AutoHeight { .. } => "auto_height", + EditorMode::Full { .. } => "full", + }; + + if EditorSettings::jupyter_enabled(cx) { + key_context.add("jupyter"); + } + + key_context.set("mode", mode); + if self.pending_rename.is_some() { + key_context.add("renaming"); + } + + match self.context_menu.borrow().as_ref() { + Some(CodeContextMenu::Completions(_)) => { + key_context.add("menu"); + key_context.add("showing_completions"); + } + Some(CodeContextMenu::CodeActions(_)) => { + key_context.add("menu"); + key_context.add("showing_code_actions") + } + None => {} + } + + // Disable vim contexts when a sub-editor (e.g. rename/inline assistant) is focused. + if !self.focus_handle(cx).contains_focused(window, cx) + || (self.is_focused(window) || self.mouse_menu_is_focused(window, cx)) + { + for addon in self.addons.values() { + addon.extend_key_context(&mut key_context, cx) + } + } + + if let Some(singleton_buffer) = self.buffer.read(cx).as_singleton() { + if let Some(extension) = singleton_buffer + .read(cx) + .file() + .and_then(|file| file.path().extension()?.to_str()) + { + key_context.set("extension", extension.to_string()); + } + } else { + key_context.add("multibuffer"); + } + + if has_active_edit_prediction { + if self.edit_prediction_in_conflict() { + key_context.add(EDIT_PREDICTION_CONFLICT_KEY_CONTEXT); + } else { + key_context.add(EDIT_PREDICTION_KEY_CONTEXT); + key_context.add("copilot_suggestion"); + } + } + + if self.selection_mark_mode { + key_context.add("selection_mode"); + } + + key_context + } + + pub fn hide_mouse_cursor(&mut self, origin: &HideMouseCursorOrigin) { + self.mouse_cursor_hidden = match origin { + HideMouseCursorOrigin::TypingAction => { + matches!( + self.hide_mouse_mode, + HideMouseMode::OnTyping | HideMouseMode::OnTypingAndMovement + ) + } + HideMouseCursorOrigin::MovementAction => { + matches!(self.hide_mouse_mode, HideMouseMode::OnTypingAndMovement) + } + }; + } + + pub fn edit_prediction_in_conflict(&self) -> bool { + if !self.show_edit_predictions_in_menu() { + return false; + } + + let showing_completions = self + .context_menu + .borrow() + .as_ref() + .map_or(false, |context| { + matches!(context, CodeContextMenu::Completions(_)) + }); + + showing_completions + || self.edit_prediction_requires_modifier() + // Require modifier key when the cursor is on leading whitespace, to allow `tab` + // bindings to insert tab characters. + || (self.edit_prediction_requires_modifier_in_indent_conflict && self.edit_prediction_indent_conflict) + } + + pub fn accept_edit_prediction_keybind( + &self, + window: &Window, + cx: &App, + ) -> AcceptEditPredictionBinding { + let key_context = self.key_context_internal(true, window, cx); + let in_conflict = self.edit_prediction_in_conflict(); + + AcceptEditPredictionBinding( + window + .bindings_for_action_in_context(&AcceptEditPrediction, key_context) + .into_iter() + .filter(|binding| { + !in_conflict + || binding + .keystrokes() + .first() + .map_or(false, |keystroke| keystroke.modifiers.modified()) + }) + .rev() + .min_by_key(|binding| { + binding + .keystrokes() + .first() + .map_or(u8::MAX, |k| k.modifiers.number_of_modifiers()) + }), + ) + } + + pub fn new_file( + workspace: &mut Workspace, + _: &workspace::NewFile, + window: &mut Window, + cx: &mut Context, + ) { + Self::new_in_workspace(workspace, window, cx).detach_and_prompt_err( + "Failed to create buffer", + window, + cx, + |e, _, _| match e.error_code() { + ErrorCode::RemoteUpgradeRequired => Some(format!( + "The remote instance of Zed does not support this yet. It must be upgraded to {}", + e.error_tag("required").unwrap_or("the latest version") + )), + _ => None, + }, + ); + } + + pub fn new_in_workspace( + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, + ) -> Task>> { + let project = workspace.project().clone(); + let create = project.update(cx, |project, cx| project.create_buffer(cx)); + + cx.spawn_in(window, async move |workspace, cx| { + let buffer = create.await?; + workspace.update_in(cx, |workspace, window, cx| { + let editor = + cx.new(|cx| Editor::for_buffer(buffer, Some(project.clone()), window, cx)); + workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx); + editor + }) + }) + } + + fn new_file_vertical( + workspace: &mut Workspace, + _: &workspace::NewFileSplitVertical, + window: &mut Window, + cx: &mut Context, + ) { + Self::new_file_in_direction(workspace, SplitDirection::vertical(cx), window, cx) + } + + fn new_file_horizontal( + workspace: &mut Workspace, + _: &workspace::NewFileSplitHorizontal, + window: &mut Window, + cx: &mut Context, + ) { + Self::new_file_in_direction(workspace, SplitDirection::horizontal(cx), window, cx) + } + + fn new_file_in_direction( + workspace: &mut Workspace, + direction: SplitDirection, + window: &mut Window, + cx: &mut Context, + ) { + let project = workspace.project().clone(); + let create = project.update(cx, |project, cx| project.create_buffer(cx)); + + cx.spawn_in(window, async move |workspace, cx| { + let buffer = create.await?; + workspace.update_in(cx, move |workspace, window, cx| { + workspace.split_item( + direction, + Box::new( + cx.new(|cx| Editor::for_buffer(buffer, Some(project.clone()), window, cx)), + ), + window, + cx, + ) + })?; + anyhow::Ok(()) + }) + .detach_and_prompt_err("Failed to create buffer", window, cx, |e, _, _| { + match e.error_code() { + ErrorCode::RemoteUpgradeRequired => Some(format!( + "The remote instance of Zed does not support this yet. It must be upgraded to {}", + e.error_tag("required").unwrap_or("the latest version") + )), + _ => None, + } + }); + } + + pub fn leader_peer_id(&self) -> Option { + self.leader_peer_id + } + + pub fn buffer(&self) -> &Entity { + &self.buffer + } + + pub fn workspace(&self) -> Option> { + self.workspace.as_ref()?.0.upgrade() + } + + pub fn title<'a>(&self, cx: &'a App) -> Cow<'a, str> { + self.buffer().read(cx).title(cx) + } + + pub fn snapshot(&self, window: &mut Window, cx: &mut App) -> EditorSnapshot { + let git_blame_gutter_max_author_length = self + .render_git_blame_gutter(cx) + .then(|| { + if let Some(blame) = self.blame.as_ref() { + let max_author_length = + blame.update(cx, |blame, cx| blame.max_author_length(cx)); + Some(max_author_length) + } else { + None + } + }) + .flatten(); + + EditorSnapshot { + mode: self.mode, + show_gutter: self.show_gutter, + show_line_numbers: self.show_line_numbers, + show_git_diff_gutter: self.show_git_diff_gutter, + show_code_actions: self.show_code_actions, + show_runnables: self.show_runnables, + show_breakpoints: self.show_breakpoints, + git_blame_gutter_max_author_length, + display_snapshot: self.display_map.update(cx, |map, cx| map.snapshot(cx)), + scroll_anchor: self.scroll_manager.anchor(), + ongoing_scroll: self.scroll_manager.ongoing_scroll(), + placeholder_text: self.placeholder_text.clone(), + is_focused: self.focus_handle.is_focused(window), + current_line_highlight: self + .current_line_highlight + .unwrap_or_else(|| EditorSettings::get_global(cx).current_line_highlight), + gutter_hovered: self.gutter_hovered, + } + } + + pub fn language_at(&self, point: T, cx: &App) -> Option> { + self.buffer.read(cx).language_at(point, cx) + } + + pub fn file_at(&self, point: T, cx: &App) -> Option> { + self.buffer.read(cx).read(cx).file_at(point).cloned() + } + + pub fn active_excerpt( + &self, + cx: &App, + ) -> Option<(ExcerptId, Entity, Range)> { + self.buffer + .read(cx) + .excerpt_containing(self.selections.newest_anchor().head(), cx) + } + + pub fn mode(&self) -> EditorMode { + self.mode + } + + pub fn set_mode(&mut self, mode: EditorMode) { + self.mode = mode; + } + + pub fn collaboration_hub(&self) -> Option<&dyn CollaborationHub> { + self.collaboration_hub.as_deref() + } + + pub fn set_collaboration_hub(&mut self, hub: Box) { + self.collaboration_hub = Some(hub); + } + + pub fn set_in_project_search(&mut self, in_project_search: bool) { + self.in_project_search = in_project_search; + } + + pub fn set_custom_context_menu( + &mut self, + f: impl 'static + + Fn( + &mut Self, + DisplayPoint, + &mut Window, + &mut Context, + ) -> Option>, + ) { + self.custom_context_menu = Some(Box::new(f)) + } + + pub fn set_completion_provider(&mut self, provider: Option>) { + self.completion_provider = provider; + } + + pub fn semantics_provider(&self) -> Option> { + self.semantics_provider.clone() + } + + pub fn set_semantics_provider(&mut self, provider: Option>) { + self.semantics_provider = provider; + } + + pub fn set_edit_prediction_provider( + &mut self, + provider: Option>, + window: &mut Window, + cx: &mut Context, + ) where + T: EditPredictionProvider, + { + self.edit_prediction_provider = + provider.map(|provider| RegisteredInlineCompletionProvider { + _subscription: cx.observe_in(&provider, window, |this, _, window, cx| { + if this.focus_handle.is_focused(window) { + this.update_visible_inline_completion(window, cx); + } + }), + provider: Arc::new(provider), + }); + self.update_edit_prediction_settings(cx); + self.refresh_inline_completion(false, false, window, cx); + } + + pub fn placeholder_text(&self) -> Option<&str> { + self.placeholder_text.as_deref() + } + + pub fn set_placeholder_text( + &mut self, + placeholder_text: impl Into>, + cx: &mut Context, + ) { + let placeholder_text = Some(placeholder_text.into()); + if self.placeholder_text != placeholder_text { + self.placeholder_text = placeholder_text; + cx.notify(); + } + } + + pub fn set_cursor_shape(&mut self, cursor_shape: CursorShape, cx: &mut Context) { + self.cursor_shape = cursor_shape; + + // Disrupt blink for immediate user feedback that the cursor shape has changed + self.blink_manager.update(cx, BlinkManager::show_cursor); + + cx.notify(); + } + + pub fn set_current_line_highlight( + &mut self, + current_line_highlight: Option, + ) { + self.current_line_highlight = current_line_highlight; + } + + pub fn set_collapse_matches(&mut self, collapse_matches: bool) { + self.collapse_matches = collapse_matches; + } + + fn register_buffers_with_language_servers(&mut self, cx: &mut Context) { + let buffers = self.buffer.read(cx).all_buffers(); + let Some(project) = self.project.as_ref() else { + return; + }; + project.update(cx, |project, cx| { + for buffer in buffers { + self.registered_buffers + .entry(buffer.read(cx).remote_id()) + .or_insert_with(|| project.register_buffer_with_language_servers(&buffer, cx)); + } + }) + } + + pub fn range_for_match(&self, range: &Range) -> Range { + if self.collapse_matches { + return range.start..range.start; + } + range.clone() + } + + pub fn set_clip_at_line_ends(&mut self, clip: bool, cx: &mut Context) { + if self.display_map.read(cx).clip_at_line_ends != clip { + self.display_map + .update(cx, |map, _| map.clip_at_line_ends = clip); + } + } + + pub fn set_input_enabled(&mut self, input_enabled: bool) { + self.input_enabled = input_enabled; + } + + pub fn set_inline_completions_hidden_for_vim_mode( + &mut self, + hidden: bool, + window: &mut Window, + cx: &mut Context, + ) { + if hidden != self.inline_completions_hidden_for_vim_mode { + self.inline_completions_hidden_for_vim_mode = hidden; + if hidden { + self.update_visible_inline_completion(window, cx); + } else { + self.refresh_inline_completion(true, false, window, cx); + } + } + } + + pub fn set_menu_inline_completions_policy(&mut self, value: MenuInlineCompletionsPolicy) { + self.menu_inline_completions_policy = value; + } + + pub fn set_autoindent(&mut self, autoindent: bool) { + if autoindent { + self.autoindent_mode = Some(AutoindentMode::EachLine); + } else { + self.autoindent_mode = None; + } + } + + pub fn read_only(&self, cx: &App) -> bool { + self.read_only || self.buffer.read(cx).read_only() + } + + pub fn set_read_only(&mut self, read_only: bool) { + self.read_only = read_only; + } + + pub fn set_use_autoclose(&mut self, autoclose: bool) { + self.use_autoclose = autoclose; + } + + pub fn set_use_auto_surround(&mut self, auto_surround: bool) { + self.use_auto_surround = auto_surround; + } + + pub fn set_auto_replace_emoji_shortcode(&mut self, auto_replace: bool) { + self.auto_replace_emoji_shortcode = auto_replace; + } + + pub fn toggle_edit_predictions( + &mut self, + _: &ToggleEditPrediction, + window: &mut Window, + cx: &mut Context, + ) { + if self.show_inline_completions_override.is_some() { + self.set_show_edit_predictions(None, window, cx); + } else { + let show_edit_predictions = !self.edit_predictions_enabled(); + self.set_show_edit_predictions(Some(show_edit_predictions), window, cx); + } + } + + pub fn set_show_edit_predictions( + &mut self, + show_edit_predictions: Option, + window: &mut Window, + cx: &mut Context, + ) { + self.show_inline_completions_override = show_edit_predictions; + self.update_edit_prediction_settings(cx); + + if let Some(false) = show_edit_predictions { + self.discard_inline_completion(false, cx); + } else { + self.refresh_inline_completion(false, true, window, cx); + } + } + + fn inline_completions_disabled_in_scope( + &self, + buffer: &Entity, + buffer_position: language::Anchor, + cx: &App, + ) -> bool { + let snapshot = buffer.read(cx).snapshot(); + let settings = snapshot.settings_at(buffer_position, cx); + + let Some(scope) = snapshot.language_scope_at(buffer_position) else { + return false; + }; + + scope.override_name().map_or(false, |scope_name| { + settings + .edit_predictions_disabled_in + .iter() + .any(|s| s == scope_name) + }) + } + + pub fn set_use_modal_editing(&mut self, to: bool) { + self.use_modal_editing = to; + } + + pub fn use_modal_editing(&self) -> bool { + self.use_modal_editing + } + + fn selections_did_change( + &mut self, + local: bool, + old_cursor_position: &Anchor, + show_completions: bool, + window: &mut Window, + cx: &mut Context, + ) { + window.invalidate_character_coordinates(); + + // Copy selections to primary selection buffer + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + if local { + let selections = self.selections.all::(cx); + let buffer_handle = self.buffer.read(cx).read(cx); + + let mut text = String::new(); + for (index, selection) in selections.iter().enumerate() { + let text_for_selection = buffer_handle + .text_for_range(selection.start..selection.end) + .collect::(); + + text.push_str(&text_for_selection); + if index != selections.len() - 1 { + text.push('\n'); + } + } + + if !text.is_empty() { + cx.write_to_primary(ClipboardItem::new_string(text)); + } + } + + if self.focus_handle.is_focused(window) && self.leader_peer_id.is_none() { + self.buffer.update(cx, |buffer, cx| { + buffer.set_active_selections( + &self.selections.disjoint_anchors(), + self.selections.line_mode, + self.cursor_shape, + cx, + ) + }); + } + let display_map = self + .display_map + .update(cx, |display_map, cx| display_map.snapshot(cx)); + let buffer = &display_map.buffer_snapshot; + self.add_selections_state = None; + self.select_next_state = None; + self.select_prev_state = None; + self.select_syntax_node_history.try_clear(); + self.invalidate_autoclose_regions(&self.selections.disjoint_anchors(), buffer); + self.snippet_stack + .invalidate(&self.selections.disjoint_anchors(), buffer); + self.take_rename(false, window, cx); + + let new_cursor_position = self.selections.newest_anchor().head(); + + self.push_to_nav_history( + *old_cursor_position, + Some(new_cursor_position.to_point(buffer)), + false, + cx, + ); + + if local { + let new_cursor_position = self.selections.newest_anchor().head(); + let mut context_menu = self.context_menu.borrow_mut(); + let completion_menu = match context_menu.as_ref() { + Some(CodeContextMenu::Completions(menu)) => Some(menu), + _ => { + *context_menu = None; + None + } + }; + if let Some(buffer_id) = new_cursor_position.buffer_id { + if !self.registered_buffers.contains_key(&buffer_id) { + if let Some(project) = self.project.as_ref() { + project.update(cx, |project, cx| { + let Some(buffer) = self.buffer.read(cx).buffer(buffer_id) else { + return; + }; + self.registered_buffers.insert( + buffer_id, + project.register_buffer_with_language_servers(&buffer, cx), + ); + }) + } + } + } + + if let Some(completion_menu) = completion_menu { + let cursor_position = new_cursor_position.to_offset(buffer); + let (word_range, kind) = + buffer.surrounding_word(completion_menu.initial_position, true); + if kind == Some(CharKind::Word) + && word_range.to_inclusive().contains(&cursor_position) + { + let mut completion_menu = completion_menu.clone(); + drop(context_menu); + + let query = Self::completion_query(buffer, cursor_position); + cx.spawn(async move |this, cx| { + completion_menu + .filter(query.as_deref(), cx.background_executor().clone()) + .await; + + this.update(cx, |this, cx| { + let mut context_menu = this.context_menu.borrow_mut(); + let Some(CodeContextMenu::Completions(menu)) = context_menu.as_ref() + else { + return; + }; + + if menu.id > completion_menu.id { + return; + } + + *context_menu = Some(CodeContextMenu::Completions(completion_menu)); + drop(context_menu); + cx.notify(); + }) + }) + .detach(); + + if show_completions { + self.show_completions(&ShowCompletions { trigger: None }, window, cx); + } + } else { + drop(context_menu); + self.hide_context_menu(window, cx); + } + } else { + drop(context_menu); + } + + hide_hover(self, cx); + + if old_cursor_position.to_display_point(&display_map).row() + != new_cursor_position.to_display_point(&display_map).row() + { + self.available_code_actions.take(); + } + self.refresh_code_actions(window, cx); + self.refresh_document_highlights(cx); + self.refresh_selected_text_highlights(false, window, cx); + refresh_matching_bracket_highlights(self, window, cx); + self.update_visible_inline_completion(window, cx); + self.edit_prediction_requires_modifier_in_indent_conflict = true; + linked_editing_ranges::refresh_linked_ranges(self, window, cx); + self.inline_blame_popover.take(); + if self.git_blame_inline_enabled { + self.start_inline_blame_timer(window, cx); + } + } + + self.blink_manager.update(cx, BlinkManager::pause_blinking); + cx.emit(EditorEvent::SelectionsChanged { local }); + + let selections = &self.selections.disjoint; + if selections.len() == 1 { + cx.emit(SearchEvent::ActiveMatchChanged) + } + if local { + if let Some((_, _, buffer_snapshot)) = buffer.as_singleton() { + let inmemory_selections = selections + .iter() + .map(|s| { + text::ToPoint::to_point(&s.range().start.text_anchor, buffer_snapshot) + ..text::ToPoint::to_point(&s.range().end.text_anchor, buffer_snapshot) + }) + .collect(); + self.update_restoration_data(cx, |data| { + data.selections = inmemory_selections; + }); + + if WorkspaceSettings::get(None, cx).restore_on_startup + != RestoreOnStartupBehavior::None + { + if let Some(workspace_id) = + self.workspace.as_ref().and_then(|workspace| workspace.1) + { + let snapshot = self.buffer().read(cx).snapshot(cx); + let selections = selections.clone(); + let background_executor = cx.background_executor().clone(); + let editor_id = cx.entity().entity_id().as_u64() as ItemId; + self.serialize_selections = cx.background_spawn(async move { + background_executor.timer(SERIALIZATION_THROTTLE_TIME).await; + let db_selections = selections + .iter() + .map(|selection| { + ( + selection.start.to_offset(&snapshot), + selection.end.to_offset(&snapshot), + ) + }) + .collect(); + + DB.save_editor_selections(editor_id, workspace_id, db_selections) + .await + .with_context(|| format!("persisting editor selections for editor {editor_id}, workspace {workspace_id:?}")) + .log_err(); + }); + } + } + } + } + + cx.notify(); + } + + fn folds_did_change(&mut self, cx: &mut Context) { + use text::ToOffset as _; + use text::ToPoint as _; + + if WorkspaceSettings::get(None, cx).restore_on_startup == RestoreOnStartupBehavior::None { + return; + } + + let Some(singleton) = self.buffer().read(cx).as_singleton() else { + return; + }; + + let snapshot = singleton.read(cx).snapshot(); + let inmemory_folds = self.display_map.update(cx, |display_map, cx| { + let display_snapshot = display_map.snapshot(cx); + + display_snapshot + .folds_in_range(0..display_snapshot.buffer_snapshot.len()) + .map(|fold| { + fold.range.start.text_anchor.to_point(&snapshot) + ..fold.range.end.text_anchor.to_point(&snapshot) + }) + .collect() + }); + self.update_restoration_data(cx, |data| { + data.folds = inmemory_folds; + }); + + let Some(workspace_id) = self.workspace.as_ref().and_then(|workspace| workspace.1) else { + return; + }; + let background_executor = cx.background_executor().clone(); + let editor_id = cx.entity().entity_id().as_u64() as ItemId; + let db_folds = self.display_map.update(cx, |display_map, cx| { + display_map + .snapshot(cx) + .folds_in_range(0..snapshot.len()) + .map(|fold| { + ( + fold.range.start.text_anchor.to_offset(&snapshot), + fold.range.end.text_anchor.to_offset(&snapshot), + ) + }) + .collect() + }); + self.serialize_folds = cx.background_spawn(async move { + background_executor.timer(SERIALIZATION_THROTTLE_TIME).await; + DB.save_editor_folds(editor_id, workspace_id, db_folds) + .await + .with_context(|| { + format!( + "persisting editor folds for editor {editor_id}, workspace {workspace_id:?}" + ) + }) + .log_err(); + }); + } + + pub fn sync_selections( + &mut self, + other: Entity, + cx: &mut Context, + ) -> gpui::Subscription { + let other_selections = other.read(cx).selections.disjoint.to_vec(); + self.selections.change_with(cx, |selections| { + selections.select_anchors(other_selections); + }); + + let other_subscription = + cx.subscribe(&other, |this, other, other_evt, cx| match other_evt { + EditorEvent::SelectionsChanged { local: true } => { + let other_selections = other.read(cx).selections.disjoint.to_vec(); + if other_selections.is_empty() { + return; + } + this.selections.change_with(cx, |selections| { + selections.select_anchors(other_selections); + }); + } + _ => {} + }); + + let this_subscription = + cx.subscribe_self::(move |this, this_evt, cx| match this_evt { + EditorEvent::SelectionsChanged { local: true } => { + let these_selections = this.selections.disjoint.to_vec(); + if these_selections.is_empty() { + return; + } + other.update(cx, |other_editor, cx| { + other_editor.selections.change_with(cx, |selections| { + selections.select_anchors(these_selections); + }) + }); + } + _ => {} + }); + + Subscription::join(other_subscription, this_subscription) + } + + pub fn change_selections( + &mut self, + autoscroll: Option, + window: &mut Window, + cx: &mut Context, + change: impl FnOnce(&mut MutableSelectionsCollection<'_>) -> R, + ) -> R { + self.change_selections_inner(autoscroll, true, window, cx, change) + } + + fn change_selections_inner( + &mut self, + autoscroll: Option, + request_completions: bool, + window: &mut Window, + cx: &mut Context, + change: impl FnOnce(&mut MutableSelectionsCollection<'_>) -> R, + ) -> R { + let old_cursor_position = self.selections.newest_anchor().head(); + self.push_to_selection_history(); + + let (changed, result) = self.selections.change_with(cx, change); + + if changed { + if let Some(autoscroll) = autoscroll { + self.request_autoscroll(autoscroll, cx); + } + self.selections_did_change(true, &old_cursor_position, request_completions, window, cx); + + if self.should_open_signature_help_automatically( + &old_cursor_position, + self.signature_help_state.backspace_pressed(), + cx, + ) { + self.show_signature_help(&ShowSignatureHelp, window, cx); + } + self.signature_help_state.set_backspace_pressed(false); + } + + result + } + + pub fn edit(&mut self, edits: I, cx: &mut Context) + where + I: IntoIterator, T)>, + S: ToOffset, + T: Into>, + { + if self.read_only(cx) { + return; + } + + self.buffer + .update(cx, |buffer, cx| buffer.edit(edits, None, cx)); + } + + pub fn edit_with_autoindent(&mut self, edits: I, cx: &mut Context) + where + I: IntoIterator, T)>, + S: ToOffset, + T: Into>, + { + if self.read_only(cx) { + return; + } + + self.buffer.update(cx, |buffer, cx| { + buffer.edit(edits, self.autoindent_mode.clone(), cx) + }); + } + + pub fn edit_with_block_indent( + &mut self, + edits: I, + original_indent_columns: Vec>, + cx: &mut Context, + ) where + I: IntoIterator, T)>, + S: ToOffset, + T: Into>, + { + if self.read_only(cx) { + return; + } + + self.buffer.update(cx, |buffer, cx| { + buffer.edit( + edits, + Some(AutoindentMode::Block { + original_indent_columns, + }), + cx, + ) + }); + } + + fn select(&mut self, phase: SelectPhase, window: &mut Window, cx: &mut Context) { + self.hide_context_menu(window, cx); + + match phase { + SelectPhase::Begin { + position, + add, + click_count, + } => self.begin_selection(position, add, click_count, window, cx), + SelectPhase::BeginColumnar { + position, + goal_column, + reset, + } => self.begin_columnar_selection(position, goal_column, reset, window, cx), + SelectPhase::Extend { + position, + click_count, + } => self.extend_selection(position, click_count, window, cx), + SelectPhase::Update { + position, + goal_column, + scroll_delta, + } => self.update_selection(position, goal_column, scroll_delta, window, cx), + SelectPhase::End => self.end_selection(window, cx), + } + } + + fn extend_selection( + &mut self, + position: DisplayPoint, + click_count: usize, + window: &mut Window, + cx: &mut Context, + ) { + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let tail = self.selections.newest::(cx).tail(); + self.begin_selection(position, false, click_count, window, cx); + + let position = position.to_offset(&display_map, Bias::Left); + let tail_anchor = display_map.buffer_snapshot.anchor_before(tail); + + let mut pending_selection = self + .selections + .pending_anchor() + .expect("extend_selection not called with pending selection"); + if position >= tail { + pending_selection.start = tail_anchor; + } else { + pending_selection.end = tail_anchor; + pending_selection.reversed = true; + } + + let mut pending_mode = self.selections.pending_mode().unwrap(); + match &mut pending_mode { + SelectMode::Word(range) | SelectMode::Line(range) => *range = tail_anchor..tail_anchor, + _ => {} + } + + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.set_pending(pending_selection, pending_mode) + }); + } + + fn begin_selection( + &mut self, + position: DisplayPoint, + add: bool, + click_count: usize, + window: &mut Window, + cx: &mut Context, + ) { + if !self.focus_handle.is_focused(window) { + self.last_focused_descendant = None; + window.focus(&self.focus_handle); + } + + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let buffer = &display_map.buffer_snapshot; + let newest_selection = self.selections.newest_anchor().clone(); + let position = display_map.clip_point(position, Bias::Left); + + let start; + let end; + let mode; + let mut auto_scroll; + match click_count { + 1 => { + start = buffer.anchor_before(position.to_point(&display_map)); + end = start; + mode = SelectMode::Character; + auto_scroll = true; + } + 2 => { + let range = movement::surrounding_word(&display_map, position); + start = buffer.anchor_before(range.start.to_point(&display_map)); + end = buffer.anchor_before(range.end.to_point(&display_map)); + mode = SelectMode::Word(start..end); + auto_scroll = true; + } + 3 => { + let position = display_map + .clip_point(position, Bias::Left) + .to_point(&display_map); + let line_start = display_map.prev_line_boundary(position).0; + let next_line_start = buffer.clip_point( + display_map.next_line_boundary(position).0 + Point::new(1, 0), + Bias::Left, + ); + start = buffer.anchor_before(line_start); + end = buffer.anchor_before(next_line_start); + mode = SelectMode::Line(start..end); + auto_scroll = true; + } + _ => { + start = buffer.anchor_before(0); + end = buffer.anchor_before(buffer.len()); + mode = SelectMode::All; + auto_scroll = false; + } + } + auto_scroll &= EditorSettings::get_global(cx).autoscroll_on_clicks; + + let point_to_delete: Option = { + let selected_points: Vec> = + self.selections.disjoint_in_range(start..end, cx); + + if !add || click_count > 1 { + None + } else if !selected_points.is_empty() { + Some(selected_points[0].id) + } else { + let clicked_point_already_selected = + self.selections.disjoint.iter().find(|selection| { + selection.start.to_point(buffer) == start.to_point(buffer) + || selection.end.to_point(buffer) == end.to_point(buffer) + }); + + clicked_point_already_selected.map(|selection| selection.id) + } + }; + + let selections_count = self.selections.count(); + + self.change_selections(auto_scroll.then(Autoscroll::newest), window, cx, |s| { + if let Some(point_to_delete) = point_to_delete { + s.delete(point_to_delete); + + if selections_count == 1 { + s.set_pending_anchor_range(start..end, mode); + } + } else { + if !add { + s.clear_disjoint(); + } else if click_count > 1 { + s.delete(newest_selection.id) + } + + s.set_pending_anchor_range(start..end, mode); + } + }); + } + + fn begin_columnar_selection( + &mut self, + position: DisplayPoint, + goal_column: u32, + reset: bool, + window: &mut Window, + cx: &mut Context, + ) { + if !self.focus_handle.is_focused(window) { + self.last_focused_descendant = None; + window.focus(&self.focus_handle); + } + + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + + if reset { + let pointer_position = display_map + .buffer_snapshot + .anchor_before(position.to_point(&display_map)); + + self.change_selections(Some(Autoscroll::newest()), window, cx, |s| { + s.clear_disjoint(); + s.set_pending_anchor_range( + pointer_position..pointer_position, + SelectMode::Character, + ); + }); + } + + let tail = self.selections.newest::(cx).tail(); + self.columnar_selection_tail = Some(display_map.buffer_snapshot.anchor_before(tail)); + + if !reset { + self.select_columns( + tail.to_display_point(&display_map), + position, + goal_column, + &display_map, + window, + cx, + ); + } + } + + fn update_selection( + &mut self, + position: DisplayPoint, + goal_column: u32, + scroll_delta: gpui::Point, + window: &mut Window, + cx: &mut Context, + ) { + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + + if let Some(tail) = self.columnar_selection_tail.as_ref() { + let tail = tail.to_display_point(&display_map); + self.select_columns(tail, position, goal_column, &display_map, window, cx); + } else if let Some(mut pending) = self.selections.pending_anchor() { + let buffer = self.buffer.read(cx).snapshot(cx); + let head; + let tail; + let mode = self.selections.pending_mode().unwrap(); + match &mode { + SelectMode::Character => { + head = position.to_point(&display_map); + tail = pending.tail().to_point(&buffer); + } + SelectMode::Word(original_range) => { + let original_display_range = original_range.start.to_display_point(&display_map) + ..original_range.end.to_display_point(&display_map); + let original_buffer_range = original_display_range.start.to_point(&display_map) + ..original_display_range.end.to_point(&display_map); + if movement::is_inside_word(&display_map, position) + || original_display_range.contains(&position) + { + let word_range = movement::surrounding_word(&display_map, position); + if word_range.start < original_display_range.start { + head = word_range.start.to_point(&display_map); + } else { + head = word_range.end.to_point(&display_map); + } + } else { + head = position.to_point(&display_map); + } + + if head <= original_buffer_range.start { + tail = original_buffer_range.end; + } else { + tail = original_buffer_range.start; + } + } + SelectMode::Line(original_range) => { + let original_range = original_range.to_point(&display_map.buffer_snapshot); + + let position = display_map + .clip_point(position, Bias::Left) + .to_point(&display_map); + let line_start = display_map.prev_line_boundary(position).0; + let next_line_start = buffer.clip_point( + display_map.next_line_boundary(position).0 + Point::new(1, 0), + Bias::Left, + ); + + if line_start < original_range.start { + head = line_start + } else { + head = next_line_start + } + + if head <= original_range.start { + tail = original_range.end; + } else { + tail = original_range.start; + } + } + SelectMode::All => { + return; + } + }; + + if head < tail { + pending.start = buffer.anchor_before(head); + pending.end = buffer.anchor_before(tail); + pending.reversed = true; + } else { + pending.start = buffer.anchor_before(tail); + pending.end = buffer.anchor_before(head); + pending.reversed = false; + } + + self.change_selections(None, window, cx, |s| { + s.set_pending(pending, mode); + }); + } else { + log::error!("update_selection dispatched with no pending selection"); + return; + } + + self.apply_scroll_delta(scroll_delta, window, cx); + cx.notify(); + } + + fn end_selection(&mut self, window: &mut Window, cx: &mut Context) { + self.columnar_selection_tail.take(); + if self.selections.pending_anchor().is_some() { + let selections = self.selections.all::(cx); + self.change_selections(None, window, cx, |s| { + s.select(selections); + s.clear_pending(); + }); + } + } + + fn select_columns( + &mut self, + tail: DisplayPoint, + head: DisplayPoint, + goal_column: u32, + display_map: &DisplaySnapshot, + window: &mut Window, + cx: &mut Context, + ) { + let start_row = cmp::min(tail.row(), head.row()); + let end_row = cmp::max(tail.row(), head.row()); + let start_column = cmp::min(tail.column(), goal_column); + let end_column = cmp::max(tail.column(), goal_column); + let reversed = start_column < tail.column(); + + let selection_ranges = (start_row.0..=end_row.0) + .map(DisplayRow) + .filter_map(|row| { + if start_column <= display_map.line_len(row) && !display_map.is_block_line(row) { + let start = display_map + .clip_point(DisplayPoint::new(row, start_column), Bias::Left) + .to_point(display_map); + let end = display_map + .clip_point(DisplayPoint::new(row, end_column), Bias::Right) + .to_point(display_map); + if reversed { + Some(end..start) + } else { + Some(start..end) + } + } else { + None + } + }) + .collect::>(); + + self.change_selections(None, window, cx, |s| { + s.select_ranges(selection_ranges); + }); + cx.notify(); + } + + pub fn has_non_empty_selection(&self, cx: &mut App) -> bool { + self.selections + .all_adjusted(cx) + .iter() + .any(|selection| !selection.is_empty()) + } + + pub fn has_pending_nonempty_selection(&self) -> bool { + let pending_nonempty_selection = match self.selections.pending_anchor() { + Some(Selection { start, end, .. }) => start != end, + None => false, + }; + + pending_nonempty_selection + || (self.columnar_selection_tail.is_some() && self.selections.disjoint.len() > 1) + } + + pub fn has_pending_selection(&self) -> bool { + self.selections.pending_anchor().is_some() || self.columnar_selection_tail.is_some() + } + + pub fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context) { + self.selection_mark_mode = false; + + if self.clear_expanded_diff_hunks(cx) { + cx.notify(); + return; + } + if self.dismiss_menus_and_popups(true, window, cx) { + return; + } + + if self.mode.is_full() + && self.change_selections(Some(Autoscroll::fit()), window, cx, |s| s.try_cancel()) + { + return; + } + + cx.propagate(); + } + + pub fn dismiss_menus_and_popups( + &mut self, + is_user_requested: bool, + window: &mut Window, + cx: &mut Context, + ) -> bool { + if self.take_rename(false, window, cx).is_some() { + return true; + } + + if hide_hover(self, cx) { + return true; + } + + if self.hide_signature_help(cx, SignatureHelpHiddenBy::Escape) { + return true; + } + + if self.hide_context_menu(window, cx).is_some() { + return true; + } + + if self.mouse_context_menu.take().is_some() { + return true; + } + + if is_user_requested && self.discard_inline_completion(true, cx) { + return true; + } + + if self.snippet_stack.pop().is_some() { + return true; + } + + if self.mode.is_full() && matches!(self.active_diagnostics, ActiveDiagnostic::Group(_)) { + self.dismiss_diagnostics(cx); + return true; + } + + false + } + + fn linked_editing_ranges_for( + &self, + selection: Range, + cx: &App, + ) -> Option, Vec>>> { + if self.linked_edit_ranges.is_empty() { + return None; + } + let ((base_range, linked_ranges), buffer_snapshot, buffer) = + selection.end.buffer_id.and_then(|end_buffer_id| { + if selection.start.buffer_id != Some(end_buffer_id) { + return None; + } + let buffer = self.buffer.read(cx).buffer(end_buffer_id)?; + let snapshot = buffer.read(cx).snapshot(); + self.linked_edit_ranges + .get(end_buffer_id, selection.start..selection.end, &snapshot) + .map(|ranges| (ranges, snapshot, buffer)) + })?; + use text::ToOffset as TO; + // find offset from the start of current range to current cursor position + let start_byte_offset = TO::to_offset(&base_range.start, &buffer_snapshot); + + let start_offset = TO::to_offset(&selection.start, &buffer_snapshot); + let start_difference = start_offset - start_byte_offset; + let end_offset = TO::to_offset(&selection.end, &buffer_snapshot); + let end_difference = end_offset - start_byte_offset; + // Current range has associated linked ranges. + let mut linked_edits = HashMap::<_, Vec<_>>::default(); + for range in linked_ranges.iter() { + let start_offset = TO::to_offset(&range.start, &buffer_snapshot); + let end_offset = start_offset + end_difference; + let start_offset = start_offset + start_difference; + if start_offset > buffer_snapshot.len() || end_offset > buffer_snapshot.len() { + continue; + } + if self.selections.disjoint_anchor_ranges().any(|s| { + if s.start.buffer_id != selection.start.buffer_id + || s.end.buffer_id != selection.end.buffer_id + { + return false; + } + TO::to_offset(&s.start.text_anchor, &buffer_snapshot) <= end_offset + && TO::to_offset(&s.end.text_anchor, &buffer_snapshot) >= start_offset + }) { + continue; + } + let start = buffer_snapshot.anchor_after(start_offset); + let end = buffer_snapshot.anchor_after(end_offset); + linked_edits + .entry(buffer.clone()) + .or_default() + .push(start..end); + } + Some(linked_edits) + } + + pub fn handle_input(&mut self, text: &str, window: &mut Window, cx: &mut Context) { + let text: Arc = text.into(); + + if self.read_only(cx) { + return; + } + + self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); + + let selections = self.selections.all_adjusted(cx); + let mut bracket_inserted = false; + let mut edits = Vec::new(); + let mut linked_edits = HashMap::<_, Vec<_>>::default(); + let mut new_selections = Vec::with_capacity(selections.len()); + let mut new_autoclose_regions = Vec::new(); + let snapshot = self.buffer.read(cx).read(cx); + let mut clear_linked_edit_ranges = false; + + for (selection, autoclose_region) in + self.selections_with_autoclose_regions(selections, &snapshot) + { + if let Some(scope) = snapshot.language_scope_at(selection.head()) { + // Determine if the inserted text matches the opening or closing + // bracket of any of this language's bracket pairs. + let mut bracket_pair = None; + let mut is_bracket_pair_start = false; + let mut is_bracket_pair_end = false; + if !text.is_empty() { + let mut bracket_pair_matching_end = None; + // `text` can be empty when a user is using IME (e.g. Chinese Wubi Simplified) + // and they are removing the character that triggered IME popup. + for (pair, enabled) in scope.brackets() { + if !pair.close && !pair.surround { + continue; + } + + if enabled && pair.start.ends_with(text.as_ref()) { + let prefix_len = pair.start.len() - text.len(); + let preceding_text_matches_prefix = prefix_len == 0 + || (selection.start.column >= (prefix_len as u32) + && snapshot.contains_str_at( + Point::new( + selection.start.row, + selection.start.column - (prefix_len as u32), + ), + &pair.start[..prefix_len], + )); + if preceding_text_matches_prefix { + bracket_pair = Some(pair.clone()); + is_bracket_pair_start = true; + break; + } + } + if pair.end.as_str() == text.as_ref() && bracket_pair_matching_end.is_none() + { + // take first bracket pair matching end, but don't break in case a later bracket + // pair matches start + bracket_pair_matching_end = Some(pair.clone()); + } + } + if bracket_pair.is_none() && bracket_pair_matching_end.is_some() { + bracket_pair = Some(bracket_pair_matching_end.unwrap()); + is_bracket_pair_end = true; + } + } + + if let Some(bracket_pair) = bracket_pair { + let snapshot_settings = snapshot.language_settings_at(selection.start, cx); + let autoclose = self.use_autoclose && snapshot_settings.use_autoclose; + let auto_surround = + self.use_auto_surround && snapshot_settings.use_auto_surround; + if selection.is_empty() { + if is_bracket_pair_start { + // If the inserted text is a suffix of an opening bracket and the + // selection is preceded by the rest of the opening bracket, then + // insert the closing bracket. + let following_text_allows_autoclose = snapshot + .chars_at(selection.start) + .next() + .map_or(true, |c| scope.should_autoclose_before(c)); + + let preceding_text_allows_autoclose = selection.start.column == 0 + || snapshot.reversed_chars_at(selection.start).next().map_or( + true, + |c| { + bracket_pair.start != bracket_pair.end + || !snapshot + .char_classifier_at(selection.start) + .is_word(c) + }, + ); + + let is_closing_quote = if bracket_pair.end == bracket_pair.start + && bracket_pair.start.len() == 1 + { + let target = bracket_pair.start.chars().next().unwrap(); + let current_line_count = snapshot + .reversed_chars_at(selection.start) + .take_while(|&c| c != '\n') + .filter(|&c| c == target) + .count(); + current_line_count % 2 == 1 + } else { + false + }; + + if autoclose + && bracket_pair.close + && following_text_allows_autoclose + && preceding_text_allows_autoclose + && !is_closing_quote + { + let anchor = snapshot.anchor_before(selection.end); + new_selections.push((selection.map(|_| anchor), text.len())); + new_autoclose_regions.push(( + anchor, + text.len(), + selection.id, + bracket_pair.clone(), + )); + edits.push(( + selection.range(), + format!("{}{}", text, bracket_pair.end).into(), + )); + bracket_inserted = true; + continue; + } + } + + if let Some(region) = autoclose_region { + // If the selection is followed by an auto-inserted closing bracket, + // then don't insert that closing bracket again; just move the selection + // past the closing bracket. + let should_skip = selection.end == region.range.end.to_point(&snapshot) + && text.as_ref() == region.pair.end.as_str(); + if should_skip { + let anchor = snapshot.anchor_after(selection.end); + new_selections + .push((selection.map(|_| anchor), region.pair.end.len())); + continue; + } + } + + let always_treat_brackets_as_autoclosed = snapshot + .language_settings_at(selection.start, cx) + .always_treat_brackets_as_autoclosed; + if always_treat_brackets_as_autoclosed + && is_bracket_pair_end + && snapshot.contains_str_at(selection.end, text.as_ref()) + { + // Otherwise, when `always_treat_brackets_as_autoclosed` is set to `true + // and the inserted text is a closing bracket and the selection is followed + // by the closing bracket then move the selection past the closing bracket. + let anchor = snapshot.anchor_after(selection.end); + new_selections.push((selection.map(|_| anchor), text.len())); + continue; + } + } + // If an opening bracket is 1 character long and is typed while + // text is selected, then surround that text with the bracket pair. + else if auto_surround + && bracket_pair.surround + && is_bracket_pair_start + && bracket_pair.start.chars().count() == 1 + { + edits.push((selection.start..selection.start, text.clone())); + edits.push(( + selection.end..selection.end, + bracket_pair.end.as_str().into(), + )); + bracket_inserted = true; + new_selections.push(( + Selection { + id: selection.id, + start: snapshot.anchor_after(selection.start), + end: snapshot.anchor_before(selection.end), + reversed: selection.reversed, + goal: selection.goal, + }, + 0, + )); + continue; + } + } + } + + if self.auto_replace_emoji_shortcode + && selection.is_empty() + && text.as_ref().ends_with(':') + { + if let Some(possible_emoji_short_code) = + Self::find_possible_emoji_shortcode_at_position(&snapshot, selection.start) + { + if !possible_emoji_short_code.is_empty() { + if let Some(emoji) = emojis::get_by_shortcode(&possible_emoji_short_code) { + let emoji_shortcode_start = Point::new( + selection.start.row, + selection.start.column - possible_emoji_short_code.len() as u32 - 1, + ); + + // Remove shortcode from buffer + edits.push(( + emoji_shortcode_start..selection.start, + "".to_string().into(), + )); + new_selections.push(( + Selection { + id: selection.id, + start: snapshot.anchor_after(emoji_shortcode_start), + end: snapshot.anchor_before(selection.start), + reversed: selection.reversed, + goal: selection.goal, + }, + 0, + )); + + // Insert emoji + let selection_start_anchor = snapshot.anchor_after(selection.start); + new_selections.push((selection.map(|_| selection_start_anchor), 0)); + edits.push((selection.start..selection.end, emoji.to_string().into())); + + continue; + } + } + } + } + + // If not handling any auto-close operation, then just replace the selected + // text with the given input and move the selection to the end of the + // newly inserted text. + let anchor = snapshot.anchor_after(selection.end); + if !self.linked_edit_ranges.is_empty() { + let start_anchor = snapshot.anchor_before(selection.start); + + let is_word_char = text.chars().next().map_or(true, |char| { + let classifier = snapshot.char_classifier_at(start_anchor.to_offset(&snapshot)); + classifier.is_word(char) + }); + + if is_word_char { + if let Some(ranges) = self + .linked_editing_ranges_for(start_anchor.text_anchor..anchor.text_anchor, cx) + { + for (buffer, edits) in ranges { + linked_edits + .entry(buffer.clone()) + .or_default() + .extend(edits.into_iter().map(|range| (range, text.clone()))); + } + } + } else { + clear_linked_edit_ranges = true; + } + } + + new_selections.push((selection.map(|_| anchor), 0)); + edits.push((selection.start..selection.end, text.clone())); + } + + drop(snapshot); + + self.transact(window, cx, |this, window, cx| { + if clear_linked_edit_ranges { + this.linked_edit_ranges.clear(); + } + let initial_buffer_versions = + jsx_tag_auto_close::construct_initial_buffer_versions_map(this, &edits, cx); + + this.buffer.update(cx, |buffer, cx| { + buffer.edit(edits, this.autoindent_mode.clone(), cx); + }); + for (buffer, edits) in linked_edits { + buffer.update(cx, |buffer, cx| { + let snapshot = buffer.snapshot(); + let edits = edits + .into_iter() + .map(|(range, text)| { + use text::ToPoint as TP; + let end_point = TP::to_point(&range.end, &snapshot); + let start_point = TP::to_point(&range.start, &snapshot); + (start_point..end_point, text) + }) + .sorted_by_key(|(range, _)| range.start); + buffer.edit(edits, None, cx); + }) + } + let new_anchor_selections = new_selections.iter().map(|e| &e.0); + let new_selection_deltas = new_selections.iter().map(|e| e.1); + let map = this.display_map.update(cx, |map, cx| map.snapshot(cx)); + let new_selections = resolve_selections::(new_anchor_selections, &map) + .zip(new_selection_deltas) + .map(|(selection, delta)| Selection { + id: selection.id, + start: selection.start + delta, + end: selection.end + delta, + reversed: selection.reversed, + goal: SelectionGoal::None, + }) + .collect::>(); + + let mut i = 0; + for (position, delta, selection_id, pair) in new_autoclose_regions { + let position = position.to_offset(&map.buffer_snapshot) + delta; + let start = map.buffer_snapshot.anchor_before(position); + let end = map.buffer_snapshot.anchor_after(position); + while let Some(existing_state) = this.autoclose_regions.get(i) { + match existing_state.range.start.cmp(&start, &map.buffer_snapshot) { + Ordering::Less => i += 1, + Ordering::Greater => break, + Ordering::Equal => { + match end.cmp(&existing_state.range.end, &map.buffer_snapshot) { + Ordering::Less => i += 1, + Ordering::Equal => break, + Ordering::Greater => break, + } + } + } + } + this.autoclose_regions.insert( + i, + AutocloseRegion { + selection_id, + range: start..end, + pair, + }, + ); + } + + let had_active_inline_completion = this.has_active_inline_completion(); + this.change_selections_inner(Some(Autoscroll::fit()), false, window, cx, |s| { + s.select(new_selections) + }); + + if !bracket_inserted { + if let Some(on_type_format_task) = + this.trigger_on_type_formatting(text.to_string(), window, cx) + { + on_type_format_task.detach_and_log_err(cx); + } + } + + let editor_settings = EditorSettings::get_global(cx); + if bracket_inserted + && (editor_settings.auto_signature_help + || editor_settings.show_signature_help_after_edits) + { + this.show_signature_help(&ShowSignatureHelp, window, cx); + } + + let trigger_in_words = + this.show_edit_predictions_in_menu() || !had_active_inline_completion; + if this.hard_wrap.is_some() { + let latest: Range = this.selections.newest(cx).range(); + if latest.is_empty() + && this + .buffer() + .read(cx) + .snapshot(cx) + .line_len(MultiBufferRow(latest.start.row)) + == latest.start.column + { + this.rewrap_impl( + RewrapOptions { + override_language_settings: true, + preserve_existing_whitespace: true, + }, + cx, + ) + } + } + this.trigger_completion_on_input(&text, trigger_in_words, window, cx); + linked_editing_ranges::refresh_linked_ranges(this, window, cx); + this.refresh_inline_completion(true, false, window, cx); + jsx_tag_auto_close::handle_from(this, initial_buffer_versions, window, cx); + }); + } + + fn find_possible_emoji_shortcode_at_position( + snapshot: &MultiBufferSnapshot, + position: Point, + ) -> Option { + let mut chars = Vec::new(); + let mut found_colon = false; + for char in snapshot.reversed_chars_at(position).take(100) { + // Found a possible emoji shortcode in the middle of the buffer + if found_colon { + if char.is_whitespace() { + chars.reverse(); + return Some(chars.iter().collect()); + } + // If the previous character is not a whitespace, we are in the middle of a word + // and we only want to complete the shortcode if the word is made up of other emojis + let mut containing_word = String::new(); + for ch in snapshot + .reversed_chars_at(position) + .skip(chars.len() + 1) + .take(100) + { + if ch.is_whitespace() { + break; + } + containing_word.push(ch); + } + let containing_word = containing_word.chars().rev().collect::(); + if util::word_consists_of_emojis(containing_word.as_str()) { + chars.reverse(); + return Some(chars.iter().collect()); + } + } + + if char.is_whitespace() || !char.is_ascii() { + return None; + } + if char == ':' { + found_colon = true; + } else { + chars.push(char); + } + } + // Found a possible emoji shortcode at the beginning of the buffer + chars.reverse(); + Some(chars.iter().collect()) + } + + pub fn newline(&mut self, _: &Newline, window: &mut Window, cx: &mut Context) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); + self.transact(window, cx, |this, window, cx| { + let (edits, selection_fixup_info): (Vec<_>, Vec<_>) = { + let selections = this.selections.all::(cx); + let multi_buffer = this.buffer.read(cx); + let buffer = multi_buffer.snapshot(cx); + selections + .iter() + .map(|selection| { + let start_point = selection.start.to_point(&buffer); + let mut indent = + buffer.indent_size_for_line(MultiBufferRow(start_point.row)); + indent.len = cmp::min(indent.len, start_point.column); + let start = selection.start; + let end = selection.end; + let selection_is_empty = start == end; + let language_scope = buffer.language_scope_at(start); + let (comment_delimiter, insert_extra_newline) = if let Some(language) = + &language_scope + { + let 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 mut index_of_first_non_whitespace = 0; + let comment_candidate = snapshot + .chars_for_range(range) + .skip_while(|c| { + let should_skip = c.is_whitespace(); + if should_skip { + index_of_first_non_whitespace += 1; + } + should_skip + }) + .take(max_len_of_delimiter) + .collect::(); + let comment_prefix = delimiters.iter().find(|comment_prefix| { + comment_candidate.starts_with(comment_prefix.as_ref()) + })?; + let cursor_is_placed_after_comment_marker = + index_of_first_non_whitespace + comment_prefix.len() + <= start_point.column as usize; + if cursor_is_placed_after_comment_marker { + Some(comment_prefix.clone()) + } else { + None + } + }); + (comment_delimiter, insert_extra_newline) + } else { + (None, false) + }; + + let capacity_for_delimiter = comment_delimiter + .as_deref() + .map(str::len) + .unwrap_or_default(); + let mut new_text = + String::with_capacity(1 + capacity_for_delimiter + indent.len as usize); + new_text.push('\n'); + new_text.extend(indent.chars()); + if let Some(delimiter) = &comment_delimiter { + new_text.push_str(delimiter); + } + if insert_extra_newline { + new_text = new_text.repeat(2); + } + + let anchor = buffer.anchor_after(end); + let new_selection = selection.map(|_| anchor); + ( + (start..end, new_text), + (insert_extra_newline, new_selection), + ) + }) + .unzip() + }; + + this.edit_with_autoindent(edits, cx); + let buffer = this.buffer.read(cx).snapshot(cx); + let new_selections = selection_fixup_info + .into_iter() + .map(|(extra_newline_inserted, new_selection)| { + let mut cursor = new_selection.end.to_point(&buffer); + if extra_newline_inserted { + cursor.row -= 1; + cursor.column = buffer.line_len(MultiBufferRow(cursor.row)); + } + new_selection.map(|_| cursor) + }) + .collect(); + + this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.select(new_selections) + }); + this.refresh_inline_completion(true, false, window, cx); + }); + } + + pub fn newline_above(&mut self, _: &NewlineAbove, window: &mut Window, cx: &mut Context) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); + + let buffer = self.buffer.read(cx); + let snapshot = buffer.snapshot(cx); + + let mut edits = Vec::new(); + let mut rows = Vec::new(); + + for (rows_inserted, selection) in self.selections.all_adjusted(cx).into_iter().enumerate() { + let cursor = selection.head(); + let row = cursor.row; + + let start_of_line = snapshot.clip_point(Point::new(row, 0), Bias::Left); + + let newline = "\n".to_string(); + edits.push((start_of_line..start_of_line, newline)); + + rows.push(row + rows_inserted as u32); + } + + self.transact(window, cx, |editor, window, cx| { + editor.edit(edits, cx); + + editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + let mut index = 0; + s.move_cursors_with(|map, _, _| { + let row = rows[index]; + index += 1; + + let point = Point::new(row, 0); + let boundary = map.next_line_boundary(point).1; + let clipped = map.clip_point(boundary, Bias::Left); + + (clipped, SelectionGoal::None) + }); + }); + + let mut indent_edits = Vec::new(); + let multibuffer_snapshot = editor.buffer.read(cx).snapshot(cx); + for row in rows { + let indents = multibuffer_snapshot.suggested_indents(row..row + 1, cx); + for (row, indent) in indents { + if indent.len == 0 { + continue; + } + + let text = match indent.kind { + IndentKind::Space => " ".repeat(indent.len as usize), + IndentKind::Tab => "\t".repeat(indent.len as usize), + }; + let point = Point::new(row.0, 0); + indent_edits.push((point..point, text)); + } + } + editor.edit(indent_edits, cx); + }); + } + + pub fn newline_below(&mut self, _: &NewlineBelow, window: &mut Window, cx: &mut Context) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); + + let buffer = self.buffer.read(cx); + let snapshot = buffer.snapshot(cx); + + let mut edits = Vec::new(); + let mut rows = Vec::new(); + let mut rows_inserted = 0; + + for selection in self.selections.all_adjusted(cx) { + let cursor = selection.head(); + let row = cursor.row; + + let point = Point::new(row + 1, 0); + let start_of_line = snapshot.clip_point(point, Bias::Left); + + let newline = "\n".to_string(); + edits.push((start_of_line..start_of_line, newline)); + + rows_inserted += 1; + rows.push(row + rows_inserted); + } + + self.transact(window, cx, |editor, window, cx| { + editor.edit(edits, cx); + + editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + let mut index = 0; + s.move_cursors_with(|map, _, _| { + let row = rows[index]; + index += 1; + + let point = Point::new(row, 0); + let boundary = map.next_line_boundary(point).1; + let clipped = map.clip_point(boundary, Bias::Left); + + (clipped, SelectionGoal::None) + }); + }); + + let mut indent_edits = Vec::new(); + let multibuffer_snapshot = editor.buffer.read(cx).snapshot(cx); + for row in rows { + let indents = multibuffer_snapshot.suggested_indents(row..row + 1, cx); + for (row, indent) in indents { + if indent.len == 0 { + continue; + } + + let text = match indent.kind { + IndentKind::Space => " ".repeat(indent.len as usize), + IndentKind::Tab => "\t".repeat(indent.len as usize), + }; + let point = Point::new(row.0, 0); + indent_edits.push((point..point, text)); + } + } + editor.edit(indent_edits, cx); + }); + } + + pub fn insert(&mut self, text: &str, window: &mut Window, cx: &mut Context) { + let autoindent = text.is_empty().not().then(|| AutoindentMode::Block { + original_indent_columns: Vec::new(), + }); + self.insert_with_autoindent_mode(text, autoindent, window, cx); + } + + fn insert_with_autoindent_mode( + &mut self, + text: &str, + autoindent_mode: Option, + window: &mut Window, + cx: &mut Context, + ) { + if self.read_only(cx) { + return; + } + + let text: Arc = text.into(); + self.transact(window, cx, |this, window, cx| { + let old_selections = this.selections.all_adjusted(cx); + let selection_anchors = this.buffer.update(cx, |buffer, cx| { + let anchors = { + let snapshot = buffer.read(cx); + old_selections + .iter() + .map(|s| { + let anchor = snapshot.anchor_after(s.head()); + s.map(|_| anchor) + }) + .collect::>() + }; + buffer.edit( + old_selections + .iter() + .map(|s| (s.start..s.end, text.clone())), + autoindent_mode, + cx, + ); + anchors + }); + + this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.select_anchors(selection_anchors); + }); + + cx.notify(); + }); + } + + fn trigger_completion_on_input( + &mut self, + text: &str, + trigger_in_words: bool, + window: &mut Window, + cx: &mut Context, + ) { + let ignore_completion_provider = self + .context_menu + .borrow() + .as_ref() + .map(|menu| match menu { + CodeContextMenu::Completions(completions_menu) => { + completions_menu.ignore_completion_provider + } + CodeContextMenu::CodeActions(_) => false, + }) + .unwrap_or(false); + + if ignore_completion_provider { + self.show_word_completions(&ShowWordCompletions, window, cx); + } else if self.is_completion_trigger(text, trigger_in_words, cx) { + self.show_completions( + &ShowCompletions { + trigger: Some(text.to_owned()).filter(|x| !x.is_empty()), + }, + window, + cx, + ); + } else { + self.hide_context_menu(window, cx); + } + } + + fn is_completion_trigger( + &self, + text: &str, + trigger_in_words: bool, + cx: &mut Context, + ) -> bool { + let position = self.selections.newest_anchor().head(); + let multibuffer = self.buffer.read(cx); + let Some(buffer) = position + .buffer_id + .and_then(|buffer_id| multibuffer.buffer(buffer_id).clone()) + else { + return false; + }; + + if let Some(completion_provider) = &self.completion_provider { + completion_provider.is_completion_trigger( + &buffer, + position.text_anchor, + text, + trigger_in_words, + cx, + ) + } else { + false + } + } + + /// If any empty selections is touching the start of its innermost containing autoclose + /// region, expand it to select the brackets. + fn select_autoclose_pair(&mut self, window: &mut Window, cx: &mut Context) { + let selections = self.selections.all::(cx); + let buffer = self.buffer.read(cx).read(cx); + let new_selections = self + .selections_with_autoclose_regions(selections, &buffer) + .map(|(mut selection, region)| { + if !selection.is_empty() { + return selection; + } + + if let Some(region) = region { + let mut range = region.range.to_offset(&buffer); + if selection.start == range.start && range.start >= region.pair.start.len() { + range.start -= region.pair.start.len(); + if buffer.contains_str_at(range.start, ®ion.pair.start) + && buffer.contains_str_at(range.end, ®ion.pair.end) + { + range.end += region.pair.end.len(); + selection.start = range.start; + selection.end = range.end; + + return selection; + } + } + } + + let always_treat_brackets_as_autoclosed = buffer + .language_settings_at(selection.start, cx) + .always_treat_brackets_as_autoclosed; + + if !always_treat_brackets_as_autoclosed { + return selection; + } + + if let Some(scope) = buffer.language_scope_at(selection.start) { + for (pair, enabled) in scope.brackets() { + if !enabled || !pair.close { + continue; + } + + if buffer.contains_str_at(selection.start, &pair.end) { + let pair_start_len = pair.start.len(); + if buffer.contains_str_at( + selection.start.saturating_sub(pair_start_len), + &pair.start, + ) { + selection.start -= pair_start_len; + selection.end += pair.end.len(); + + return selection; + } + } + } + } + + selection + }) + .collect(); + + drop(buffer); + self.change_selections(None, window, cx, |selections| { + selections.select(new_selections) + }); + } + + /// Iterate the given selections, and for each one, find the smallest surrounding + /// autoclose region. This uses the ordering of the selections and the autoclose + /// regions to avoid repeated comparisons. + fn selections_with_autoclose_regions<'a, D: ToOffset + Clone>( + &'a self, + selections: impl IntoIterator>, + buffer: &'a MultiBufferSnapshot, + ) -> impl Iterator, Option<&'a AutocloseRegion>)> { + let mut i = 0; + let mut regions = self.autoclose_regions.as_slice(); + selections.into_iter().map(move |selection| { + let range = selection.start.to_offset(buffer)..selection.end.to_offset(buffer); + + let mut enclosing = None; + while let Some(pair_state) = regions.get(i) { + if pair_state.range.end.to_offset(buffer) < range.start { + regions = ®ions[i + 1..]; + i = 0; + } else if pair_state.range.start.to_offset(buffer) > range.end { + break; + } else { + if pair_state.selection_id == selection.id { + enclosing = Some(pair_state); + } + i += 1; + } + } + + (selection, enclosing) + }) + } + + /// Remove any autoclose regions that no longer contain their selection. + fn invalidate_autoclose_regions( + &mut self, + mut selections: &[Selection], + buffer: &MultiBufferSnapshot, + ) { + self.autoclose_regions.retain(|state| { + let mut i = 0; + while let Some(selection) = selections.get(i) { + if selection.end.cmp(&state.range.start, buffer).is_lt() { + selections = &selections[1..]; + continue; + } + if selection.start.cmp(&state.range.end, buffer).is_gt() { + break; + } + if selection.id == state.selection_id { + return true; + } else { + i += 1; + } + } + false + }); + } + + fn completion_query(buffer: &MultiBufferSnapshot, position: impl ToOffset) -> Option { + let offset = position.to_offset(buffer); + let (word_range, kind) = buffer.surrounding_word(offset, true); + if offset > word_range.start && kind == Some(CharKind::Word) { + Some( + buffer + .text_for_range(word_range.start..offset) + .collect::(), + ) + } else { + None + } + } + + pub fn toggle_inline_values( + &mut self, + _: &ToggleInlineValues, + _: &mut Window, + cx: &mut Context, + ) { + self.inline_value_cache.enabled = !self.inline_value_cache.enabled; + + self.refresh_inline_values(cx); + } + + pub fn toggle_inlay_hints( + &mut self, + _: &ToggleInlayHints, + _: &mut Window, + cx: &mut Context, + ) { + self.refresh_inlay_hints( + InlayHintRefreshReason::Toggle(!self.inlay_hints_enabled()), + cx, + ); + } + + pub fn inlay_hints_enabled(&self) -> bool { + self.inlay_hint_cache.enabled + } + + pub fn inline_values_enabled(&self) -> bool { + self.inline_value_cache.enabled + } + + fn refresh_inlay_hints(&mut self, reason: InlayHintRefreshReason, cx: &mut Context) { + if self.semantics_provider.is_none() || !self.mode.is_full() { + return; + } + + let reason_description = reason.description(); + let ignore_debounce = matches!( + reason, + InlayHintRefreshReason::SettingsChange(_) + | InlayHintRefreshReason::Toggle(_) + | InlayHintRefreshReason::ExcerptsRemoved(_) + | InlayHintRefreshReason::ModifiersChanged(_) + ); + let (invalidate_cache, required_languages) = match reason { + InlayHintRefreshReason::ModifiersChanged(enabled) => { + match self.inlay_hint_cache.modifiers_override(enabled) { + Some(enabled) => { + if enabled { + (InvalidationStrategy::RefreshRequested, None) + } else { + self.splice_inlays( + &self + .visible_inlay_hints(cx) + .iter() + .map(|inlay| inlay.id) + .collect::>(), + Vec::new(), + cx, + ); + return; + } + } + None => return, + } + } + InlayHintRefreshReason::Toggle(enabled) => { + if self.inlay_hint_cache.toggle(enabled) { + if enabled { + (InvalidationStrategy::RefreshRequested, None) + } else { + self.splice_inlays( + &self + .visible_inlay_hints(cx) + .iter() + .map(|inlay| inlay.id) + .collect::>(), + Vec::new(), + cx, + ); + return; + } + } else { + return; + } + } + InlayHintRefreshReason::SettingsChange(new_settings) => { + match self.inlay_hint_cache.update_settings( + &self.buffer, + new_settings, + self.visible_inlay_hints(cx), + cx, + ) { + ControlFlow::Break(Some(InlaySplice { + to_remove, + to_insert, + })) => { + self.splice_inlays(&to_remove, to_insert, cx); + return; + } + ControlFlow::Break(None) => return, + ControlFlow::Continue(()) => (InvalidationStrategy::RefreshRequested, None), + } + } + InlayHintRefreshReason::ExcerptsRemoved(excerpts_removed) => { + if let Some(InlaySplice { + to_remove, + to_insert, + }) = self.inlay_hint_cache.remove_excerpts(&excerpts_removed) + { + self.splice_inlays(&to_remove, to_insert, cx); + } + self.display_map.update(cx, |display_map, _| { + display_map.remove_inlays_for_excerpts(&excerpts_removed) + }); + return; + } + InlayHintRefreshReason::NewLinesShown => (InvalidationStrategy::None, None), + InlayHintRefreshReason::BufferEdited(buffer_languages) => { + (InvalidationStrategy::BufferEdited, Some(buffer_languages)) + } + InlayHintRefreshReason::RefreshRequested => { + (InvalidationStrategy::RefreshRequested, None) + } + }; + + if let Some(InlaySplice { + to_remove, + to_insert, + }) = self.inlay_hint_cache.spawn_hint_refresh( + reason_description, + self.excerpts_for_inlay_hints_query(required_languages.as_ref(), cx), + invalidate_cache, + ignore_debounce, + cx, + ) { + self.splice_inlays(&to_remove, to_insert, cx); + } + } + + fn visible_inlay_hints(&self, cx: &Context) -> Vec { + self.display_map + .read(cx) + .current_inlays() + .filter(move |inlay| matches!(inlay.id, InlayId::Hint(_))) + .cloned() + .collect() + } + + pub fn excerpts_for_inlay_hints_query( + &self, + restrict_to_languages: Option<&HashSet>>, + cx: &mut Context, + ) -> HashMap, clock::Global, Range)> { + let Some(project) = self.project.as_ref() else { + return HashMap::default(); + }; + let project = project.read(cx); + let multi_buffer = self.buffer().read(cx); + let multi_buffer_snapshot = multi_buffer.snapshot(cx); + let multi_buffer_visible_start = self + .scroll_manager + .anchor() + .anchor + .to_point(&multi_buffer_snapshot); + let multi_buffer_visible_end = multi_buffer_snapshot.clip_point( + multi_buffer_visible_start + + Point::new(self.visible_line_count().unwrap_or(0.).ceil() as u32, 0), + Bias::Left, + ); + let multi_buffer_visible_range = multi_buffer_visible_start..multi_buffer_visible_end; + multi_buffer_snapshot + .range_to_buffer_ranges(multi_buffer_visible_range) + .into_iter() + .filter(|(_, excerpt_visible_range, _)| !excerpt_visible_range.is_empty()) + .filter_map(|(buffer, excerpt_visible_range, excerpt_id)| { + let buffer_file = project::File::from_dyn(buffer.file())?; + let buffer_worktree = project.worktree_for_id(buffer_file.worktree_id(cx), cx)?; + let worktree_entry = buffer_worktree + .read(cx) + .entry_for_id(buffer_file.project_entry_id(cx)?)?; + if worktree_entry.is_ignored { + return None; + } + + let language = buffer.language()?; + if let Some(restrict_to_languages) = restrict_to_languages { + if !restrict_to_languages.contains(language) { + return None; + } + } + Some(( + excerpt_id, + ( + multi_buffer.buffer(buffer.remote_id()).unwrap(), + buffer.version().clone(), + excerpt_visible_range, + ), + )) + }) + .collect() + } + + pub fn text_layout_details(&self, window: &mut Window) -> TextLayoutDetails { + TextLayoutDetails { + text_system: window.text_system().clone(), + editor_style: self.style.clone().unwrap(), + rem_size: window.rem_size(), + scroll_anchor: self.scroll_manager.anchor(), + visible_rows: self.visible_line_count(), + vertical_scroll_margin: self.scroll_manager.vertical_scroll_margin, + } + } + + pub fn splice_inlays( + &self, + to_remove: &[InlayId], + to_insert: Vec, + cx: &mut Context, + ) { + self.display_map.update(cx, |display_map, cx| { + display_map.splice_inlays(to_remove, to_insert, cx) + }); + cx.notify(); + } + + fn trigger_on_type_formatting( + &self, + input: String, + window: &mut Window, + cx: &mut Context, + ) -> Option>> { + if input.len() != 1 { + return None; + } + + let project = self.project.as_ref()?; + let position = self.selections.newest_anchor().head(); + let (buffer, buffer_position) = self + .buffer + .read(cx) + .text_anchor_for_position(position, cx)?; + + let settings = language_settings::language_settings( + buffer + .read(cx) + .language_at(buffer_position) + .map(|l| l.name()), + buffer.read(cx).file(), + cx, + ); + if !settings.use_on_type_format { + return None; + } + + // OnTypeFormatting returns a list of edits, no need to pass them between Zed instances, + // hence we do LSP request & edit on host side only — add formats to host's history. + let push_to_lsp_host_history = true; + // If this is not the host, append its history with new edits. + let push_to_client_history = project.read(cx).is_via_collab(); + + let on_type_formatting = project.update(cx, |project, cx| { + project.on_type_format( + buffer.clone(), + buffer_position, + input, + push_to_lsp_host_history, + cx, + ) + }); + Some(cx.spawn_in(window, async move |editor, cx| { + if let Some(transaction) = on_type_formatting.await? { + if push_to_client_history { + buffer + .update(cx, |buffer, _| { + buffer.push_transaction(transaction, Instant::now()); + buffer.finalize_last_transaction(); + }) + .ok(); + } + editor.update(cx, |editor, cx| { + editor.refresh_document_highlights(cx); + })?; + } + Ok(()) + })) + } + + pub fn show_word_completions( + &mut self, + _: &ShowWordCompletions, + window: &mut Window, + cx: &mut Context, + ) { + self.open_completions_menu(true, None, window, cx); + } + + pub fn show_completions( + &mut self, + options: &ShowCompletions, + window: &mut Window, + cx: &mut Context, + ) { + self.open_completions_menu(false, options.trigger.as_deref(), window, cx); + } + + fn open_completions_menu( + &mut self, + ignore_completion_provider: bool, + trigger: Option<&str>, + window: &mut Window, + cx: &mut Context, + ) { + if self.pending_rename.is_some() { + return; + } + if !self.snippet_stack.is_empty() && self.context_menu.borrow().as_ref().is_some() { + return; + } + + let position = self.selections.newest_anchor().head(); + if position.diff_base_anchor.is_some() { + return; + } + let (buffer, buffer_position) = + if let Some(output) = self.buffer.read(cx).text_anchor_for_position(position, cx) { + output + } else { + return; + }; + let buffer_snapshot = buffer.read(cx).snapshot(); + let show_completion_documentation = buffer_snapshot + .settings_at(buffer_position, cx) + .show_completion_documentation; + + let query = Self::completion_query(&self.buffer.read(cx).read(cx), position); + + let trigger_kind = match trigger { + Some(trigger) if buffer.read(cx).completion_triggers().contains(trigger) => { + CompletionTriggerKind::TRIGGER_CHARACTER + } + _ => CompletionTriggerKind::INVOKED, + }; + let completion_context = CompletionContext { + trigger_character: trigger.and_then(|trigger| { + if trigger_kind == CompletionTriggerKind::TRIGGER_CHARACTER { + Some(String::from(trigger)) + } else { + None + } + }), + trigger_kind, + }; + + let (old_range, word_kind) = buffer_snapshot.surrounding_word(buffer_position); + let (old_range, word_to_exclude) = if word_kind == Some(CharKind::Word) { + let word_to_exclude = buffer_snapshot + .text_for_range(old_range.clone()) + .collect::(); + ( + buffer_snapshot.anchor_before(old_range.start) + ..buffer_snapshot.anchor_after(old_range.end), + Some(word_to_exclude), + ) + } else { + (buffer_position..buffer_position, None) + }; + + let completion_settings = language_settings( + buffer_snapshot + .language_at(buffer_position) + .map(|language| language.name()), + buffer_snapshot.file(), + cx, + ) + .completions; + + // The document can be large, so stay in reasonable bounds when searching for words, + // otherwise completion pop-up might be slow to appear. + const WORD_LOOKUP_ROWS: u32 = 5_000; + let buffer_row = text::ToPoint::to_point(&buffer_position, &buffer_snapshot).row; + let min_word_search = buffer_snapshot.clip_point( + Point::new(buffer_row.saturating_sub(WORD_LOOKUP_ROWS), 0), + Bias::Left, + ); + let max_word_search = buffer_snapshot.clip_point( + Point::new(buffer_row + WORD_LOOKUP_ROWS, 0).min(buffer_snapshot.max_point()), + Bias::Right, + ); + let word_search_range = buffer_snapshot.point_to_offset(min_word_search) + ..buffer_snapshot.point_to_offset(max_word_search); + + let provider = self + .completion_provider + .as_ref() + .filter(|_| !ignore_completion_provider); + let skip_digits = query + .as_ref() + .map_or(true, |query| !query.chars().any(|c| c.is_digit(10))); + + let (mut words, provided_completions) = match provider { + Some(provider) => { + let completions = provider.completions( + position.excerpt_id, + &buffer, + buffer_position, + completion_context, + window, + cx, + ); + + let words = match completion_settings.words { + WordsCompletionMode::Disabled => Task::ready(BTreeMap::default()), + WordsCompletionMode::Enabled | WordsCompletionMode::Fallback => cx + .background_spawn(async move { + buffer_snapshot.words_in_range(WordsQuery { + fuzzy_contents: None, + range: word_search_range, + skip_digits, + }) + }), + }; + + (words, completions) + } + None => ( + cx.background_spawn(async move { + buffer_snapshot.words_in_range(WordsQuery { + fuzzy_contents: None, + range: word_search_range, + skip_digits, + }) + }), + Task::ready(Ok(None)), + ), + }; + + let sort_completions = provider + .as_ref() + .map_or(false, |provider| provider.sort_completions()); + + let filter_completions = provider + .as_ref() + .map_or(true, |provider| provider.filter_completions()); + + let snippet_sort_order = EditorSettings::get_global(cx).snippet_sort_order; + + let id = post_inc(&mut self.next_completion_id); + let task = cx.spawn_in(window, async move |editor, cx| { + async move { + editor.update(cx, |this, _| { + this.completion_tasks.retain(|(task_id, _)| *task_id >= id); + })?; + + let mut completions = Vec::new(); + if let Some(provided_completions) = provided_completions.await.log_err().flatten() { + completions.extend(provided_completions); + if completion_settings.words == WordsCompletionMode::Fallback { + words = Task::ready(BTreeMap::default()); + } + } + + let mut words = words.await; + if let Some(word_to_exclude) = &word_to_exclude { + words.remove(word_to_exclude); + } + for lsp_completion in &completions { + words.remove(&lsp_completion.new_text); + } + completions.extend(words.into_iter().map(|(word, word_range)| Completion { + replace_range: old_range.clone(), + new_text: word.clone(), + label: CodeLabel::plain(word, None), + icon_path: None, + documentation: None, + source: CompletionSource::BufferWord { + word_range, + resolved: false, + }, + insert_text_mode: Some(InsertTextMode::AS_IS), + confirm: None, + })); + + let menu = if completions.is_empty() { + None + } else { + let mut menu = CompletionsMenu::new( + id, + sort_completions, + show_completion_documentation, + ignore_completion_provider, + position, + buffer.clone(), + completions.into(), + snippet_sort_order, + ); + + menu.filter( + if filter_completions { + query.as_deref() + } else { + None + }, + cx.background_executor().clone(), + ) + .await; + + menu.visible().then_some(menu) + }; + + editor.update_in(cx, |editor, window, cx| { + match editor.context_menu.borrow().as_ref() { + None => {} + Some(CodeContextMenu::Completions(prev_menu)) => { + if prev_menu.id > id { + return; + } + } + _ => return, + } + + if editor.focus_handle.is_focused(window) && menu.is_some() { + let mut menu = menu.unwrap(); + menu.resolve_visible_completions(editor.completion_provider.as_deref(), cx); + + *editor.context_menu.borrow_mut() = + Some(CodeContextMenu::Completions(menu)); + + if editor.show_edit_predictions_in_menu() { + editor.update_visible_inline_completion(window, cx); + } else { + editor.discard_inline_completion(false, cx); + } + + cx.notify(); + } else if editor.completion_tasks.len() <= 1 { + // If there are no more completion tasks and the last menu was + // empty, we should hide it. + let was_hidden = editor.hide_context_menu(window, cx).is_none(); + // If it was already hidden and we don't show inline + // completions in the menu, we should also show the + // inline-completion when available. + if was_hidden && editor.show_edit_predictions_in_menu() { + editor.update_visible_inline_completion(window, cx); + } + } + })?; + + anyhow::Ok(()) + } + .log_err() + .await + }); + + self.completion_tasks.push((id, task)); + } + + #[cfg(feature = "test-support")] + pub fn current_completions(&self) -> Option> { + let menu = self.context_menu.borrow(); + if let CodeContextMenu::Completions(menu) = menu.as_ref()? { + let completions = menu.completions.borrow(); + Some(completions.to_vec()) + } else { + None + } + } + + pub fn confirm_completion( + &mut self, + action: &ConfirmCompletion, + window: &mut Window, + cx: &mut Context, + ) -> Option>> { + self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); + self.do_completion(action.item_ix, CompletionIntent::Complete, window, cx) + } + + pub fn confirm_completion_insert( + &mut self, + _: &ConfirmCompletionInsert, + window: &mut Window, + cx: &mut Context, + ) -> Option>> { + self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); + self.do_completion(None, CompletionIntent::CompleteWithInsert, window, cx) + } + + pub fn confirm_completion_replace( + &mut self, + _: &ConfirmCompletionReplace, + window: &mut Window, + cx: &mut Context, + ) -> Option>> { + self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); + self.do_completion(None, CompletionIntent::CompleteWithReplace, window, cx) + } + + pub fn compose_completion( + &mut self, + action: &ComposeCompletion, + window: &mut Window, + cx: &mut Context, + ) -> Option>> { + self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); + self.do_completion(action.item_ix, CompletionIntent::Compose, window, cx) + } + + fn do_completion( + &mut self, + item_ix: Option, + intent: CompletionIntent, + window: &mut Window, + cx: &mut Context, + ) -> Option>> { + use language::ToOffset as _; + + let CodeContextMenu::Completions(completions_menu) = self.hide_context_menu(window, cx)? + else { + return None; + }; + + let candidate_id = { + let entries = completions_menu.entries.borrow(); + let mat = entries.get(item_ix.unwrap_or(completions_menu.selected_item))?; + if self.show_edit_predictions_in_menu() { + self.discard_inline_completion(true, cx); + } + mat.candidate_id + }; + + let buffer_handle = completions_menu.buffer; + let completion = completions_menu + .completions + .borrow() + .get(candidate_id)? + .clone(); + cx.stop_propagation(); + + let snippet; + let new_text; + if completion.is_snippet() { + snippet = Some(Snippet::parse(&completion.new_text).log_err()?); + new_text = snippet.as_ref().unwrap().text.clone(); + } else { + snippet = None; + new_text = completion.new_text.clone(); + }; + + let replace_range = choose_completion_range(&completion, intent, &buffer_handle, cx); + let buffer = buffer_handle.read(cx); + let snapshot = self.buffer.read(cx).snapshot(cx); + let replace_range_multibuffer = { + let excerpt = snapshot + .excerpt_containing(self.selections.newest_anchor().range()) + .unwrap(); + let multibuffer_anchor = snapshot + .anchor_in_excerpt(excerpt.id(), buffer.anchor_before(replace_range.start)) + .unwrap() + ..snapshot + .anchor_in_excerpt(excerpt.id(), buffer.anchor_before(replace_range.end)) + .unwrap(); + multibuffer_anchor.start.to_offset(&snapshot) + ..multibuffer_anchor.end.to_offset(&snapshot) + }; + let newest_anchor = self.selections.newest_anchor(); + if newest_anchor.head().buffer_id != Some(buffer.remote_id()) { + return None; + } + + let old_text = buffer + .text_for_range(replace_range.clone()) + .collect::(); + let lookbehind = newest_anchor + .start + .text_anchor + .to_offset(buffer) + .saturating_sub(replace_range.start); + let lookahead = replace_range + .end + .saturating_sub(newest_anchor.end.text_anchor.to_offset(buffer)); + let prefix = &old_text[..old_text.len().saturating_sub(lookahead)]; + let suffix = &old_text[lookbehind.min(old_text.len())..]; + + let selections = self.selections.all::(cx); + let mut ranges = Vec::new(); + let mut linked_edits = HashMap::<_, Vec<_>>::default(); + + for selection in &selections { + let range = if selection.id == newest_anchor.id { + replace_range_multibuffer.clone() + } else { + let mut range = selection.range(); + + // if prefix is present, don't duplicate it + if snapshot.contains_str_at(range.start.saturating_sub(lookbehind), prefix) { + range.start = range.start.saturating_sub(lookbehind); + + // if suffix is also present, mimic the newest cursor and replace it + if selection.id != newest_anchor.id + && snapshot.contains_str_at(range.end, suffix) + { + range.end += lookahead; + } + } + range + }; + + ranges.push(range); + + if !self.linked_edit_ranges.is_empty() { + let start_anchor = snapshot.anchor_before(selection.head()); + let end_anchor = snapshot.anchor_after(selection.tail()); + if let Some(ranges) = self + .linked_editing_ranges_for(start_anchor.text_anchor..end_anchor.text_anchor, cx) + { + for (buffer, edits) in ranges { + linked_edits + .entry(buffer.clone()) + .or_default() + .extend(edits.into_iter().map(|range| (range, new_text.to_owned()))); + } + } + } + } + + cx.emit(EditorEvent::InputHandled { + utf16_range_to_replace: None, + text: new_text.clone().into(), + }); + + self.transact(window, cx, |this, window, cx| { + if let Some(mut snippet) = snippet { + snippet.text = new_text.to_string(); + this.insert_snippet(&ranges, snippet, window, cx).log_err(); + } else { + this.buffer.update(cx, |buffer, cx| { + let auto_indent = match completion.insert_text_mode { + Some(InsertTextMode::AS_IS) => None, + _ => this.autoindent_mode.clone(), + }; + let edits = ranges.into_iter().map(|range| (range, new_text.as_str())); + buffer.edit(edits, auto_indent, cx); + }); + } + for (buffer, edits) in linked_edits { + buffer.update(cx, |buffer, cx| { + let snapshot = buffer.snapshot(); + let edits = edits + .into_iter() + .map(|(range, text)| { + use text::ToPoint as TP; + let end_point = TP::to_point(&range.end, &snapshot); + let start_point = TP::to_point(&range.start, &snapshot); + (start_point..end_point, text) + }) + .sorted_by_key(|(range, _)| range.start); + buffer.edit(edits, None, cx); + }) + } + + this.refresh_inline_completion(true, false, window, cx); + }); + + let show_new_completions_on_confirm = completion + .confirm + .as_ref() + .map_or(false, |confirm| confirm(intent, window, cx)); + if show_new_completions_on_confirm { + self.show_completions(&ShowCompletions { trigger: None }, window, cx); + } + + let provider = self.completion_provider.as_ref()?; + drop(completion); + let apply_edits = provider.apply_additional_edits_for_completion( + buffer_handle, + completions_menu.completions.clone(), + candidate_id, + true, + cx, + ); + + let editor_settings = EditorSettings::get_global(cx); + if editor_settings.show_signature_help_after_edits || editor_settings.auto_signature_help { + // After the code completion is finished, users often want to know what signatures are needed. + // so we should automatically call signature_help + self.show_signature_help(&ShowSignatureHelp, window, cx); + } + + Some(cx.foreground_executor().spawn(async move { + apply_edits.await?; + Ok(()) + })) + } + + pub fn toggle_code_actions( + &mut self, + action: &ToggleCodeActions, + window: &mut Window, + cx: &mut Context, + ) { + let quick_launch = action.quick_launch; + let mut context_menu = self.context_menu.borrow_mut(); + if let Some(CodeContextMenu::CodeActions(code_actions)) = context_menu.as_ref() { + if code_actions.deployed_from_indicator == action.deployed_from_indicator { + // Toggle if we're selecting the same one + *context_menu = None; + cx.notify(); + return; + } else { + // Otherwise, clear it and start a new one + *context_menu = None; + cx.notify(); + } + } + drop(context_menu); + let snapshot = self.snapshot(window, cx); + let deployed_from_indicator = action.deployed_from_indicator; + let mut task = self.code_actions_task.take(); + let action = action.clone(); + cx.spawn_in(window, async move |editor, cx| { + while let Some(prev_task) = task { + prev_task.await.log_err(); + task = editor.update(cx, |this, _| this.code_actions_task.take())?; + } + + let spawned_test_task = editor.update_in(cx, |editor, window, cx| { + if editor.focus_handle.is_focused(window) { + let multibuffer_point = action + .deployed_from_indicator + .map(|row| DisplayPoint::new(row, 0).to_point(&snapshot)) + .unwrap_or_else(|| editor.selections.newest::(cx).head()); + let (buffer, buffer_row) = snapshot + .buffer_snapshot + .buffer_line_for_row(MultiBufferRow(multibuffer_point.row)) + .and_then(|(buffer_snapshot, range)| { + editor + .buffer + .read(cx) + .buffer(buffer_snapshot.remote_id()) + .map(|buffer| (buffer, range.start.row)) + })?; + let (_, code_actions) = editor + .available_code_actions + .clone() + .and_then(|(location, code_actions)| { + let snapshot = location.buffer.read(cx).snapshot(); + let point_range = location.range.to_point(&snapshot); + let point_range = point_range.start.row..=point_range.end.row; + if point_range.contains(&buffer_row) { + Some((location, code_actions)) + } else { + None + } + }) + .unzip(); + let buffer_id = buffer.read(cx).remote_id(); + let tasks = editor + .tasks + .get(&(buffer_id, buffer_row)) + .map(|t| Arc::new(t.to_owned())); + if tasks.is_none() && code_actions.is_none() { + return None; + } + + editor.completion_tasks.clear(); + editor.discard_inline_completion(false, cx); + let task_context = + tasks + .as_ref() + .zip(editor.project.clone()) + .map(|(tasks, project)| { + Self::build_tasks_context(&project, &buffer, buffer_row, tasks, cx) + }); + + Some(cx.spawn_in(window, async move |editor, cx| { + let task_context = match task_context { + Some(task_context) => task_context.await, + None => None, + }; + let resolved_tasks = + tasks + .zip(task_context.clone()) + .map(|(tasks, task_context)| ResolvedTasks { + templates: tasks.resolve(&task_context).collect(), + position: snapshot.buffer_snapshot.anchor_before(Point::new( + multibuffer_point.row, + tasks.column, + )), + }); + let spawn_straight_away = quick_launch + && resolved_tasks + .as_ref() + .map_or(false, |tasks| tasks.templates.len() == 1) + && code_actions + .as_ref() + .map_or(true, |actions| actions.is_empty()); + let debug_scenarios = editor.update(cx, |editor, cx| { + if cx.has_flag::() { + maybe!({ + let project = editor.project.as_ref()?; + let dap_store = project.read(cx).dap_store(); + let mut scenarios = vec![]; + let resolved_tasks = resolved_tasks.as_ref()?; + let debug_adapter: SharedString = buffer + .read(cx) + .language()? + .context_provider()? + .debug_adapter()? + .into(); + dap_store.update(cx, |this, cx| { + for (_, task) in &resolved_tasks.templates { + if let Some(scenario) = this + .debug_scenario_for_build_task( + task.resolved.clone(), + SharedString::from( + task.original_task().label.clone(), + ), + debug_adapter.clone(), + cx, + ) + { + scenarios.push(scenario); + } + } + }); + Some(scenarios) + }) + .unwrap_or_default() + } else { + vec![] + } + })?; + if let Ok(task) = editor.update_in(cx, |editor, window, cx| { + *editor.context_menu.borrow_mut() = + Some(CodeContextMenu::CodeActions(CodeActionsMenu { + buffer, + actions: CodeActionContents::new( + resolved_tasks, + code_actions, + debug_scenarios, + task_context.unwrap_or_default(), + ), + selected_item: Default::default(), + scroll_handle: UniformListScrollHandle::default(), + deployed_from_indicator, + })); + if spawn_straight_away { + if let Some(task) = editor.confirm_code_action( + &ConfirmCodeAction { item_ix: Some(0) }, + window, + cx, + ) { + cx.notify(); + return task; + } + } + cx.notify(); + Task::ready(Ok(())) + }) { + task.await + } else { + Ok(()) + } + })) + } else { + Some(Task::ready(Ok(()))) + } + })?; + if let Some(task) = spawned_test_task { + task.await?; + } + + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + + pub fn confirm_code_action( + &mut self, + action: &ConfirmCodeAction, + window: &mut Window, + cx: &mut Context, + ) -> Option>> { + self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); + + let actions_menu = + if let CodeContextMenu::CodeActions(menu) = self.hide_context_menu(window, cx)? { + menu + } else { + return None; + }; + + let action_ix = action.item_ix.unwrap_or(actions_menu.selected_item); + let action = actions_menu.actions.get(action_ix)?; + let title = action.label(); + let buffer = actions_menu.buffer; + let workspace = self.workspace()?; + + match action { + CodeActionsItem::Task(task_source_kind, resolved_task) => { + workspace.update(cx, |workspace, cx| { + workspace.schedule_resolved_task( + task_source_kind, + resolved_task, + false, + window, + cx, + ); + + Some(Task::ready(Ok(()))) + }) + } + CodeActionsItem::CodeAction { + excerpt_id, + action, + provider, + } => { + let apply_code_action = + provider.apply_code_action(buffer, action, excerpt_id, true, window, cx); + let workspace = workspace.downgrade(); + Some(cx.spawn_in(window, async move |editor, cx| { + let project_transaction = apply_code_action.await?; + Self::open_project_transaction( + &editor, + workspace, + project_transaction, + title, + cx, + ) + .await + })) + } + CodeActionsItem::DebugScenario(scenario) => { + let context = actions_menu.actions.context.clone(); + + workspace.update(cx, |workspace, cx| { + workspace.start_debug_session(scenario, context, Some(buffer), window, cx); + }); + Some(Task::ready(Ok(()))) + } + } + } + + pub async fn open_project_transaction( + this: &WeakEntity, + workspace: WeakEntity, + transaction: ProjectTransaction, + title: String, + cx: &mut AsyncWindowContext, + ) -> Result<()> { + let mut entries = transaction.0.into_iter().collect::>(); + cx.update(|_, cx| { + entries.sort_unstable_by_key(|(buffer, _)| { + buffer.read(cx).file().map(|f| f.path().clone()) + }); + })?; + + // If the project transaction's edits are all contained within this editor, then + // avoid opening a new editor to display them. + + if let Some((buffer, transaction)) = entries.first() { + if entries.len() == 1 { + let excerpt = this.update(cx, |editor, cx| { + editor + .buffer() + .read(cx) + .excerpt_containing(editor.selections.newest_anchor().head(), cx) + })?; + if let Some((_, excerpted_buffer, excerpt_range)) = excerpt { + if excerpted_buffer == *buffer { + let all_edits_within_excerpt = buffer.read_with(cx, |buffer, _| { + let excerpt_range = excerpt_range.to_offset(buffer); + buffer + .edited_ranges_for_transaction::(transaction) + .all(|range| { + excerpt_range.start <= range.start + && excerpt_range.end >= range.end + }) + })?; + + if all_edits_within_excerpt { + return Ok(()); + } + } + } + } + } else { + return Ok(()); + } + + let mut ranges_to_highlight = Vec::new(); + let excerpt_buffer = cx.new(|cx| { + let mut multibuffer = MultiBuffer::new(Capability::ReadWrite).with_title(title); + for (buffer_handle, transaction) in &entries { + let edited_ranges = buffer_handle + .read(cx) + .edited_ranges_for_transaction::(transaction) + .collect::>(); + let (ranges, _) = multibuffer.set_excerpts_for_path( + PathKey::for_buffer(buffer_handle, cx), + buffer_handle.clone(), + edited_ranges, + DEFAULT_MULTIBUFFER_CONTEXT, + cx, + ); + + ranges_to_highlight.extend(ranges); + } + multibuffer.push_transaction(entries.iter().map(|(b, t)| (b, t)), cx); + multibuffer + })?; + + workspace.update_in(cx, |workspace, window, cx| { + let project = workspace.project().clone(); + let editor = + cx.new(|cx| Editor::for_multibuffer(excerpt_buffer, Some(project), window, cx)); + workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx); + editor.update(cx, |editor, cx| { + editor.highlight_background::( + &ranges_to_highlight, + |theme| theme.editor_highlighted_line_background, + cx, + ); + }); + })?; + + Ok(()) + } + + pub fn clear_code_action_providers(&mut self) { + self.code_action_providers.clear(); + self.available_code_actions.take(); + } + + pub fn add_code_action_provider( + &mut self, + provider: Rc, + window: &mut Window, + cx: &mut Context, + ) { + if self + .code_action_providers + .iter() + .any(|existing_provider| existing_provider.id() == provider.id()) + { + return; + } + + self.code_action_providers.push(provider); + self.refresh_code_actions(window, cx); + } + + pub fn remove_code_action_provider( + &mut self, + id: Arc, + window: &mut Window, + cx: &mut Context, + ) { + self.code_action_providers + .retain(|provider| provider.id() != id); + self.refresh_code_actions(window, cx); + } + + fn refresh_code_actions(&mut self, window: &mut Window, cx: &mut Context) -> Option<()> { + let newest_selection = self.selections.newest_anchor().clone(); + let newest_selection_adjusted = self.selections.newest_adjusted(cx).clone(); + let buffer = self.buffer.read(cx); + if newest_selection.head().diff_base_anchor.is_some() { + return None; + } + let (start_buffer, start) = + buffer.text_anchor_for_position(newest_selection_adjusted.start, cx)?; + let (end_buffer, end) = + buffer.text_anchor_for_position(newest_selection_adjusted.end, cx)?; + if start_buffer != end_buffer { + return None; + } + + self.code_actions_task = Some(cx.spawn_in(window, async move |this, cx| { + cx.background_executor() + .timer(CODE_ACTIONS_DEBOUNCE_TIMEOUT) + .await; + + let (providers, tasks) = this.update_in(cx, |this, window, cx| { + let providers = this.code_action_providers.clone(); + let tasks = this + .code_action_providers + .iter() + .map(|provider| provider.code_actions(&start_buffer, start..end, window, cx)) + .collect::>(); + (providers, tasks) + })?; + + let mut actions = Vec::new(); + for (provider, provider_actions) in + providers.into_iter().zip(future::join_all(tasks).await) + { + if let Some(provider_actions) = provider_actions.log_err() { + actions.extend(provider_actions.into_iter().map(|action| { + AvailableCodeAction { + excerpt_id: newest_selection.start.excerpt_id, + action, + provider: provider.clone(), + } + })); + } + } + + this.update(cx, |this, cx| { + this.available_code_actions = if actions.is_empty() { + None + } else { + Some(( + Location { + buffer: start_buffer, + range: start..end, + }, + actions.into(), + )) + }; + cx.notify(); + }) + })); + None + } + + fn start_inline_blame_timer(&mut self, window: &mut Window, cx: &mut Context) { + if let Some(delay) = ProjectSettings::get_global(cx).git.inline_blame_delay() { + self.show_git_blame_inline = false; + + self.show_git_blame_inline_delay_task = + Some(cx.spawn_in(window, async move |this, cx| { + cx.background_executor().timer(delay).await; + + this.update(cx, |this, cx| { + this.show_git_blame_inline = true; + cx.notify(); + }) + .log_err(); + })); + } + } + + fn show_blame_popover( + &mut self, + blame_entry: &BlameEntry, + position: gpui::Point, + cx: &mut Context, + ) { + if let Some(state) = &mut self.inline_blame_popover { + state.hide_task.take(); + cx.notify(); + } else { + let delay = EditorSettings::get_global(cx).hover_popover_delay; + let show_task = cx.spawn(async move |editor, cx| { + cx.background_executor() + .timer(std::time::Duration::from_millis(delay)) + .await; + editor + .update(cx, |editor, cx| { + if let Some(state) = &mut editor.inline_blame_popover { + state.show_task = None; + cx.notify(); + } + }) + .ok(); + }); + let Some(blame) = self.blame.as_ref() else { + return; + }; + let blame = blame.read(cx); + let details = blame.details_for_entry(&blame_entry); + let markdown = cx.new(|cx| { + Markdown::new( + details + .as_ref() + .map(|message| message.message.clone()) + .unwrap_or_default(), + None, + None, + cx, + ) + }); + self.inline_blame_popover = Some(InlineBlamePopover { + position, + show_task: Some(show_task), + hide_task: None, + popover_bounds: None, + popover_state: InlineBlamePopoverState { + scroll_handle: ScrollHandle::new(), + commit_message: details, + markdown, + }, + }); + } + } + + fn hide_blame_popover(&mut self, cx: &mut Context) { + if let Some(state) = &mut self.inline_blame_popover { + if state.show_task.is_some() { + self.inline_blame_popover.take(); + cx.notify(); + } else { + let hide_task = cx.spawn(async move |editor, cx| { + cx.background_executor() + .timer(std::time::Duration::from_millis(100)) + .await; + editor + .update(cx, |editor, cx| { + editor.inline_blame_popover.take(); + cx.notify(); + }) + .ok(); + }); + state.hide_task = Some(hide_task); + } + } + } + + fn refresh_document_highlights(&mut self, cx: &mut Context) -> Option<()> { + if self.pending_rename.is_some() { + return None; + } + + let provider = self.semantics_provider.clone()?; + let buffer = self.buffer.read(cx); + let newest_selection = self.selections.newest_anchor().clone(); + let cursor_position = newest_selection.head(); + let (cursor_buffer, cursor_buffer_position) = + buffer.text_anchor_for_position(cursor_position, cx)?; + let (tail_buffer, _) = buffer.text_anchor_for_position(newest_selection.tail(), cx)?; + if cursor_buffer != tail_buffer { + return None; + } + let debounce = EditorSettings::get_global(cx).lsp_highlight_debounce; + self.document_highlights_task = Some(cx.spawn(async move |this, cx| { + cx.background_executor() + .timer(Duration::from_millis(debounce)) + .await; + + let highlights = if let Some(highlights) = cx + .update(|cx| { + provider.document_highlights(&cursor_buffer, cursor_buffer_position, cx) + }) + .ok() + .flatten() + { + highlights.await.log_err() + } else { + None + }; + + if let Some(highlights) = highlights { + this.update(cx, |this, cx| { + if this.pending_rename.is_some() { + return; + } + + let buffer_id = cursor_position.buffer_id; + let buffer = this.buffer.read(cx); + if !buffer + .text_anchor_for_position(cursor_position, cx) + .map_or(false, |(buffer, _)| buffer == cursor_buffer) + { + return; + } + + let cursor_buffer_snapshot = cursor_buffer.read(cx); + let mut write_ranges = Vec::new(); + let mut read_ranges = Vec::new(); + for highlight in highlights { + for (excerpt_id, excerpt_range) in + buffer.excerpts_for_buffer(cursor_buffer.read(cx).remote_id(), cx) + { + let start = highlight + .range + .start + .max(&excerpt_range.context.start, cursor_buffer_snapshot); + let end = highlight + .range + .end + .min(&excerpt_range.context.end, cursor_buffer_snapshot); + if start.cmp(&end, cursor_buffer_snapshot).is_ge() { + continue; + } + + let range = Anchor { + buffer_id, + excerpt_id, + text_anchor: start, + diff_base_anchor: None, + }..Anchor { + buffer_id, + excerpt_id, + text_anchor: end, + diff_base_anchor: None, + }; + if highlight.kind == lsp::DocumentHighlightKind::WRITE { + write_ranges.push(range); + } else { + read_ranges.push(range); + } + } + } + + this.highlight_background::( + &read_ranges, + |theme| theme.editor_document_highlight_read_background, + cx, + ); + this.highlight_background::( + &write_ranges, + |theme| theme.editor_document_highlight_write_background, + cx, + ); + cx.notify(); + }) + .log_err(); + } + })); + None + } + + fn prepare_highlight_query_from_selection( + &mut self, + cx: &mut Context, + ) -> Option<(String, Range)> { + if matches!(self.mode, EditorMode::SingleLine { .. }) { + return None; + } + if !EditorSettings::get_global(cx).selection_highlight { + return None; + } + if self.selections.count() != 1 || self.selections.line_mode { + return None; + } + let selection = self.selections.newest::(cx); + if selection.is_empty() || selection.start.row != selection.end.row { + return None; + } + let multi_buffer_snapshot = self.buffer().read(cx).snapshot(cx); + let selection_anchor_range = selection.range().to_anchors(&multi_buffer_snapshot); + let query = multi_buffer_snapshot + .text_for_range(selection_anchor_range.clone()) + .collect::(); + if query.trim().is_empty() { + return None; + } + Some((query, selection_anchor_range)) + } + + fn update_selection_occurrence_highlights( + &mut self, + query_text: String, + query_range: Range, + multi_buffer_range_to_query: Range, + use_debounce: bool, + window: &mut Window, + cx: &mut Context, + ) -> Task<()> { + let multi_buffer_snapshot = self.buffer().read(cx).snapshot(cx); + cx.spawn_in(window, async move |editor, cx| { + if use_debounce { + cx.background_executor() + .timer(SELECTION_HIGHLIGHT_DEBOUNCE_TIMEOUT) + .await; + } + let match_task = cx.background_spawn(async move { + let buffer_ranges = multi_buffer_snapshot + .range_to_buffer_ranges(multi_buffer_range_to_query) + .into_iter() + .filter(|(_, excerpt_visible_range, _)| !excerpt_visible_range.is_empty()); + let mut match_ranges = Vec::new(); + for (buffer_snapshot, search_range, excerpt_id) in buffer_ranges { + match_ranges.extend( + project::search::SearchQuery::text( + query_text.clone(), + false, + false, + false, + Default::default(), + Default::default(), + false, + None, + ) + .unwrap() + .search(&buffer_snapshot, Some(search_range.clone())) + .await + .into_iter() + .filter_map(|match_range| { + let match_start = buffer_snapshot + .anchor_after(search_range.start + match_range.start); + let match_end = + buffer_snapshot.anchor_before(search_range.start + match_range.end); + let match_anchor_range = Anchor::range_in_buffer( + excerpt_id, + buffer_snapshot.remote_id(), + match_start..match_end, + ); + (match_anchor_range != query_range).then_some(match_anchor_range) + }), + ); + } + match_ranges + }); + let match_ranges = match_task.await; + editor + .update_in(cx, |editor, _, cx| { + editor.clear_background_highlights::(cx); + if !match_ranges.is_empty() { + editor.highlight_background::( + &match_ranges, + |theme| theme.editor_document_highlight_bracket_background, + cx, + ) + } + }) + .log_err(); + }) + } + + fn refresh_selected_text_highlights( + &mut self, + on_buffer_edit: bool, + window: &mut Window, + cx: &mut Context, + ) { + let Some((query_text, query_range)) = self.prepare_highlight_query_from_selection(cx) + else { + self.clear_background_highlights::(cx); + self.quick_selection_highlight_task.take(); + self.debounced_selection_highlight_task.take(); + return; + }; + let multi_buffer_snapshot = self.buffer().read(cx).snapshot(cx); + if on_buffer_edit + || self + .quick_selection_highlight_task + .as_ref() + .map_or(true, |(prev_anchor_range, _)| { + prev_anchor_range != &query_range + }) + { + let multi_buffer_visible_start = self + .scroll_manager + .anchor() + .anchor + .to_point(&multi_buffer_snapshot); + let multi_buffer_visible_end = multi_buffer_snapshot.clip_point( + multi_buffer_visible_start + + Point::new(self.visible_line_count().unwrap_or(0.).ceil() as u32, 0), + Bias::Left, + ); + let multi_buffer_visible_range = multi_buffer_visible_start..multi_buffer_visible_end; + self.quick_selection_highlight_task = Some(( + query_range.clone(), + self.update_selection_occurrence_highlights( + query_text.clone(), + query_range.clone(), + multi_buffer_visible_range, + false, + window, + cx, + ), + )); + } + if on_buffer_edit + || self + .debounced_selection_highlight_task + .as_ref() + .map_or(true, |(prev_anchor_range, _)| { + prev_anchor_range != &query_range + }) + { + let multi_buffer_start = multi_buffer_snapshot + .anchor_before(0) + .to_point(&multi_buffer_snapshot); + let multi_buffer_end = multi_buffer_snapshot + .anchor_after(multi_buffer_snapshot.len()) + .to_point(&multi_buffer_snapshot); + let multi_buffer_full_range = multi_buffer_start..multi_buffer_end; + self.debounced_selection_highlight_task = Some(( + query_range.clone(), + self.update_selection_occurrence_highlights( + query_text, + query_range, + multi_buffer_full_range, + true, + window, + cx, + ), + )); + } + } + + pub fn refresh_inline_completion( + &mut self, + debounce: bool, + user_requested: bool, + 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_enabled_in_buffer(&buffer, cursor_buffer_position, cx) { + self.discard_inline_completion(false, cx); + return None; + } + + if !user_requested + && (!self.should_show_edit_predictions() + || !self.is_focused(window) + || buffer.read(cx).is_empty()) + { + self.discard_inline_completion(false, cx); + return None; + } + + self.update_visible_inline_completion(window, cx); + provider.refresh( + self.project.clone(), + buffer, + cursor_buffer_position, + debounce, + cx, + ); + Some(()) + } + + fn show_edit_predictions_in_menu(&self) -> bool { + match self.edit_prediction_settings { + EditPredictionSettings::Disabled => false, + EditPredictionSettings::Enabled { show_in_menu, .. } => show_in_menu, + } + } + + pub fn edit_predictions_enabled(&self) -> bool { + match self.edit_prediction_settings { + EditPredictionSettings::Disabled => false, + EditPredictionSettings::Enabled { .. } => true, + } + } + + fn edit_prediction_requires_modifier(&self) -> bool { + match self.edit_prediction_settings { + EditPredictionSettings::Disabled => false, + EditPredictionSettings::Enabled { + preview_requires_modifier, + .. + } => preview_requires_modifier, + } + } + + pub fn update_edit_prediction_settings(&mut self, cx: &mut Context) { + if self.edit_prediction_provider.is_none() { + self.edit_prediction_settings = EditPredictionSettings::Disabled; + } else { + let selection = self.selections.newest_anchor(); + let cursor = selection.head(); + + if let Some((buffer, cursor_buffer_position)) = + self.buffer.read(cx).text_anchor_for_position(cursor, cx) + { + self.edit_prediction_settings = + self.edit_prediction_settings_at_position(&buffer, cursor_buffer_position, cx); + } + } + } + + fn edit_prediction_settings_at_position( + &self, + buffer: &Entity, + buffer_position: language::Anchor, + cx: &App, + ) -> EditPredictionSettings { + if !self.mode.is_full() + || !self.show_inline_completions_override.unwrap_or(true) + || self.inline_completions_disabled_in_scope(buffer, buffer_position, cx) + { + return EditPredictionSettings::Disabled; + } + + let buffer = buffer.read(cx); + + let file = buffer.file(); + + if !language_settings(buffer.language().map(|l| l.name()), file, cx).show_edit_predictions { + return EditPredictionSettings::Disabled; + }; + + let by_provider = matches!( + self.menu_inline_completions_policy, + MenuInlineCompletionsPolicy::ByProvider + ); + + let show_in_menu = by_provider + && self + .edit_prediction_provider + .as_ref() + .map_or(false, |provider| { + provider.provider.show_completions_in_menu() + }); + + let preview_requires_modifier = + all_language_settings(file, cx).edit_predictions_mode() == EditPredictionsMode::Subtle; + + EditPredictionSettings::Enabled { + show_in_menu, + preview_requires_modifier, + } + } + + fn should_show_edit_predictions(&self) -> bool { + self.snippet_stack.is_empty() && self.edit_predictions_enabled() + } + + pub fn edit_prediction_preview_is_active(&self) -> bool { + matches!( + self.edit_prediction_preview, + EditPredictionPreview::Active { .. } + ) + } + + pub fn edit_predictions_enabled_at_cursor(&self, cx: &App) -> bool { + let cursor = self.selections.newest_anchor().head(); + if let Some((buffer, cursor_position)) = + self.buffer.read(cx).text_anchor_for_position(cursor, cx) + { + self.edit_predictions_enabled_in_buffer(&buffer, cursor_position, cx) + } else { + false + } + } + + fn edit_predictions_enabled_in_buffer( + &self, + buffer: &Entity, + buffer_position: language::Anchor, + cx: &App, + ) -> bool { + maybe!({ + if self.read_only(cx) { + return Some(false); + } + let provider = self.edit_prediction_provider()?; + if !provider.is_enabled(&buffer, buffer_position, cx) { + return Some(false); + } + let buffer = buffer.read(cx); + let Some(file) = buffer.file() else { + return Some(true); + }; + let settings = all_language_settings(Some(file), cx); + Some(settings.edit_predictions_enabled_for_file(file, cx)) + }) + .unwrap_or(false) + } + + fn cycle_inline_completion( + &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.inline_completions_hidden_for_vim_mode || !self.should_show_edit_predictions() { + return None; + } + + provider.cycle(buffer, cursor_buffer_position, direction, cx); + self.update_visible_inline_completion(window, cx); + + Some(()) + } + + pub fn show_inline_completion( + &mut self, + _: &ShowEditPrediction, + window: &mut Window, + cx: &mut Context, + ) { + if !self.has_active_inline_completion() { + self.refresh_inline_completion(false, true, window, cx); + return; + } + + self.update_visible_inline_completion(window, cx); + } + + pub fn display_cursor_names( + &mut self, + _: &DisplayCursorNames, + window: &mut Window, + cx: &mut Context, + ) { + self.show_cursor_names(window, cx); + } + + fn show_cursor_names(&mut self, window: &mut Window, cx: &mut Context) { + self.show_cursor_names = true; + cx.notify(); + cx.spawn_in(window, async move |this, cx| { + cx.background_executor().timer(CURSORS_VISIBLE_FOR).await; + this.update(cx, |this, cx| { + this.show_cursor_names = false; + cx.notify() + }) + .ok() + }) + .detach(); + } + + pub fn next_edit_prediction( + &mut self, + _: &NextEditPrediction, + window: &mut Window, + cx: &mut Context, + ) { + if self.has_active_inline_completion() { + self.cycle_inline_completion(Direction::Next, window, cx); + } else { + let is_copilot_disabled = self + .refresh_inline_completion(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_inline_completion() { + self.cycle_inline_completion(Direction::Prev, window, cx); + } else { + let is_copilot_disabled = self + .refresh_inline_completion(false, true, window, cx) + .is_none(); + if is_copilot_disabled { + cx.propagate(); + } + } + } + + pub fn accept_edit_prediction( + &mut self, + _: &AcceptEditPrediction, + window: &mut Window, + cx: &mut Context, + ) { + if self.show_edit_predictions_in_menu() { + self.hide_context_menu(window, cx); + } + + let Some(active_inline_completion) = self.active_inline_completion.as_ref() else { + return; + }; + + self.report_inline_completion_event( + active_inline_completion.completion_id.clone(), + true, + cx, + ); + + match &active_inline_completion.completion { + InlineCompletion::Move { 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( + Some(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 { + 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); + } + } + } + InlineCompletion::Edit { edits, .. } => { + if let Some(provider) = self.edit_prediction_provider() { + provider.accept(cx); + } + + 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.change_selections(None, window, cx, |s| { + s.select_anchor_ranges([last_edit_end..last_edit_end]) + }); + + self.update_visible_inline_completion(window, cx); + if self.active_inline_completion.is_none() { + self.refresh_inline_completion(true, true, window, cx); + } + + cx.notify(); + } + } + + self.edit_prediction_requires_modifier_in_indent_conflict = false; + } + + pub fn accept_partial_inline_completion( + &mut self, + _: &AcceptPartialEditPrediction, + window: &mut Window, + cx: &mut Context, + ) { + let Some(active_inline_completion) = self.active_inline_completion.as_ref() else { + return; + }; + if self.selections.count() != 1 { + return; + } + + self.report_inline_completion_event( + active_inline_completion.completion_id.clone(), + true, + cx, + ); + + match &active_inline_completion.completion { + InlineCompletion::Move { target, .. } => { + let target = *target; + self.change_selections(Some(Autoscroll::newest()), window, cx, |selections| { + selections.select_anchor_ranges([target..target]); + }); + } + InlineCompletion::Edit { edits, .. } => { + // Find an insertion that starts at the cursor position. + let snapshot = self.buffer.read(cx).snapshot(cx); + let cursor_offset = self.selections.newest::(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.insert_with_autoindent_mode(&partial_completion, None, window, cx); + + self.refresh_inline_completion(true, true, window, cx); + cx.notify(); + } else { + self.accept_edit_prediction(&Default::default(), window, cx); + } + } + } + } + + fn discard_inline_completion( + &mut self, + should_report_inline_completion_event: bool, + cx: &mut Context, + ) -> bool { + if should_report_inline_completion_event { + let completion_id = self + .active_inline_completion + .as_ref() + .and_then(|active_completion| active_completion.completion_id.clone()); + + self.report_inline_completion_event(completion_id, false, cx); + } + + if let Some(provider) = self.edit_prediction_provider() { + provider.discard(cx); + } + + self.take_active_inline_completion(cx) + } + + fn report_inline_completion_event(&self, id: Option, accepted: bool, cx: &App) { + let Some(provider) = self.edit_prediction_provider() else { + return; + }; + + let Some((_, buffer, _)) = self + .buffer + .read(cx) + .excerpt_containing(self.selections.newest_anchor().head(), cx) + else { + return; + }; + + let extension = buffer + .read(cx) + .file() + .and_then(|file| Some(file.path().extension()?.to_string_lossy().to_string())); + + let event_type = match accepted { + true => "Edit Prediction Accepted", + false => "Edit Prediction Discarded", + }; + telemetry::event!( + event_type, + provider = provider.name(), + prediction_id = id, + suggestion_accepted = accepted, + file_extension = extension, + ); + } + + pub fn has_active_inline_completion(&self) -> bool { + self.active_inline_completion.is_some() + } + + fn take_active_inline_completion(&mut self, cx: &mut Context) -> bool { + let Some(active_inline_completion) = self.active_inline_completion.take() else { + return false; + }; + + self.splice_inlays(&active_inline_completion.inlay_ids, Default::default(), cx); + self.clear_highlights::(cx); + self.stale_inline_completion_in_menu = Some(active_inline_completion); + true + } + + /// Returns true when we're displaying the edit prediction popover below the cursor + /// like we are not previewing and the LSP autocomplete menu is visible + /// or we are in `when_holding_modifier` mode. + pub fn edit_prediction_visible_in_cursor_popover(&self, has_completion: bool) -> bool { + if self.edit_prediction_preview_is_active() + || !self.show_edit_predictions_in_menu() + || !self.edit_predictions_enabled() + { + return false; + } + + if self.has_visible_completions_menu() { + return true; + } + + has_completion && self.edit_prediction_requires_modifier() + } + + fn handle_modifiers_changed( + &mut self, + modifiers: Modifiers, + position_map: &PositionMap, + window: &mut Window, + cx: &mut Context, + ) { + if self.show_edit_predictions_in_menu() { + self.update_edit_prediction_preview(&modifiers, window, cx); + } + + self.update_selection_mode(&modifiers, position_map, window, cx); + + let mouse_position = window.mouse_position(); + if !position_map.text_hitbox.is_hovered(window) { + return; + } + + self.update_hovered_link( + position_map.point_for_position(mouse_position), + &position_map.snapshot, + modifiers, + window, + cx, + ) + } + + fn update_selection_mode( + &mut self, + modifiers: &Modifiers, + position_map: &PositionMap, + window: &mut Window, + cx: &mut Context, + ) { + if modifiers != &COLUMNAR_SELECTION_MODIFIERS || self.selections.pending.is_none() { + return; + } + + let mouse_position = window.mouse_position(); + let point_for_position = position_map.point_for_position(mouse_position); + let position = point_for_position.previous_valid; + + self.select( + SelectPhase::BeginColumnar { + position, + reset: false, + goal_column: point_for_position.exact_unclipped.column(), + }, + window, + cx, + ); + } + + fn update_edit_prediction_preview( + &mut self, + modifiers: &Modifiers, + window: &mut Window, + cx: &mut Context, + ) { + let accept_keybind = self.accept_edit_prediction_keybind(window, cx); + let Some(accept_keystroke) = accept_keybind.keystroke() else { + return; + }; + + if &accept_keystroke.modifiers == modifiers && accept_keystroke.modifiers.modified() { + if matches!( + self.edit_prediction_preview, + EditPredictionPreview::Inactive { .. } + ) { + self.edit_prediction_preview = EditPredictionPreview::Active { + previous_scroll_position: None, + since: Instant::now(), + }; + + self.update_visible_inline_completion(window, cx); + cx.notify(); + } + } else if let EditPredictionPreview::Active { + previous_scroll_position, + since, + } = self.edit_prediction_preview + { + if let (Some(previous_scroll_position), Some(position_map)) = + (previous_scroll_position, self.last_position_map.as_ref()) + { + self.set_scroll_position( + previous_scroll_position + .scroll_position(&position_map.snapshot.display_snapshot), + window, + cx, + ); + } + + self.edit_prediction_preview = EditPredictionPreview::Inactive { + released_too_fast: since.elapsed() < Duration::from_millis(200), + }; + self.clear_row_highlights::(); + self.update_visible_inline_completion(window, cx); + cx.notify(); + } + } + + fn update_visible_inline_completion( + &mut self, + _window: &mut Window, + cx: &mut Context, + ) -> Option<()> { + let selection = self.selections.newest_anchor(); + let cursor = selection.head(); + let multibuffer = self.buffer.read(cx).snapshot(cx); + let offset_selection = selection.map(|endpoint| endpoint.to_offset(&multibuffer)); + let excerpt_id = cursor.excerpt_id; + + let show_in_menu = self.show_edit_predictions_in_menu(); + let completions_menu_has_precedence = !show_in_menu + && (self.context_menu.borrow().is_some() + || (!self.completion_tasks.is_empty() && !self.has_active_inline_completion())); + + if completions_menu_has_precedence + || !offset_selection.is_empty() + || self + .active_inline_completion + .as_ref() + .map_or(false, |completion| { + let invalidation_range = completion.invalidation_range.to_offset(&multibuffer); + let invalidation_range = invalidation_range.start..=invalidation_range.end; + !invalidation_range.contains(&offset_selection.head()) + }) + { + self.discard_inline_completion(false, cx); + return None; + } + + self.take_active_inline_completion(cx); + let Some(provider) = self.edit_prediction_provider() else { + self.edit_prediction_settings = EditPredictionSettings::Disabled; + return None; + }; + + let (buffer, cursor_buffer_position) = + self.buffer.read(cx).text_anchor_for_position(cursor, cx)?; + + self.edit_prediction_settings = + self.edit_prediction_settings_at_position(&buffer, cursor_buffer_position, cx); + + self.edit_prediction_indent_conflict = multibuffer.is_line_whitespace_upto(cursor); + + if self.edit_prediction_indent_conflict { + let cursor_point = cursor.to_point(&multibuffer); + + let indents = multibuffer.suggested_indents(cursor_point.row..cursor_point.row + 1, cx); + + if let Some((_, indent)) = indents.iter().next() { + if indent.len == cursor_point.column { + self.edit_prediction_indent_conflict = false; + } + } + } + + let inline_completion = provider.suggest(&buffer, cursor_buffer_position, cx)?; + let edits = inline_completion + .edits + .into_iter() + .flat_map(|(range, new_text)| { + let start = multibuffer.anchor_in_excerpt(excerpt_id, range.start)?; + let end = multibuffer.anchor_in_excerpt(excerpt_id, range.end)?; + Some((start..end, new_text)) + }) + .collect::>(); + if edits.is_empty() { + return None; + } + + let first_edit_start = edits.first().unwrap().0.start; + let first_edit_start_point = first_edit_start.to_point(&multibuffer); + let edit_start_row = first_edit_start_point.row.saturating_sub(2); + + let last_edit_end = edits.last().unwrap().0.end; + let last_edit_end_point = last_edit_end.to_point(&multibuffer); + let edit_end_row = cmp::min(multibuffer.max_point().row, last_edit_end_point.row + 2); + + let cursor_row = cursor.to_point(&multibuffer).row; + + let snapshot = multibuffer.buffer_for_excerpt(excerpt_id).cloned()?; + + let mut inlay_ids = Vec::new(); + let invalidation_row_range; + let move_invalidation_row_range = if cursor_row < edit_start_row { + Some(cursor_row..edit_end_row) + } else if cursor_row > edit_end_row { + Some(edit_start_row..cursor_row) + } else { + None + }; + let is_move = + move_invalidation_row_range.is_some() || self.inline_completions_hidden_for_vim_mode; + let completion = if is_move { + invalidation_row_range = + move_invalidation_row_range.unwrap_or(edit_start_row..edit_end_row); + let target = first_edit_start; + InlineCompletion::Move { target, snapshot } + } else { + let show_completions_in_buffer = !self.edit_prediction_visible_in_cursor_popover(true) + && !self.inline_completions_hidden_for_vim_mode; + + if show_completions_in_buffer { + if edits + .iter() + .all(|(range, _)| range.to_offset(&multibuffer).is_empty()) + { + let mut inlays = Vec::new(); + for (range, new_text) in &edits { + let inlay = Inlay::inline_completion( + post_inc(&mut self.next_inlay_id), + range.start, + new_text.as_str(), + ); + inlay_ids.push(inlay.id); + inlays.push(inlay); + } + + self.splice_inlays(&[], inlays, cx); + } else { + let background_color = cx.theme().status().deleted_background; + self.highlight_text::( + edits.iter().map(|(range, _)| range.clone()).collect(), + HighlightStyle { + background_color: Some(background_color), + ..Default::default() + }, + cx, + ); + } + } + + invalidation_row_range = edit_start_row..edit_end_row; + + let display_mode = if all_edits_insertions_or_deletions(&edits, &multibuffer) { + if provider.show_tab_accept_marker() { + EditDisplayMode::TabAccept + } else { + EditDisplayMode::Inline + } + } else { + EditDisplayMode::DiffPopover + }; + + InlineCompletion::Edit { + edits, + edit_preview: inline_completion.edit_preview, + display_mode, + snapshot, + } + }; + + let invalidation_range = multibuffer + .anchor_before(Point::new(invalidation_row_range.start, 0)) + ..multibuffer.anchor_after(Point::new( + invalidation_row_range.end, + multibuffer.line_len(MultiBufferRow(invalidation_row_range.end)), + )); + + self.stale_inline_completion_in_menu = None; + self.active_inline_completion = Some(InlineCompletionState { + inlay_ids, + completion, + completion_id: inline_completion.id, + invalidation_range, + }); + + cx.notify(); + + Some(()) + } + + pub fn edit_prediction_provider(&self) -> Option> { + Some(self.edit_prediction_provider.as_ref()?.provider.clone()) + } + + fn render_code_actions_indicator( + &self, + _style: &EditorStyle, + row: DisplayRow, + is_active: bool, + breakpoint: Option<&(Anchor, Breakpoint)>, + cx: &mut Context, + ) -> Option { + let color = Color::Muted; + let position = breakpoint.as_ref().map(|(anchor, _)| *anchor); + let show_tooltip = !self.context_menu_visible(); + + if self.available_code_actions.is_some() { + Some( + IconButton::new("code_actions_indicator", ui::IconName::Bolt) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::XSmall) + .icon_color(color) + .toggle_state(is_active) + .when(show_tooltip, |this| { + this.tooltip({ + let focus_handle = self.focus_handle.clone(); + move |window, cx| { + Tooltip::for_action_in( + "Toggle Code Actions", + &ToggleCodeActions { + deployed_from_indicator: None, + quick_launch: false, + }, + &focus_handle, + window, + cx, + ) + } + }) + }) + .on_click(cx.listener(move |editor, e: &ClickEvent, window, cx| { + let quick_launch = e.down.button == MouseButton::Left; + window.focus(&editor.focus_handle(cx)); + editor.toggle_code_actions( + &ToggleCodeActions { + deployed_from_indicator: Some(row), + quick_launch, + }, + window, + cx, + ); + })) + .on_right_click(cx.listener(move |editor, event: &ClickEvent, window, cx| { + editor.set_breakpoint_context_menu( + row, + position, + event.down.position, + window, + cx, + ); + })), + ) + } else { + None + } + } + + fn clear_tasks(&mut self) { + self.tasks.clear() + } + + fn insert_tasks(&mut self, key: (BufferId, BufferRow), value: RunnableTasks) { + if self.tasks.insert(key, value).is_some() { + // This case should hopefully be rare, but just in case... + log::error!( + "multiple different run targets found on a single line, only the last target will be rendered" + ) + } + } + + /// Get all display points of breakpoints that will be rendered within editor + /// + /// This function is used to handle overlaps between breakpoints and Code action/runner symbol. + /// It's also used to set the color of line numbers with breakpoints to the breakpoint color. + /// TODO debugger: Use this function to color toggle symbols that house nested breakpoints + fn active_breakpoints( + &self, + range: Range, + window: &mut Window, + cx: &mut Context, + ) -> HashMap { + let mut breakpoint_display_points = HashMap::default(); + + let Some(breakpoint_store) = self.breakpoint_store.clone() else { + return breakpoint_display_points; + }; + + let snapshot = self.snapshot(window, cx); + + let multi_buffer_snapshot = &snapshot.display_snapshot.buffer_snapshot; + let Some(project) = self.project.as_ref() else { + return breakpoint_display_points; + }; + + let range = snapshot.display_point_to_point(DisplayPoint::new(range.start, 0), Bias::Left) + ..snapshot.display_point_to_point(DisplayPoint::new(range.end, 0), Bias::Right); + + for (buffer_snapshot, range, excerpt_id) in + multi_buffer_snapshot.range_to_buffer_ranges(range) + { + let Some(buffer) = project.read_with(cx, |this, cx| { + this.buffer_for_id(buffer_snapshot.remote_id(), cx) + }) else { + continue; + }; + let breakpoints = breakpoint_store.read(cx).breakpoints( + &buffer, + Some( + buffer_snapshot.anchor_before(range.start) + ..buffer_snapshot.anchor_after(range.end), + ), + buffer_snapshot, + cx, + ); + for (anchor, breakpoint) in breakpoints { + let multi_buffer_anchor = + Anchor::in_buffer(excerpt_id, buffer_snapshot.remote_id(), *anchor); + let position = multi_buffer_anchor + .to_point(&multi_buffer_snapshot) + .to_display_point(&snapshot); + + breakpoint_display_points + .insert(position.row(), (multi_buffer_anchor, breakpoint.clone())); + } + } + + breakpoint_display_points + } + + fn breakpoint_context_menu( + &self, + anchor: Anchor, + window: &mut Window, + cx: &mut Context, + ) -> Entity { + let weak_editor = cx.weak_entity(); + let focus_handle = self.focus_handle(cx); + + let row = self + .buffer + .read(cx) + .snapshot(cx) + .summary_for_anchor::(&anchor) + .row; + + let breakpoint = self + .breakpoint_at_row(row, window, cx) + .map(|(anchor, bp)| (anchor, Arc::from(bp))); + + let log_breakpoint_msg = if breakpoint.as_ref().is_some_and(|bp| bp.1.message.is_some()) { + "Edit Log Breakpoint" + } else { + "Set Log Breakpoint" + }; + + let condition_breakpoint_msg = if breakpoint + .as_ref() + .is_some_and(|bp| bp.1.condition.is_some()) + { + "Edit Condition Breakpoint" + } else { + "Set Condition Breakpoint" + }; + + let hit_condition_breakpoint_msg = if breakpoint + .as_ref() + .is_some_and(|bp| bp.1.hit_condition.is_some()) + { + "Edit Hit Condition Breakpoint" + } else { + "Set Hit Condition Breakpoint" + }; + + let set_breakpoint_msg = if breakpoint.as_ref().is_some() { + "Unset Breakpoint" + } else { + "Set Breakpoint" + }; + + let run_to_cursor = command_palette_hooks::CommandPaletteFilter::try_global(cx) + .map_or(false, |filter| !filter.is_hidden(&DebuggerRunToCursor)); + + let toggle_state_msg = breakpoint.as_ref().map_or(None, |bp| match bp.1.state { + BreakpointState::Enabled => Some("Disable"), + BreakpointState::Disabled => Some("Enable"), + }); + + let (anchor, breakpoint) = + breakpoint.unwrap_or_else(|| (anchor, Arc::new(Breakpoint::new_standard()))); + + ui::ContextMenu::build(window, cx, |menu, _, _cx| { + menu.on_blur_subscription(Subscription::new(|| {})) + .context(focus_handle) + .when(run_to_cursor, |this| { + let weak_editor = weak_editor.clone(); + this.entry("Run to cursor", None, move |window, cx| { + weak_editor + .update(cx, |editor, cx| { + editor.change_selections(None, window, cx, |s| { + s.select_ranges([Point::new(row, 0)..Point::new(row, 0)]) + }); + }) + .ok(); + + window.dispatch_action(Box::new(DebuggerRunToCursor), cx); + }) + .separator() + }) + .when_some(toggle_state_msg, |this, msg| { + this.entry(msg, None, { + let weak_editor = weak_editor.clone(); + let breakpoint = breakpoint.clone(); + move |_window, cx| { + weak_editor + .update(cx, |this, cx| { + this.edit_breakpoint_at_anchor( + anchor, + breakpoint.as_ref().clone(), + BreakpointEditAction::InvertState, + cx, + ); + }) + .log_err(); + } + }) + }) + .entry(set_breakpoint_msg, None, { + let weak_editor = weak_editor.clone(); + let breakpoint = breakpoint.clone(); + move |_window, cx| { + weak_editor + .update(cx, |this, cx| { + this.edit_breakpoint_at_anchor( + anchor, + breakpoint.as_ref().clone(), + BreakpointEditAction::Toggle, + cx, + ); + }) + .log_err(); + } + }) + .entry(log_breakpoint_msg, None, { + let breakpoint = breakpoint.clone(); + let weak_editor = weak_editor.clone(); + move |window, cx| { + weak_editor + .update(cx, |this, cx| { + this.add_edit_breakpoint_block( + anchor, + breakpoint.as_ref(), + BreakpointPromptEditAction::Log, + window, + cx, + ); + }) + .log_err(); + } + }) + .entry(condition_breakpoint_msg, None, { + let breakpoint = breakpoint.clone(); + let weak_editor = weak_editor.clone(); + move |window, cx| { + weak_editor + .update(cx, |this, cx| { + this.add_edit_breakpoint_block( + anchor, + breakpoint.as_ref(), + BreakpointPromptEditAction::Condition, + window, + cx, + ); + }) + .log_err(); + } + }) + .entry(hit_condition_breakpoint_msg, None, move |window, cx| { + weak_editor + .update(cx, |this, cx| { + this.add_edit_breakpoint_block( + anchor, + breakpoint.as_ref(), + BreakpointPromptEditAction::HitCondition, + window, + cx, + ); + }) + .log_err(); + }) + }) + } + + fn render_breakpoint( + &self, + position: Anchor, + row: DisplayRow, + breakpoint: &Breakpoint, + cx: &mut Context, + ) -> IconButton { + // Is it a breakpoint that shows up when hovering over gutter? + let (is_phantom, collides_with_existing) = self.gutter_breakpoint_indicator.0.map_or( + (false, false), + |PhantomBreakpointIndicator { + is_active, + display_row, + collides_with_existing_breakpoint, + }| { + ( + is_active && display_row == row, + collides_with_existing_breakpoint, + ) + }, + ); + + let (color, icon) = { + let icon = match (&breakpoint.message.is_some(), breakpoint.is_disabled()) { + (false, false) => ui::IconName::DebugBreakpoint, + (true, false) => ui::IconName::DebugLogBreakpoint, + (false, true) => ui::IconName::DebugDisabledBreakpoint, + (true, true) => ui::IconName::DebugDisabledLogBreakpoint, + }; + + let color = if is_phantom { + Color::Hint + } else { + Color::Debugger + }; + + (color, icon) + }; + + let breakpoint = Arc::from(breakpoint.clone()); + + let alt_as_text = gpui::Keystroke { + modifiers: Modifiers::secondary_key(), + ..Default::default() + }; + let primary_action_text = if breakpoint.is_disabled() { + "enable" + } else if is_phantom && !collides_with_existing { + "set" + } else { + "unset" + }; + let mut primary_text = format!("Click to {primary_action_text}"); + if collides_with_existing && !breakpoint.is_disabled() { + use std::fmt::Write; + write!(primary_text, ", {alt_as_text}-click to disable").ok(); + } + let primary_text = SharedString::from(primary_text); + let focus_handle = self.focus_handle.clone(); + IconButton::new(("breakpoint_indicator", row.0 as usize), icon) + .icon_size(IconSize::XSmall) + .size(ui::ButtonSize::None) + .icon_color(color) + .style(ButtonStyle::Transparent) + .on_click(cx.listener({ + let breakpoint = breakpoint.clone(); + + move |editor, event: &ClickEvent, window, cx| { + let edit_action = if event.modifiers().platform || breakpoint.is_disabled() { + BreakpointEditAction::InvertState + } else { + BreakpointEditAction::Toggle + }; + + window.focus(&editor.focus_handle(cx)); + editor.edit_breakpoint_at_anchor( + position, + breakpoint.as_ref().clone(), + edit_action, + cx, + ); + } + })) + .on_right_click(cx.listener(move |editor, event: &ClickEvent, window, cx| { + editor.set_breakpoint_context_menu( + row, + Some(position), + event.down.position, + window, + cx, + ); + })) + .tooltip(move |window, cx| { + Tooltip::with_meta_in( + primary_text.clone(), + None, + "Right-click for more options", + &focus_handle, + window, + cx, + ) + }) + } + + fn build_tasks_context( + project: &Entity, + buffer: &Entity, + buffer_row: u32, + tasks: &Arc, + cx: &mut Context, + ) -> Task> { + let position = Point::new(buffer_row, tasks.column); + let range_start = buffer.read(cx).anchor_at(position, Bias::Right); + let location = Location { + buffer: buffer.clone(), + range: range_start..range_start, + }; + // Fill in the environmental variables from the tree-sitter captures + let mut captured_task_variables = TaskVariables::default(); + for (capture_name, value) in tasks.extra_variables.clone() { + captured_task_variables.insert( + task::VariableName::Custom(capture_name.into()), + value.clone(), + ); + } + project.update(cx, |project, cx| { + project.task_store().update(cx, |task_store, cx| { + task_store.task_context_for_location(captured_task_variables, location, cx) + }) + }) + } + + pub fn spawn_nearest_task( + &mut self, + action: &SpawnNearestTask, + window: &mut Window, + cx: &mut Context, + ) { + let Some((workspace, _)) = self.workspace.clone() else { + return; + }; + let Some(project) = self.project.clone() else { + return; + }; + + // Try to find a closest, enclosing node using tree-sitter that has a + // task + let Some((buffer, buffer_row, tasks)) = self + .find_enclosing_node_task(cx) + // Or find the task that's closest in row-distance. + .or_else(|| self.find_closest_task(cx)) + else { + return; + }; + + let reveal_strategy = action.reveal; + let task_context = Self::build_tasks_context(&project, &buffer, buffer_row, &tasks, cx); + cx.spawn_in(window, async move |_, cx| { + let context = task_context.await?; + let (task_source_kind, mut resolved_task) = tasks.resolve(&context).next()?; + + let resolved = &mut resolved_task.resolved; + resolved.reveal = reveal_strategy; + + workspace + .update_in(cx, |workspace, window, cx| { + workspace.schedule_resolved_task( + task_source_kind, + resolved_task, + false, + window, + cx, + ); + }) + .ok() + }) + .detach(); + } + + fn find_closest_task( + &mut self, + cx: &mut Context, + ) -> Option<(Entity, u32, Arc)> { + let cursor_row = self.selections.newest_adjusted(cx).head().row; + + let ((buffer_id, row), tasks) = self + .tasks + .iter() + .min_by_key(|((_, row), _)| cursor_row.abs_diff(*row))?; + + let buffer = self.buffer.read(cx).buffer(*buffer_id)?; + let tasks = Arc::new(tasks.to_owned()); + Some((buffer, *row, tasks)) + } + + fn find_enclosing_node_task( + &mut self, + cx: &mut Context, + ) -> Option<(Entity, u32, Arc)> { + let snapshot = self.buffer.read(cx).snapshot(cx); + let offset = self.selections.newest::(cx).head(); + let excerpt = snapshot.excerpt_containing(offset..offset)?; + let buffer_id = excerpt.buffer().remote_id(); + + let layer = excerpt.buffer().syntax_layer_at(offset)?; + let mut cursor = layer.node().walk(); + + while cursor.goto_first_child_for_byte(offset).is_some() { + if cursor.node().end_byte() == offset { + cursor.goto_next_sibling(); + } + } + + // Ascend to the smallest ancestor that contains the range and has a task. + loop { + let node = cursor.node(); + let node_range = node.byte_range(); + let symbol_start_row = excerpt.buffer().offset_to_point(node.start_byte()).row; + + // Check if this node contains our offset + if node_range.start <= offset && node_range.end >= offset { + // If it contains offset, check for task + if let Some(tasks) = self.tasks.get(&(buffer_id, symbol_start_row)) { + let buffer = self.buffer.read(cx).buffer(buffer_id)?; + return Some((buffer, symbol_start_row, Arc::new(tasks.to_owned()))); + } + } + + if !cursor.goto_parent() { + break; + } + } + None + } + + fn render_run_indicator( + &self, + _style: &EditorStyle, + is_active: bool, + row: DisplayRow, + breakpoint: Option<(Anchor, Breakpoint)>, + cx: &mut Context, + ) -> IconButton { + let color = Color::Muted; + let position = breakpoint.as_ref().map(|(anchor, _)| *anchor); + + IconButton::new(("run_indicator", row.0 as usize), ui::IconName::Play) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::XSmall) + .icon_color(color) + .toggle_state(is_active) + .on_click(cx.listener(move |editor, e: &ClickEvent, window, cx| { + let quick_launch = e.down.button == MouseButton::Left; + window.focus(&editor.focus_handle(cx)); + editor.toggle_code_actions( + &ToggleCodeActions { + deployed_from_indicator: Some(row), + quick_launch, + }, + window, + cx, + ); + })) + .on_right_click(cx.listener(move |editor, event: &ClickEvent, window, cx| { + editor.set_breakpoint_context_menu(row, position, event.down.position, window, cx); + })) + } + + pub fn context_menu_visible(&self) -> bool { + !self.edit_prediction_preview_is_active() + && self + .context_menu + .borrow() + .as_ref() + .map_or(false, |menu| menu.visible()) + } + + fn context_menu_origin(&self) -> Option { + self.context_menu + .borrow() + .as_ref() + .map(|menu| menu.origin()) + } + + pub fn set_context_menu_options(&mut self, options: ContextMenuOptions) { + self.context_menu_options = Some(options); + } + + const EDIT_PREDICTION_POPOVER_PADDING_X: Pixels = Pixels(24.); + const EDIT_PREDICTION_POPOVER_PADDING_Y: Pixels = Pixels(2.); + + fn render_edit_prediction_popover( + &mut self, + text_bounds: &Bounds, + content_origin: gpui::Point, + editor_snapshot: &EditorSnapshot, + visible_row_range: Range, + scroll_top: f32, + scroll_bottom: f32, + line_layouts: &[LineWithInvisibles], + line_height: Pixels, + scroll_pixel_position: gpui::Point, + newest_selection_head: Option, + editor_width: Pixels, + style: &EditorStyle, + window: &mut Window, + cx: &mut App, + ) -> Option<(AnyElement, gpui::Point)> { + let active_inline_completion = self.active_inline_completion.as_ref()?; + + if self.edit_prediction_visible_in_cursor_popover(true) { + return None; + } + + match &active_inline_completion.completion { + InlineCompletion::Move { target, .. } => { + let target_display_point = target.to_display_point(editor_snapshot); + + if self.edit_prediction_requires_modifier() { + if !self.edit_prediction_preview_is_active() { + return None; + } + + self.render_edit_prediction_modifier_jump_popover( + text_bounds, + content_origin, + visible_row_range, + line_layouts, + line_height, + scroll_pixel_position, + newest_selection_head, + target_display_point, + window, + cx, + ) + } else { + self.render_edit_prediction_eager_jump_popover( + text_bounds, + content_origin, + editor_snapshot, + visible_row_range, + scroll_top, + scroll_bottom, + line_height, + scroll_pixel_position, + target_display_point, + editor_width, + window, + cx, + ) + } + } + InlineCompletion::Edit { + display_mode: EditDisplayMode::Inline, + .. + } => None, + InlineCompletion::Edit { + display_mode: EditDisplayMode::TabAccept, + edits, + .. + } => { + let range = &edits.first()?.0; + let target_display_point = range.end.to_display_point(editor_snapshot); + + self.render_edit_prediction_end_of_line_popover( + "Accept", + editor_snapshot, + visible_row_range, + target_display_point, + line_height, + scroll_pixel_position, + content_origin, + editor_width, + window, + cx, + ) + } + InlineCompletion::Edit { + edits, + edit_preview, + display_mode: EditDisplayMode::DiffPopover, + snapshot, + } => self.render_edit_prediction_diff_popover( + text_bounds, + content_origin, + editor_snapshot, + visible_row_range, + line_layouts, + line_height, + scroll_pixel_position, + newest_selection_head, + editor_width, + style, + edits, + edit_preview, + snapshot, + window, + cx, + ), + } + } + + fn render_edit_prediction_modifier_jump_popover( + &mut self, + text_bounds: &Bounds, + content_origin: gpui::Point, + visible_row_range: Range, + line_layouts: &[LineWithInvisibles], + line_height: Pixels, + scroll_pixel_position: gpui::Point, + newest_selection_head: Option, + target_display_point: DisplayPoint, + window: &mut Window, + cx: &mut App, + ) -> Option<(AnyElement, gpui::Point)> { + let scrolled_content_origin = + content_origin - gpui::Point::new(scroll_pixel_position.x, Pixels(0.0)); + + const SCROLL_PADDING_Y: Pixels = px(12.); + + if target_display_point.row() < visible_row_range.start { + return self.render_edit_prediction_scroll_popover( + |_| SCROLL_PADDING_Y, + IconName::ArrowUp, + visible_row_range, + line_layouts, + newest_selection_head, + scrolled_content_origin, + window, + cx, + ); + } else if target_display_point.row() >= visible_row_range.end { + return self.render_edit_prediction_scroll_popover( + |size| text_bounds.size.height - size.height - SCROLL_PADDING_Y, + IconName::ArrowDown, + visible_row_range, + line_layouts, + newest_selection_head, + scrolled_content_origin, + window, + cx, + ); + } + + const POLE_WIDTH: Pixels = px(2.); + + let line_layout = + line_layouts.get(target_display_point.row().minus(visible_row_range.start) as usize)?; + let target_column = target_display_point.column() as usize; + + let target_x = line_layout.x_for_index(target_column); + let target_y = + (target_display_point.row().as_f32() * line_height) - scroll_pixel_position.y; + + let flag_on_right = target_x < text_bounds.size.width / 2.; + + let mut border_color = Self::edit_prediction_callout_popover_border_color(cx); + border_color.l += 0.001; + + let mut element = v_flex() + .items_end() + .when(flag_on_right, |el| el.items_start()) + .child(if flag_on_right { + self.render_edit_prediction_line_popover("Jump", None, window, cx)? + .rounded_bl(px(0.)) + .rounded_tl(px(0.)) + .border_l_2() + .border_color(border_color) + } else { + self.render_edit_prediction_line_popover("Jump", None, window, cx)? + .rounded_br(px(0.)) + .rounded_tr(px(0.)) + .border_r_2() + .border_color(border_color) + }) + .child(div().w(POLE_WIDTH).bg(border_color).h(line_height)) + .into_any(); + + let size = element.layout_as_root(AvailableSpace::min_size(), window, cx); + + let mut origin = scrolled_content_origin + point(target_x, target_y) + - point( + if flag_on_right { + POLE_WIDTH + } else { + size.width - POLE_WIDTH + }, + size.height - line_height, + ); + + origin.x = origin.x.max(content_origin.x); + + element.prepaint_at(origin, window, cx); + + Some((element, origin)) + } + + fn render_edit_prediction_scroll_popover( + &mut self, + to_y: impl Fn(Size) -> Pixels, + scroll_icon: IconName, + visible_row_range: Range, + line_layouts: &[LineWithInvisibles], + newest_selection_head: Option, + scrolled_content_origin: gpui::Point, + window: &mut Window, + cx: &mut App, + ) -> Option<(AnyElement, gpui::Point)> { + let mut element = self + .render_edit_prediction_line_popover("Scroll", Some(scroll_icon), window, cx)? + .into_any(); + + let size = element.layout_as_root(AvailableSpace::min_size(), window, cx); + + let cursor = newest_selection_head?; + let cursor_row_layout = + line_layouts.get(cursor.row().minus(visible_row_range.start) as usize)?; + let cursor_column = cursor.column() as usize; + + let cursor_character_x = cursor_row_layout.x_for_index(cursor_column); + + let origin = scrolled_content_origin + point(cursor_character_x, to_y(size)); + + element.prepaint_at(origin, window, cx); + Some((element, origin)) + } + + fn render_edit_prediction_eager_jump_popover( + &mut self, + text_bounds: &Bounds, + content_origin: gpui::Point, + editor_snapshot: &EditorSnapshot, + visible_row_range: Range, + scroll_top: f32, + scroll_bottom: f32, + line_height: Pixels, + scroll_pixel_position: gpui::Point, + target_display_point: DisplayPoint, + editor_width: Pixels, + window: &mut Window, + cx: &mut App, + ) -> Option<(AnyElement, gpui::Point)> { + if target_display_point.row().as_f32() < scroll_top { + let mut element = self + .render_edit_prediction_line_popover( + "Jump to Edit", + Some(IconName::ArrowUp), + window, + cx, + )? + .into_any(); + + let size = element.layout_as_root(AvailableSpace::min_size(), window, cx); + let offset = point( + (text_bounds.size.width - size.width) / 2., + Self::EDIT_PREDICTION_POPOVER_PADDING_Y, + ); + + let origin = text_bounds.origin + offset; + element.prepaint_at(origin, window, cx); + Some((element, origin)) + } else if (target_display_point.row().as_f32() + 1.) > scroll_bottom { + let mut element = self + .render_edit_prediction_line_popover( + "Jump to Edit", + Some(IconName::ArrowDown), + window, + cx, + )? + .into_any(); + + let size = element.layout_as_root(AvailableSpace::min_size(), window, cx); + let offset = point( + (text_bounds.size.width - size.width) / 2., + text_bounds.size.height - size.height - Self::EDIT_PREDICTION_POPOVER_PADDING_Y, + ); + + let origin = text_bounds.origin + offset; + element.prepaint_at(origin, window, cx); + Some((element, origin)) + } else { + self.render_edit_prediction_end_of_line_popover( + "Jump to Edit", + editor_snapshot, + visible_row_range, + target_display_point, + line_height, + scroll_pixel_position, + content_origin, + editor_width, + window, + cx, + ) + } + } + + fn render_edit_prediction_end_of_line_popover( + self: &mut Editor, + label: &'static str, + editor_snapshot: &EditorSnapshot, + visible_row_range: Range, + target_display_point: DisplayPoint, + line_height: Pixels, + scroll_pixel_position: gpui::Point, + content_origin: gpui::Point, + editor_width: Pixels, + window: &mut Window, + cx: &mut App, + ) -> Option<(AnyElement, gpui::Point)> { + let target_line_end = DisplayPoint::new( + target_display_point.row(), + editor_snapshot.line_len(target_display_point.row()), + ); + + let mut element = self + .render_edit_prediction_line_popover(label, None, window, cx)? + .into_any(); + + 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 start_point = content_origin - point(scroll_pixel_position.x, Pixels::ZERO); + let mut origin = start_point + + line_origin + + point(Self::EDIT_PREDICTION_POPOVER_PADDING_X, Pixels::ZERO); + origin.x = origin.x.max(content_origin.x); + + let max_x = content_origin.x + editor_width - size.width; + + if origin.x > max_x { + let offset = line_height + Self::EDIT_PREDICTION_POPOVER_PADDING_Y; + + let icon = if visible_row_range.contains(&(target_display_point.row() + 2)) { + origin.y += offset; + IconName::ArrowUp + } else { + origin.y -= offset; + IconName::ArrowDown + }; + + element = self + .render_edit_prediction_line_popover(label, Some(icon), window, cx)? + .into_any(); + + let size = element.layout_as_root(AvailableSpace::min_size(), window, cx); + + origin.x = content_origin.x + editor_width - size.width - px(2.); + } + + element.prepaint_at(origin, window, cx); + Some((element, origin)) + } + + fn render_edit_prediction_diff_popover( + self: &Editor, + text_bounds: &Bounds, + content_origin: gpui::Point, + editor_snapshot: &EditorSnapshot, + visible_row_range: Range, + line_layouts: &[LineWithInvisibles], + line_height: Pixels, + scroll_pixel_position: gpui::Point, + newest_selection_head: Option, + editor_width: Pixels, + style: &EditorStyle, + edits: &Vec<(Range, String)>, + edit_preview: &Option, + snapshot: &language::BufferSnapshot, + window: &mut Window, + cx: &mut App, + ) -> Option<(AnyElement, gpui::Point)> { + let edit_start = edits + .first() + .unwrap() + .0 + .start + .to_display_point(editor_snapshot); + let edit_end = edits + .last() + .unwrap() + .0 + .end + .to_display_point(editor_snapshot); + + let is_visible = visible_row_range.contains(&edit_start.row()) + || visible_row_range.contains(&edit_end.row()); + if !is_visible { + return None; + } + + let highlighted_edits = + crate::inline_completion_edit_text(&snapshot, edits, edit_preview.as_ref()?, false, cx); + + let styled_text = highlighted_edits.to_styled_text(&style.text); + let line_count = highlighted_edits.text.lines().count(); + + const BORDER_WIDTH: Pixels = px(1.); + + let keybind = self.render_edit_prediction_accept_keybind(window, cx); + let has_keybind = keybind.is_some(); + + let mut element = h_flex() + .items_start() + .child( + h_flex() + .bg(cx.theme().colors().editor_background) + .border(BORDER_WIDTH) + .shadow_sm() + .border_color(cx.theme().colors().border) + .rounded_l_lg() + .when(line_count > 1, |el| el.rounded_br_lg()) + .pr_1() + .child(styled_text), + ) + .child( + h_flex() + .h(line_height + BORDER_WIDTH * 2.) + .px_1p5() + .gap_1() + // Workaround: For some reason, there's a gap if we don't do this + .ml(-BORDER_WIDTH) + .shadow(vec![gpui::BoxShadow { + color: gpui::black().opacity(0.05), + offset: point(px(1.), px(1.)), + blur_radius: px(2.), + spread_radius: px(0.), + }]) + .bg(Editor::edit_prediction_line_popover_bg_color(cx)) + .border(BORDER_WIDTH) + .border_color(cx.theme().colors().border) + .rounded_r_lg() + .id("edit_prediction_diff_popover_keybind") + .when(!has_keybind, |el| { + let status_colors = cx.theme().status(); + + el.bg(status_colors.error_background) + .border_color(status_colors.error.opacity(0.6)) + .child(Icon::new(IconName::Info).color(Color::Error)) + .cursor_default() + .hoverable_tooltip(move |_window, cx| { + cx.new(|_| MissingEditPredictionKeybindingTooltip).into() + }) + }) + .children(keybind), + ) + .into_any(); + + let longest_row = + editor_snapshot.longest_row_in_range(edit_start.row()..edit_end.row() + 1); + let longest_line_width = if visible_row_range.contains(&longest_row) { + line_layouts[(longest_row.0 - visible_row_range.start.0) as usize].width + } else { + layout_line( + longest_row, + editor_snapshot, + style, + editor_width, + |_| false, + window, + cx, + ) + .width + }; + + let viewport_bounds = + Bounds::new(Default::default(), window.viewport_size()).extend(Edges { + right: -EditorElement::SCROLLBAR_WIDTH, + ..Default::default() + }); + + let x_after_longest = + text_bounds.origin.x + longest_line_width + Self::EDIT_PREDICTION_POPOVER_PADDING_X + - scroll_pixel_position.x; + + let element_bounds = element.layout_as_root(AvailableSpace::min_size(), window, cx); + + // Fully visible if it can be displayed within the window (allow overlapping other + // panes). However, this is only allowed if the popover starts within text_bounds. + let can_position_to_the_right = x_after_longest < text_bounds.right() + && x_after_longest + element_bounds.width < viewport_bounds.right(); + + let mut origin = if can_position_to_the_right { + point( + x_after_longest, + text_bounds.origin.y + edit_start.row().as_f32() * line_height + - scroll_pixel_position.y, + ) + } else { + let cursor_row = newest_selection_head.map(|head| head.row()); + let above_edit = edit_start + .row() + .0 + .checked_sub(line_count as u32) + .map(DisplayRow); + let below_edit = Some(edit_end.row() + 1); + let above_cursor = + cursor_row.and_then(|row| row.0.checked_sub(line_count as u32).map(DisplayRow)); + let below_cursor = cursor_row.map(|cursor_row| cursor_row + 1); + + // Place the edit popover adjacent to the edit if there is a location + // available that is onscreen and does not obscure the cursor. Otherwise, + // place it adjacent to the cursor. + let row_target = [above_edit, below_edit, above_cursor, below_cursor] + .into_iter() + .flatten() + .find(|&start_row| { + let end_row = start_row + line_count as u32; + visible_row_range.contains(&start_row) + && visible_row_range.contains(&end_row) + && cursor_row.map_or(true, |cursor_row| { + !((start_row..end_row).contains(&cursor_row)) + }) + })?; + + content_origin + + point( + -scroll_pixel_position.x, + row_target.as_f32() * line_height - scroll_pixel_position.y, + ) + }; + + origin.x -= BORDER_WIDTH; + + window.defer_draw(element, origin, 1); + + // Do not return an element, since it will already be drawn due to defer_draw. + None + } + + fn edit_prediction_cursor_popover_height(&self) -> Pixels { + px(30.) + } + + fn current_user_player_color(&self, cx: &mut App) -> PlayerColor { + if self.read_only(cx) { + cx.theme().players().read_only() + } else { + self.style.as_ref().unwrap().local_player + } + } + + fn render_edit_prediction_accept_keybind( + &self, + window: &mut Window, + cx: &App, + ) -> Option { + let accept_binding = self.accept_edit_prediction_keybind(window, cx); + let accept_keystroke = accept_binding.keystroke()?; + + let is_platform_style_mac = PlatformStyle::platform() == PlatformStyle::Mac; + + let modifiers_color = if accept_keystroke.modifiers == window.modifiers() { + Color::Accent + } else { + Color::Muted + }; + + h_flex() + .px_0p5() + .when(is_platform_style_mac, |parent| parent.gap_0p5()) + .font(theme_settings::ThemeSettings::get_global(cx).buffer_font.clone()) + .text_size(TextSize::XSmall.rems(cx)) + .child(h_flex().children(ui::render_modifiers( + &accept_keystroke.modifiers, + PlatformStyle::platform(), + Some(modifiers_color), + Some(IconSize::XSmall.rems().into()), + true, + ))) + .when(is_platform_style_mac, |parent| { + parent.child(accept_keystroke.key.clone()) + }) + .when(!is_platform_style_mac, |parent| { + parent.child( + Key::new( + util::capitalize(&accept_keystroke.key), + Some(Color::Default), + ) + .size(Some(IconSize::XSmall.rems().into())), + ) + }) + .into_any() + .into() + } + + fn render_edit_prediction_line_popover( + &self, + label: impl Into, + icon: Option, + window: &mut Window, + cx: &App, + ) -> Option> { + let padding_right = if icon.is_some() { px(4.) } else { px(8.) }; + + let keybind = self.render_edit_prediction_accept_keybind(window, cx); + let has_keybind = keybind.is_some(); + + let result = h_flex() + .id("ep-line-popover") + .py_0p5() + .pl_1() + .pr(padding_right) + .gap_1() + .rounded_md() + .border_1() + .bg(Self::edit_prediction_line_popover_bg_color(cx)) + .border_color(Self::edit_prediction_callout_popover_border_color(cx)) + .shadow_sm() + .when(!has_keybind, |el| { + let status_colors = cx.theme().status(); + + el.bg(status_colors.error_background) + .border_color(status_colors.error.opacity(0.6)) + .pl_2() + .child(Icon::new(IconName::ZedPredictError).color(Color::Error)) + .cursor_default() + .hoverable_tooltip(move |_window, cx| { + cx.new(|_| MissingEditPredictionKeybindingTooltip).into() + }) + }) + .children(keybind) + .child( + Label::new(label) + .size(LabelSize::Small) + .when(!has_keybind, |el| { + el.color(cx.theme().status().error.into()).strikethrough() + }), + ) + .when(!has_keybind, |el| { + el.child( + h_flex().ml_1().child( + Icon::new(IconName::Info) + .size(IconSize::Small) + .color(cx.theme().status().error.into()), + ), + ) + }) + .when_some(icon, |element, icon| { + element.child( + div() + .mt(px(1.5)) + .child(Icon::new(icon).size(IconSize::Small)), + ) + }); + + Some(result) + } + + fn edit_prediction_line_popover_bg_color(cx: &App) -> Hsla { + let accent_color = cx.theme().colors().text_accent; + let editor_bg_color = cx.theme().colors().editor_background; + editor_bg_color.blend(accent_color.opacity(0.1)) + } + + fn edit_prediction_callout_popover_border_color(cx: &App) -> Hsla { + let accent_color = cx.theme().colors().text_accent; + let editor_bg_color = cx.theme().colors().editor_background; + editor_bg_color.blend(accent_color.opacity(0.6)) + } + + fn render_edit_prediction_cursor_popover( + &self, + min_width: Pixels, + max_width: Pixels, + cursor_point: Point, + style: &EditorStyle, + accept_keystroke: Option<&gpui::Keystroke>, + _window: &Window, + cx: &mut Context, + ) -> Option { + let provider = self.edit_prediction_provider.as_ref()?; + + if provider.provider.needs_terms_acceptance(cx) { + return Some( + h_flex() + .min_w(min_width) + .flex_1() + .px_2() + .py_1() + .gap_3() + .elevation_2(cx) + .hover(|style| style.bg(cx.theme().colors().element_hover)) + .id("accept-terms") + .cursor_pointer() + .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) + .on_click(cx.listener(|this, _event, window, cx| { + cx.stop_propagation(); + this.report_editor_event("Edit Prediction Provider ToS Clicked", None, cx); + window.dispatch_action( + zed_actions::OpenZedPredictOnboarding.boxed_clone(), + cx, + ); + })) + .child( + h_flex() + .flex_1() + .gap_2() + .child(Icon::new(IconName::ZedPredict)) + .child(Label::new("Accept Terms of Service")) + .child(div().w_full()) + .child( + Icon::new(IconName::ArrowUpRight) + .color(Color::Muted) + .size(IconSize::Small), + ) + .into_any_element(), + ) + .into_any(), + ); + } + + let is_refreshing = provider.provider.is_refreshing(cx); + + fn pending_completion_container() -> Div { + h_flex() + .h_full() + .flex_1() + .gap_2() + .child(Icon::new(IconName::ZedPredict)) + } + + let completion = match &self.active_inline_completion { + Some(prediction) => { + if !self.has_visible_completions_menu() { + const RADIUS: Pixels = px(6.); + const BORDER_WIDTH: Pixels = px(1.); + + return Some( + h_flex() + .elevation_2(cx) + .border(BORDER_WIDTH) + .border_color(cx.theme().colors().border) + .when(accept_keystroke.is_none(), |el| { + el.border_color(cx.theme().status().error) + }) + .rounded(RADIUS) + .rounded_tl(px(0.)) + .overflow_hidden() + .child(div().px_1p5().child(match &prediction.completion { + InlineCompletion::Move { target, snapshot } => { + use text::ToPoint as _; + if target.text_anchor.to_point(&snapshot).row > cursor_point.row + { + Icon::new(IconName::ZedPredictDown) + } else { + Icon::new(IconName::ZedPredictUp) + } + } + InlineCompletion::Edit { .. } => Icon::new(IconName::ZedPredict), + })) + .child( + h_flex() + .gap_1() + .py_1() + .px_2() + .rounded_r(RADIUS - BORDER_WIDTH) + .border_l_1() + .border_color(cx.theme().colors().border) + .bg(Self::edit_prediction_line_popover_bg_color(cx)) + .when(self.edit_prediction_preview.released_too_fast(), |el| { + el.child( + Label::new("Hold") + .size(LabelSize::Small) + .when(accept_keystroke.is_none(), |el| { + el.strikethrough() + }) + .line_height_style(LineHeightStyle::UiLabel), + ) + }) + .id("edit_prediction_cursor_popover_keybind") + .when(accept_keystroke.is_none(), |el| { + let status_colors = cx.theme().status(); + + el.bg(status_colors.error_background) + .border_color(status_colors.error.opacity(0.6)) + .child(Icon::new(IconName::Info).color(Color::Error)) + .cursor_default() + .hoverable_tooltip(move |_window, cx| { + cx.new(|_| MissingEditPredictionKeybindingTooltip) + .into() + }) + }) + .when_some( + accept_keystroke.as_ref(), + |el, accept_keystroke| { + el.child(h_flex().children(ui::render_modifiers( + &accept_keystroke.modifiers, + PlatformStyle::platform(), + Some(Color::Default), + Some(IconSize::XSmall.rems().into()), + false, + ))) + }, + ), + ) + .into_any(), + ); + } + + self.render_edit_prediction_cursor_popover_preview( + prediction, + cursor_point, + style, + cx, + )? + } + + None if is_refreshing => match &self.stale_inline_completion_in_menu { + Some(stale_completion) => self.render_edit_prediction_cursor_popover_preview( + stale_completion, + cursor_point, + style, + cx, + )?, + + None => { + pending_completion_container().child(Label::new("...").size(LabelSize::Small)) + } + }, + + None => pending_completion_container().child(Label::new("No Prediction")), + }; + + let completion = if is_refreshing { + completion + .with_animation( + "loading-completion", + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(pulsating_between(0.4, 0.8)), + |label, delta| label.opacity(delta), + ) + .into_any_element() + } else { + completion.into_any_element() + }; + + let has_completion = self.active_inline_completion.is_some(); + + let is_platform_style_mac = PlatformStyle::platform() == PlatformStyle::Mac; + Some( + h_flex() + .min_w(min_width) + .max_w(max_width) + .flex_1() + .elevation_2(cx) + .border_color(cx.theme().colors().border) + .child( + div() + .flex_1() + .py_1() + .px_2() + .overflow_hidden() + .child(completion), + ) + .when_some(accept_keystroke, |el, accept_keystroke| { + if !accept_keystroke.modifiers.modified() { + return el; + } + + el.child( + h_flex() + .h_full() + .border_l_1() + .rounded_r_lg() + .border_color(cx.theme().colors().border) + .bg(Self::edit_prediction_line_popover_bg_color(cx)) + .gap_1() + .py_1() + .px_2() + .child( + h_flex() + .font(theme_settings::ThemeSettings::get_global(cx).buffer_font.clone()) + .when(is_platform_style_mac, |parent| parent.gap_1()) + .child(h_flex().children(ui::render_modifiers( + &accept_keystroke.modifiers, + PlatformStyle::platform(), + Some(if !has_completion { + Color::Muted + } else { + Color::Default + }), + None, + false, + ))), + ) + .child(Label::new("Preview").into_any_element()) + .opacity(if has_completion { 1.0 } else { 0.4 }), + ) + }) + .into_any(), + ) + } + + fn render_edit_prediction_cursor_popover_preview( + &self, + completion: &InlineCompletionState, + cursor_point: Point, + style: &EditorStyle, + cx: &mut Context, + ) -> Option
{ + use text::ToPoint as _; + + fn render_relative_row_jump( + prefix: impl Into, + current_row: u32, + target_row: u32, + ) -> Div { + let (row_diff, arrow) = if target_row < current_row { + (current_row - target_row, IconName::ArrowUp) + } else { + (target_row - current_row, IconName::ArrowDown) + }; + + h_flex() + .child( + Label::new(format!("{}{}", prefix.into(), row_diff)) + .color(Color::Muted) + .size(LabelSize::Small), + ) + .child(Icon::new(arrow).color(Color::Muted).size(IconSize::Small)) + } + + match &completion.completion { + InlineCompletion::Move { + target, snapshot, .. + } => Some( + h_flex() + .px_2() + .gap_2() + .flex_1() + .child( + if target.text_anchor.to_point(&snapshot).row > cursor_point.row { + Icon::new(IconName::ZedPredictDown) + } else { + Icon::new(IconName::ZedPredictUp) + }, + ) + .child(Label::new("Jump to Edit")), + ), + + InlineCompletion::Edit { + edits, + edit_preview, + snapshot, + display_mode: _, + } => { + let first_edit_row = edits.first()?.0.start.text_anchor.to_point(&snapshot).row; + + let (highlighted_edits, has_more_lines) = crate::inline_completion_edit_text( + &snapshot, + &edits, + edit_preview.as_ref()?, + true, + cx, + ) + .first_line_preview(); + + let styled_text = gpui::StyledText::new(highlighted_edits.text) + .with_default_highlights(&style.text, highlighted_edits.highlights); + + let preview = h_flex() + .gap_1() + .min_w_16() + .child(styled_text) + .when(has_more_lines, |parent| parent.child("…")); + + let left = if first_edit_row != cursor_point.row { + render_relative_row_jump("", cursor_point.row, first_edit_row) + .into_any_element() + } else { + Icon::new(IconName::ZedPredict).into_any_element() + }; + + Some( + h_flex() + .h_full() + .flex_1() + .gap_2() + .pr_1() + .overflow_x_hidden() + .font(theme_settings::ThemeSettings::get_global(cx).buffer_font.clone()) + .child(left) + .child(preview), + ) + } + } + } + + fn render_context_menu( + &self, + style: &EditorStyle, + max_height_in_lines: u32, + window: &mut Window, + cx: &mut Context, + ) -> Option { + let menu = self.context_menu.borrow(); + let menu = menu.as_ref()?; + if !menu.visible() { + return None; + }; + Some(menu.render(style, max_height_in_lines, window, cx)) + } + + fn render_context_menu_aside( + &mut self, + max_size: Size, + window: &mut Window, + cx: &mut Context, + ) -> Option { + self.context_menu.borrow_mut().as_mut().and_then(|menu| { + if menu.visible() { + menu.render_aside(self, max_size, window, cx) + } else { + None + } + }) + } + + fn hide_context_menu( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Option { + cx.notify(); + self.completion_tasks.clear(); + let context_menu = self.context_menu.borrow_mut().take(); + self.stale_inline_completion_in_menu.take(); + self.update_visible_inline_completion(window, cx); + context_menu + } + + fn show_snippet_choices( + &mut self, + choices: &Vec, + selection: Range, + cx: &mut Context, + ) { + if selection.start.buffer_id.is_none() { + return; + } + let buffer_id = selection.start.buffer_id.unwrap(); + let buffer = self.buffer().read(cx).buffer(buffer_id); + let id = post_inc(&mut self.next_completion_id); + let snippet_sort_order = EditorSettings::get_global(cx).snippet_sort_order; + + if let Some(buffer) = buffer { + *self.context_menu.borrow_mut() = Some(CodeContextMenu::Completions( + CompletionsMenu::new_snippet_choices( + id, + true, + choices, + selection, + buffer, + snippet_sort_order, + ), + )); + } + } + + pub fn insert_snippet( + &mut self, + insertion_ranges: &[Range], + snippet: Snippet, + window: &mut Window, + cx: &mut Context, + ) -> Result<()> { + struct Tabstop { + is_end_tabstop: bool, + ranges: Vec>, + choices: Option>, + } + + let tabstops = self.buffer.update(cx, |buffer, cx| { + let snippet_text: Arc = snippet.text.clone().into(); + let edits = insertion_ranges + .iter() + .cloned() + .map(|range| (range, snippet_text.clone())); + buffer.edit(edits, Some(AutoindentMode::EachLine), cx); + + let snapshot = &*buffer.read(cx); + let snippet = &snippet; + snippet + .tabstops + .iter() + .map(|tabstop| { + let is_end_tabstop = tabstop.ranges.first().map_or(false, |tabstop| { + tabstop.is_empty() && tabstop.start == snippet.text.len() as isize + }); + let mut tabstop_ranges = tabstop + .ranges + .iter() + .flat_map(|tabstop_range| { + let mut delta = 0_isize; + insertion_ranges.iter().map(move |insertion_range| { + let insertion_start = insertion_range.start as isize + delta; + delta += + snippet.text.len() as isize - insertion_range.len() as isize; + + let start = ((insertion_start + tabstop_range.start) as usize) + .min(snapshot.len()); + let end = ((insertion_start + tabstop_range.end) as usize) + .min(snapshot.len()); + snapshot.anchor_before(start)..snapshot.anchor_after(end) + }) + }) + .collect::>(); + tabstop_ranges.sort_unstable_by(|a, b| a.start.cmp(&b.start, snapshot)); + + Tabstop { + is_end_tabstop, + ranges: tabstop_ranges, + choices: tabstop.choices.clone(), + } + }) + .collect::>() + }); + if let Some(tabstop) = tabstops.first() { + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.select_ranges(tabstop.ranges.iter().cloned()); + }); + + if let Some(choices) = &tabstop.choices { + if let Some(selection) = tabstop.ranges.first() { + self.show_snippet_choices(choices, selection.clone(), cx) + } + } + + // If we're already at the last tabstop and it's at the end of the snippet, + // we're done, we don't need to keep the state around. + if !tabstop.is_end_tabstop { + let choices = tabstops + .iter() + .map(|tabstop| tabstop.choices.clone()) + .collect(); + + let ranges = tabstops + .into_iter() + .map(|tabstop| tabstop.ranges) + .collect::>(); + + self.snippet_stack.push(SnippetState { + active_index: 0, + ranges, + choices, + }); + } + + // Check whether the just-entered snippet ends with an auto-closable bracket. + if self.autoclose_regions.is_empty() { + let snapshot = self.buffer.read(cx).snapshot(cx); + for selection in &mut self.selections.all::(cx) { + let selection_head = selection.head(); + let Some(scope) = snapshot.language_scope_at(selection_head) else { + continue; + }; + + let mut bracket_pair = None; + let next_chars = snapshot.chars_at(selection_head).collect::(); + let prev_chars = snapshot + .reversed_chars_at(selection_head) + .collect::(); + for (pair, enabled) in scope.brackets() { + if enabled + && pair.close + && prev_chars.starts_with(pair.start.as_str()) + && next_chars.starts_with(pair.end.as_str()) + { + bracket_pair = Some(pair.clone()); + break; + } + } + if let Some(pair) = bracket_pair { + let snapshot_settings = snapshot.language_settings_at(selection_head, cx); + let autoclose_enabled = + self.use_autoclose && snapshot_settings.use_autoclose; + if autoclose_enabled { + let start = snapshot.anchor_after(selection_head); + let end = snapshot.anchor_after(selection_head); + self.autoclose_regions.push(AutocloseRegion { + selection_id: selection.id, + range: start..end, + pair, + }); + } + } + } + } + } + Ok(()) + } + + pub fn move_to_next_snippet_tabstop( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> bool { + self.move_to_snippet_tabstop(Bias::Right, window, cx) + } + + pub fn move_to_prev_snippet_tabstop( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> bool { + self.move_to_snippet_tabstop(Bias::Left, window, cx) + } + + pub fn move_to_snippet_tabstop( + &mut self, + bias: Bias, + window: &mut Window, + cx: &mut Context, + ) -> bool { + if let Some(mut snippet) = self.snippet_stack.pop() { + match bias { + Bias::Left => { + if snippet.active_index > 0 { + snippet.active_index -= 1; + } else { + self.snippet_stack.push(snippet); + return false; + } + } + Bias::Right => { + if snippet.active_index + 1 < snippet.ranges.len() { + snippet.active_index += 1; + } else { + self.snippet_stack.push(snippet); + return false; + } + } + } + if let Some(current_ranges) = snippet.ranges.get(snippet.active_index) { + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.select_anchor_ranges(current_ranges.iter().cloned()) + }); + + if let Some(choices) = &snippet.choices[snippet.active_index] { + if let Some(selection) = current_ranges.first() { + self.show_snippet_choices(&choices, selection.clone(), cx); + } + } + + // If snippet state is not at the last tabstop, push it back on the stack + if snippet.active_index + 1 < snippet.ranges.len() { + self.snippet_stack.push(snippet); + } + return true; + } + } + + false + } + + pub fn clear(&mut self, window: &mut Window, cx: &mut Context) { + self.transact(window, cx, |this, window, cx| { + this.select_all(&SelectAll, window, cx); + this.insert("", window, cx); + }); + } + + pub fn backspace(&mut self, _: &Backspace, window: &mut Window, cx: &mut Context) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); + self.transact(window, cx, |this, window, cx| { + this.select_autoclose_pair(window, cx); + let mut linked_ranges = HashMap::<_, Vec<_>>::default(); + if !this.linked_edit_ranges.is_empty() { + let selections = this.selections.all::(cx); + let snapshot = this.buffer.read(cx).snapshot(cx); + + for selection in selections.iter() { + let selection_start = snapshot.anchor_before(selection.start).text_anchor; + let selection_end = snapshot.anchor_after(selection.end).text_anchor; + if selection_start.buffer_id != selection_end.buffer_id { + continue; + } + if let Some(ranges) = + this.linked_editing_ranges_for(selection_start..selection_end, cx) + { + for (buffer, entries) in ranges { + linked_ranges.entry(buffer).or_default().extend(entries); + } + } + } + } + + let mut selections = this.selections.all::(cx); + let display_map = this.display_map.update(cx, |map, cx| map.snapshot(cx)); + for selection in &mut selections { + if selection.is_empty() { + let old_head = selection.head(); + let mut new_head = + movement::left(&display_map, old_head.to_display_point(&display_map)) + .to_point(&display_map); + if let Some((buffer, line_buffer_range)) = display_map + .buffer_snapshot + .buffer_line_for_row(MultiBufferRow(old_head.row)) + { + let indent_size = buffer.indent_size_for_line(line_buffer_range.start.row); + let indent_len = match indent_size.kind { + IndentKind::Space => { + buffer.settings_at(line_buffer_range.start, cx).tab_size + } + IndentKind::Tab => NonZeroU32::new(1).unwrap(), + }; + if old_head.column <= indent_size.len && old_head.column > 0 { + let indent_len = indent_len.get(); + new_head = cmp::min( + new_head, + MultiBufferPoint::new( + old_head.row, + ((old_head.column - 1) / indent_len) * indent_len, + ), + ); + } + } + + selection.set_head(new_head, SelectionGoal::None); + } + } + + this.signature_help_state.set_backspace_pressed(true); + this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.select(selections) + }); + this.insert("", window, cx); + let empty_str: Arc = Arc::from(""); + for (buffer, edits) in linked_ranges { + let snapshot = buffer.read(cx).snapshot(); + use text::ToPoint as TP; + + let edits = edits + .into_iter() + .map(|range| { + let end_point = TP::to_point(&range.end, &snapshot); + let mut start_point = TP::to_point(&range.start, &snapshot); + + if end_point == start_point { + let offset = text::ToOffset::to_offset(&range.start, &snapshot) + .saturating_sub(1); + start_point = + snapshot.clip_point(TP::to_point(&offset, &snapshot), Bias::Left); + }; + + (start_point..end_point, empty_str.clone()) + }) + .sorted_by_key(|(range, _)| range.start) + .collect::>(); + buffer.update(cx, |this, cx| { + this.edit(edits, None, cx); + }) + } + this.refresh_inline_completion(true, false, window, cx); + linked_editing_ranges::refresh_linked_ranges(this, window, cx); + }); + } + + pub fn delete(&mut self, _: &Delete, window: &mut Window, cx: &mut Context) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); + self.transact(window, cx, |this, window, cx| { + this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.move_with(|map, selection| { + if selection.is_empty() { + let cursor = movement::right(map, selection.head()); + selection.end = cursor; + selection.reversed = true; + selection.goal = SelectionGoal::None; + } + }) + }); + this.insert("", window, cx); + this.refresh_inline_completion(true, false, window, cx); + }); + } + + pub fn backtab(&mut self, _: &Backtab, window: &mut Window, cx: &mut Context) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); + if self.move_to_prev_snippet_tabstop(window, cx) { + return; + } + self.outdent(&Outdent, window, cx); + } + + pub fn tab(&mut self, _: &Tab, window: &mut Window, cx: &mut Context) { + if self.move_to_next_snippet_tabstop(window, cx) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); + return; + } + if self.read_only(cx) { + return; + } + self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); + let mut selections = self.selections.all_adjusted(cx); + let buffer = self.buffer.read(cx); + let snapshot = buffer.snapshot(cx); + let rows_iter = selections.iter().map(|s| s.head().row); + let suggested_indents = snapshot.suggested_indents(rows_iter, cx); + + let has_some_cursor_in_whitespace = selections + .iter() + .filter(|selection| selection.is_empty()) + .any(|selection| { + let cursor = selection.head(); + let current_indent = snapshot.indent_size_for_line(MultiBufferRow(cursor.row)); + cursor.column < current_indent.len + }); + + let mut edits = Vec::new(); + let mut prev_edited_row = 0; + let mut row_delta = 0; + for selection in &mut selections { + if selection.start.row != prev_edited_row { + row_delta = 0; + } + prev_edited_row = selection.end.row; + + // If the selection is non-empty, then increase the indentation of the selected lines. + if !selection.is_empty() { + row_delta = + Self::indent_selection(buffer, &snapshot, selection, &mut edits, row_delta, cx); + continue; + } + + // If the selection is empty and the cursor is in the leading whitespace before the + // suggested indentation, then auto-indent the line. + let cursor = selection.head(); + let current_indent = snapshot.indent_size_for_line(MultiBufferRow(cursor.row)); + if let Some(suggested_indent) = + suggested_indents.get(&MultiBufferRow(cursor.row)).copied() + { + // If there exist any empty selection in the leading whitespace, then skip + // indent for selections at the boundary. + if has_some_cursor_in_whitespace + && cursor.column == current_indent.len + && current_indent.len == suggested_indent.len + { + continue; + } + + if cursor.column < suggested_indent.len + && cursor.column <= current_indent.len + && current_indent.len <= suggested_indent.len + { + selection.start = Point::new(cursor.row, suggested_indent.len); + selection.end = selection.start; + if row_delta == 0 { + edits.extend(Buffer::edit_for_indent_size_adjustment( + cursor.row, + current_indent, + suggested_indent, + )); + row_delta = suggested_indent.len - current_indent.len; + } + continue; + } + } + + // Otherwise, insert a hard or soft tab. + let settings = buffer.language_settings_at(cursor, cx); + let tab_size = if settings.hard_tabs { + IndentSize::tab() + } else { + let tab_size = settings.tab_size.get(); + let indent_remainder = snapshot + .text_for_range(Point::new(cursor.row, 0)..cursor) + .flat_map(str::chars) + .fold(row_delta % tab_size, |counter: u32, c| { + if c == '\t' { + 0 + } else { + (counter + 1) % tab_size + } + }); + + let chars_to_next_tab_stop = tab_size - indent_remainder; + IndentSize::spaces(chars_to_next_tab_stop) + }; + selection.start = Point::new(cursor.row, cursor.column + row_delta + tab_size.len); + selection.end = selection.start; + edits.push((cursor..cursor, tab_size.chars().collect::())); + row_delta += tab_size.len; + } + + self.transact(window, cx, |this, window, cx| { + this.buffer.update(cx, |b, cx| b.edit(edits, None, cx)); + this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.select(selections) + }); + this.refresh_inline_completion(true, false, window, cx); + }); + } + + pub fn indent(&mut self, _: &Indent, window: &mut Window, cx: &mut Context) { + if self.read_only(cx) { + return; + } + self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); + let mut selections = self.selections.all::(cx); + let mut prev_edited_row = 0; + let mut row_delta = 0; + let mut edits = Vec::new(); + let buffer = self.buffer.read(cx); + let snapshot = buffer.snapshot(cx); + for selection in &mut selections { + if selection.start.row != prev_edited_row { + row_delta = 0; + } + prev_edited_row = selection.end.row; + + row_delta = + Self::indent_selection(buffer, &snapshot, selection, &mut edits, row_delta, cx); + } + + self.transact(window, cx, |this, window, cx| { + this.buffer.update(cx, |b, cx| b.edit(edits, None, cx)); + this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.select(selections) + }); + }); + } + + fn indent_selection( + buffer: &MultiBuffer, + snapshot: &MultiBufferSnapshot, + selection: &mut Selection, + edits: &mut Vec<(Range, String)>, + delta_for_start_row: u32, + cx: &App, + ) -> u32 { + let settings = buffer.language_settings_at(selection.start, cx); + let tab_size = settings.tab_size.get(); + let indent_kind = if settings.hard_tabs { + IndentKind::Tab + } else { + IndentKind::Space + }; + let mut start_row = selection.start.row; + let mut end_row = selection.end.row + 1; + + // If a selection ends at the beginning of a line, don't indent + // that last line. + if selection.end.column == 0 && selection.end.row > selection.start.row { + end_row -= 1; + } + + // Avoid re-indenting a row that has already been indented by a + // previous selection, but still update this selection's column + // to reflect that indentation. + if delta_for_start_row > 0 { + start_row += 1; + selection.start.column += delta_for_start_row; + if selection.end.row == selection.start.row { + selection.end.column += delta_for_start_row; + } + } + + let mut delta_for_end_row = 0; + let has_multiple_rows = start_row + 1 != end_row; + for row in start_row..end_row { + let current_indent = snapshot.indent_size_for_line(MultiBufferRow(row)); + let indent_delta = match (current_indent.kind, indent_kind) { + (IndentKind::Space, IndentKind::Space) => { + let columns_to_next_tab_stop = tab_size - (current_indent.len % tab_size); + IndentSize::spaces(columns_to_next_tab_stop) + } + (IndentKind::Tab, IndentKind::Space) => IndentSize::spaces(tab_size), + (_, IndentKind::Tab) => IndentSize::tab(), + }; + + let start = if has_multiple_rows || current_indent.len < selection.start.column { + 0 + } else { + selection.start.column + }; + let row_start = Point::new(row, start); + edits.push(( + row_start..row_start, + indent_delta.chars().collect::(), + )); + + // Update this selection's endpoints to reflect the indentation. + if row == selection.start.row { + selection.start.column += indent_delta.len; + } + if row == selection.end.row { + selection.end.column += indent_delta.len; + delta_for_end_row = indent_delta.len; + } + } + + if selection.start.row == selection.end.row { + delta_for_start_row + delta_for_end_row + } else { + delta_for_end_row + } + } + + pub fn outdent(&mut self, _: &Outdent, window: &mut Window, cx: &mut Context) { + if self.read_only(cx) { + return; + } + self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let selections = self.selections.all::(cx); + let mut deletion_ranges = Vec::new(); + let mut last_outdent = None; + { + let buffer = self.buffer.read(cx); + let snapshot = buffer.snapshot(cx); + for selection in &selections { + let settings = buffer.language_settings_at(selection.start, cx); + let tab_size = settings.tab_size.get(); + let mut rows = selection.spanned_rows(false, &display_map); + + // Avoid re-outdenting a row that has already been outdented by a + // previous selection. + if let Some(last_row) = last_outdent { + if last_row == rows.start { + rows.start = rows.start.next_row(); + } + } + let has_multiple_rows = rows.len() > 1; + for row in rows.iter_rows() { + let indent_size = snapshot.indent_size_for_line(row); + if indent_size.len > 0 { + let deletion_len = match indent_size.kind { + IndentKind::Space => { + let columns_to_prev_tab_stop = indent_size.len % tab_size; + if columns_to_prev_tab_stop == 0 { + tab_size + } else { + columns_to_prev_tab_stop + } + } + IndentKind::Tab => 1, + }; + let start = if has_multiple_rows + || deletion_len > selection.start.column + || indent_size.len < selection.start.column + { + 0 + } else { + selection.start.column - deletion_len + }; + deletion_ranges.push( + Point::new(row.0, start)..Point::new(row.0, start + deletion_len), + ); + last_outdent = Some(row); + } + } + } + } + + self.transact(window, cx, |this, window, cx| { + this.buffer.update(cx, |buffer, cx| { + let empty_str: Arc = Arc::default(); + buffer.edit( + deletion_ranges + .into_iter() + .map(|range| (range, empty_str.clone())), + None, + cx, + ); + }); + let selections = this.selections.all::(cx); + this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.select(selections) + }); + }); + } + + pub fn autoindent(&mut self, _: &AutoIndent, window: &mut Window, cx: &mut Context) { + if self.read_only(cx) { + return; + } + self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); + let selections = self + .selections + .all::(cx) + .into_iter() + .map(|s| s.range()); + + self.transact(window, cx, |this, window, cx| { + this.buffer.update(cx, |buffer, cx| { + buffer.autoindent_ranges(selections, cx); + }); + let selections = this.selections.all::(cx); + this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.select(selections) + }); + }); + } + + pub fn delete_line(&mut self, _: &DeleteLine, window: &mut Window, cx: &mut Context) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let selections = self.selections.all::(cx); + + let mut new_cursors = Vec::new(); + let mut edit_ranges = Vec::new(); + let mut selections = selections.iter().peekable(); + while let Some(selection) = selections.next() { + let mut rows = selection.spanned_rows(false, &display_map); + let goal_display_column = selection.head().to_display_point(&display_map).column(); + + // Accumulate contiguous regions of rows that we want to delete. + while let Some(next_selection) = selections.peek() { + let next_rows = next_selection.spanned_rows(false, &display_map); + if next_rows.start <= rows.end { + rows.end = next_rows.end; + selections.next().unwrap(); + } else { + break; + } + } + + let buffer = &display_map.buffer_snapshot; + let mut edit_start = Point::new(rows.start.0, 0).to_offset(buffer); + let edit_end; + let cursor_buffer_row; + if buffer.max_point().row >= rows.end.0 { + // If there's a line after the range, delete the \n from the end of the row range + // and position the cursor on the next line. + edit_end = Point::new(rows.end.0, 0).to_offset(buffer); + cursor_buffer_row = rows.end; + } else { + // If there isn't a line after the range, delete the \n from the line before the + // start of the row range and position the cursor there. + edit_start = edit_start.saturating_sub(1); + edit_end = buffer.len(); + cursor_buffer_row = rows.start.previous_row(); + } + + let mut cursor = Point::new(cursor_buffer_row.0, 0).to_display_point(&display_map); + *cursor.column_mut() = + cmp::min(goal_display_column, display_map.line_len(cursor.row())); + + new_cursors.push(( + selection.id, + buffer.anchor_after(cursor.to_point(&display_map)), + )); + edit_ranges.push(edit_start..edit_end); + } + + self.transact(window, cx, |this, window, cx| { + let buffer = this.buffer.update(cx, |buffer, cx| { + let empty_str: Arc = Arc::default(); + buffer.edit( + edit_ranges + .into_iter() + .map(|range| (range, empty_str.clone())), + None, + cx, + ); + buffer.snapshot(cx) + }); + let new_selections = new_cursors + .into_iter() + .map(|(id, cursor)| { + let cursor = cursor.to_point(&buffer); + Selection { + id, + start: cursor, + end: cursor, + reversed: false, + goal: SelectionGoal::None, + } + }) + .collect(); + + this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.select(new_selections); + }); + }); + } + + pub fn join_lines_impl( + &mut self, + insert_whitespace: bool, + window: &mut Window, + cx: &mut Context, + ) { + if self.read_only(cx) { + return; + } + let mut row_ranges = Vec::>::new(); + for selection in self.selections.all::(cx) { + let start = MultiBufferRow(selection.start.row); + // Treat single line selections as if they include the next line. Otherwise this action + // would do nothing for single line selections individual cursors. + let end = if selection.start.row == selection.end.row { + MultiBufferRow(selection.start.row + 1) + } else { + MultiBufferRow(selection.end.row) + }; + + if let Some(last_row_range) = row_ranges.last_mut() { + if start <= last_row_range.end { + last_row_range.end = end; + continue; + } + } + row_ranges.push(start..end); + } + + let snapshot = self.buffer.read(cx).snapshot(cx); + let mut cursor_positions = Vec::new(); + for row_range in &row_ranges { + let anchor = snapshot.anchor_before(Point::new( + row_range.end.previous_row().0, + snapshot.line_len(row_range.end.previous_row()), + )); + cursor_positions.push(anchor..anchor); + } + + self.transact(window, cx, |this, window, cx| { + for row_range in row_ranges.into_iter().rev() { + for row in row_range.iter_rows().rev() { + let end_of_line = Point::new(row.0, snapshot.line_len(row)); + let next_line_row = row.next_row(); + let indent = snapshot.indent_size_for_line(next_line_row); + let start_of_next_line = Point::new(next_line_row.0, indent.len); + + let replace = + if snapshot.line_len(next_line_row) > indent.len && insert_whitespace { + " " + } else { + "" + }; + + this.buffer.update(cx, |buffer, cx| { + buffer.edit([(end_of_line..start_of_next_line, replace)], None, cx) + }); + } + } + + this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.select_anchor_ranges(cursor_positions) + }); + }); + } + + pub fn join_lines(&mut self, _: &JoinLines, window: &mut Window, cx: &mut Context) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); + self.join_lines_impl(true, window, cx); + } + + pub fn sort_lines_case_sensitive( + &mut self, + _: &SortLinesCaseSensitive, + window: &mut Window, + cx: &mut Context, + ) { + self.manipulate_lines(window, cx, |lines| lines.sort()) + } + + pub fn sort_lines_case_insensitive( + &mut self, + _: &SortLinesCaseInsensitive, + window: &mut Window, + cx: &mut Context, + ) { + self.manipulate_lines(window, cx, |lines| { + lines.sort_by_key(|line| line.to_lowercase()) + }) + } + + pub fn unique_lines_case_insensitive( + &mut self, + _: &UniqueLinesCaseInsensitive, + window: &mut Window, + cx: &mut Context, + ) { + self.manipulate_lines(window, cx, |lines| { + let mut seen = HashSet::default(); + lines.retain(|line| seen.insert(line.to_lowercase())); + }) + } + + pub fn unique_lines_case_sensitive( + &mut self, + _: &UniqueLinesCaseSensitive, + window: &mut Window, + cx: &mut Context, + ) { + self.manipulate_lines(window, cx, |lines| { + let mut seen = HashSet::default(); + lines.retain(|line| seen.insert(*line)); + }) + } + + pub fn reload_file(&mut self, _: &ReloadFile, window: &mut Window, cx: &mut Context) { + let Some(project) = self.project.clone() else { + return; + }; + self.reload(project, window, cx) + .detach_and_notify_err(window, cx); + } + + pub fn restore_file( + &mut self, + _: &::git::RestoreFile, + window: &mut Window, + cx: &mut Context, + ) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); + let mut buffer_ids = HashSet::default(); + let snapshot = self.buffer().read(cx).snapshot(cx); + for selection in self.selections.all::(cx) { + buffer_ids.extend(snapshot.buffer_ids_for_range(selection.range())) + } + + let buffer = self.buffer().read(cx); + let ranges = buffer_ids + .into_iter() + .flat_map(|buffer_id| buffer.excerpt_ranges_for_buffer(buffer_id, cx)) + .collect::>(); + + self.restore_hunks_in_ranges(ranges, window, cx); + } + + pub fn git_restore(&mut self, _: &Restore, window: &mut Window, cx: &mut Context) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); + let selections = self + .selections + .all(cx) + .into_iter() + .map(|s| s.range()) + .collect(); + self.restore_hunks_in_ranges(selections, window, cx); + } + + pub fn restore_hunks_in_ranges( + &mut self, + ranges: Vec>, + window: &mut Window, + cx: &mut Context, + ) { + let mut revert_changes = HashMap::default(); + let chunk_by = self + .snapshot(window, cx) + .hunks_for_ranges(ranges) + .into_iter() + .chunk_by(|hunk| hunk.buffer_id); + for (buffer_id, hunks) in &chunk_by { + let hunks = hunks.collect::>(); + for hunk in &hunks { + self.prepare_restore_change(&mut revert_changes, hunk, cx); + } + self.do_stage_or_unstage(false, buffer_id, hunks.into_iter(), cx); + } + drop(chunk_by); + if !revert_changes.is_empty() { + self.transact(window, cx, |editor, window, cx| { + editor.restore(revert_changes, window, cx); + }); + } + } + + pub fn open_active_item_in_terminal( + &mut self, + _: &OpenInTerminal, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(working_directory) = self.active_excerpt(cx).and_then(|(_, buffer, _)| { + let project_path = buffer.read(cx).project_path(cx)?; + let project = self.project.as_ref()?.read(cx); + let entry = project.entry_for_path(&project_path, cx)?; + let parent = match &entry.canonical_path { + Some(canonical_path) => canonical_path.to_path_buf(), + None => project.absolute_path(&project_path, cx)?, + } + .parent()? + .to_path_buf(); + Some(parent) + }) { + window.dispatch_action(OpenTerminal { working_directory }.boxed_clone(), cx); + } + } + + fn set_breakpoint_context_menu( + &mut self, + display_row: DisplayRow, + position: Option, + clicked_point: gpui::Point, + window: &mut Window, + cx: &mut Context, + ) { + if !cx.has_flag::() { + return; + } + let source = self + .buffer + .read(cx) + .snapshot(cx) + .anchor_before(Point::new(display_row.0, 0u32)); + + let context_menu = self.breakpoint_context_menu(position.unwrap_or(source), window, cx); + + self.mouse_context_menu = MouseContextMenu::pinned_to_editor( + self, + source, + clicked_point, + context_menu, + window, + cx, + ); + } + + fn add_edit_breakpoint_block( + &mut self, + anchor: Anchor, + breakpoint: &Breakpoint, + edit_action: BreakpointPromptEditAction, + window: &mut Window, + cx: &mut Context, + ) { + let weak_editor = cx.weak_entity(); + let bp_prompt = cx.new(|cx| { + BreakpointPromptEditor::new( + weak_editor, + anchor, + breakpoint.clone(), + edit_action, + window, + cx, + ) + }); + + let height = bp_prompt.update(cx, |this, cx| { + this.prompt + .update(cx, |prompt, cx| prompt.max_point(cx).row().0 + 1 + 2) + }); + let cloned_prompt = bp_prompt.clone(); + let blocks = vec![BlockProperties { + style: BlockStyle::Sticky, + placement: BlockPlacement::Above(anchor), + height: Some(height), + render: Arc::new(move |cx| { + *cloned_prompt.read(cx).gutter_dimensions.lock() = *cx.gutter_dimensions; + cloned_prompt.clone().into_any_element() + }), + priority: 0, + }]; + + let focus_handle = bp_prompt.focus_handle(cx); + window.focus(&focus_handle); + + let block_ids = self.insert_blocks(blocks, None, cx); + bp_prompt.update(cx, |prompt, _| { + prompt.add_block_ids(block_ids); + }); + } + + pub(crate) fn breakpoint_at_row( + &self, + row: u32, + window: &mut Window, + cx: &mut Context, + ) -> Option<(Anchor, Breakpoint)> { + let snapshot = self.snapshot(window, cx); + let breakpoint_position = snapshot.buffer_snapshot.anchor_before(Point::new(row, 0)); + + self.breakpoint_at_anchor(breakpoint_position, &snapshot, cx) + } + + pub(crate) fn breakpoint_at_anchor( + &self, + breakpoint_position: Anchor, + snapshot: &EditorSnapshot, + cx: &mut Context, + ) -> Option<(Anchor, Breakpoint)> { + let project = self.project.clone()?; + + let buffer_id = breakpoint_position.buffer_id.or_else(|| { + snapshot + .buffer_snapshot + .buffer_id_for_excerpt(breakpoint_position.excerpt_id) + })?; + + let enclosing_excerpt = breakpoint_position.excerpt_id; + let buffer = project.read_with(cx, |project, cx| project.buffer_for_id(buffer_id, cx))?; + let buffer_snapshot = buffer.read(cx).snapshot(); + + let row = buffer_snapshot + .summary_for_anchor::(&breakpoint_position.text_anchor) + .row; + + let line_len = snapshot.buffer_snapshot.line_len(MultiBufferRow(row)); + let anchor_end = snapshot + .buffer_snapshot + .anchor_after(Point::new(row, line_len)); + + let bp = self + .breakpoint_store + .as_ref()? + .read_with(cx, |breakpoint_store, cx| { + breakpoint_store + .breakpoints( + &buffer, + Some(breakpoint_position.text_anchor..anchor_end.text_anchor), + &buffer_snapshot, + cx, + ) + .next() + .and_then(|(anchor, bp)| { + let breakpoint_row = buffer_snapshot + .summary_for_anchor::(anchor) + .row; + + if breakpoint_row == row { + snapshot + .buffer_snapshot + .anchor_in_excerpt(enclosing_excerpt, *anchor) + .map(|anchor| (anchor, bp.clone())) + } else { + None + } + }) + }); + bp + } + + pub fn edit_log_breakpoint( + &mut self, + _: &EditLogBreakpoint, + window: &mut Window, + cx: &mut Context, + ) { + for (anchor, breakpoint) in self.breakpoints_at_cursors(window, cx) { + let breakpoint = breakpoint.unwrap_or_else(|| Breakpoint { + message: None, + state: BreakpointState::Enabled, + condition: None, + hit_condition: None, + }); + + self.add_edit_breakpoint_block( + anchor, + &breakpoint, + BreakpointPromptEditAction::Log, + window, + cx, + ); + } + } + + fn breakpoints_at_cursors( + &self, + window: &mut Window, + cx: &mut Context, + ) -> Vec<(Anchor, Option)> { + let snapshot = self.snapshot(window, cx); + let cursors = self + .selections + .disjoint_anchors() + .into_iter() + .map(|selection| { + let cursor_position: Point = selection.head().to_point(&snapshot.buffer_snapshot); + + let breakpoint_position = self + .breakpoint_at_row(cursor_position.row, window, cx) + .map(|bp| bp.0) + .unwrap_or_else(|| { + snapshot + .display_snapshot + .buffer_snapshot + .anchor_after(Point::new(cursor_position.row, 0)) + }); + + let breakpoint = self + .breakpoint_at_anchor(breakpoint_position, &snapshot, cx) + .map(|(anchor, breakpoint)| (anchor, Some(breakpoint))); + + breakpoint.unwrap_or_else(|| (breakpoint_position, None)) + }) + // There might be multiple cursors on the same line; all of them should have the same anchors though as their breakpoints positions, which makes it possible to sort and dedup the list. + .collect::>(); + + cursors.into_iter().collect() + } + + pub fn enable_breakpoint( + &mut self, + _: &crate::actions::EnableBreakpoint, + window: &mut Window, + cx: &mut Context, + ) { + for (anchor, breakpoint) in self.breakpoints_at_cursors(window, cx) { + let Some(breakpoint) = breakpoint.filter(|breakpoint| breakpoint.is_disabled()) else { + continue; + }; + self.edit_breakpoint_at_anchor( + anchor, + breakpoint, + BreakpointEditAction::InvertState, + cx, + ); + } + } + + pub fn disable_breakpoint( + &mut self, + _: &crate::actions::DisableBreakpoint, + window: &mut Window, + cx: &mut Context, + ) { + for (anchor, breakpoint) in self.breakpoints_at_cursors(window, cx) { + let Some(breakpoint) = breakpoint.filter(|breakpoint| breakpoint.is_enabled()) else { + continue; + }; + self.edit_breakpoint_at_anchor( + anchor, + breakpoint, + BreakpointEditAction::InvertState, + cx, + ); + } + } + + pub fn toggle_breakpoint( + &mut self, + _: &crate::actions::ToggleBreakpoint, + window: &mut Window, + cx: &mut Context, + ) { + for (anchor, breakpoint) in self.breakpoints_at_cursors(window, cx) { + if let Some(breakpoint) = breakpoint { + self.edit_breakpoint_at_anchor( + anchor, + breakpoint, + BreakpointEditAction::Toggle, + cx, + ); + } else { + self.edit_breakpoint_at_anchor( + anchor, + Breakpoint::new_standard(), + BreakpointEditAction::Toggle, + cx, + ); + } + } + } + + pub fn edit_breakpoint_at_anchor( + &mut self, + breakpoint_position: Anchor, + breakpoint: Breakpoint, + edit_action: BreakpointEditAction, + cx: &mut Context, + ) { + let Some(breakpoint_store) = &self.breakpoint_store else { + return; + }; + + let Some(buffer_id) = breakpoint_position.buffer_id.or_else(|| { + if breakpoint_position == Anchor::min() { + self.buffer() + .read(cx) + .excerpt_buffer_ids() + .into_iter() + .next() + } else { + None + } + }) else { + return; + }; + + let Some(buffer) = self.buffer().read(cx).buffer(buffer_id) else { + return; + }; + + breakpoint_store.update(cx, |breakpoint_store, cx| { + breakpoint_store.toggle_breakpoint( + buffer, + (breakpoint_position.text_anchor, breakpoint), + edit_action, + cx, + ); + }); + + cx.notify(); + } + + #[cfg(any(test, feature = "test-support"))] + pub fn breakpoint_store(&self) -> Option> { + self.breakpoint_store.clone() + } + + pub fn prepare_restore_change( + &self, + revert_changes: &mut HashMap, Rope)>>, + hunk: &MultiBufferDiffHunk, + cx: &mut App, + ) -> Option<()> { + if hunk.is_created_file() { + return None; + } + let buffer = self.buffer.read(cx); + let diff = buffer.diff_for(hunk.buffer_id)?; + let buffer = buffer.buffer(hunk.buffer_id)?; + let buffer = buffer.read(cx); + let original_text = diff + .read(cx) + .base_text() + .as_rope() + .slice(hunk.diff_base_byte_range.clone()); + let buffer_snapshot = buffer.snapshot(); + let buffer_revert_changes = revert_changes.entry(buffer.remote_id()).or_default(); + if let Err(i) = buffer_revert_changes.binary_search_by(|probe| { + probe + .0 + .start + .cmp(&hunk.buffer_range.start, &buffer_snapshot) + .then(probe.0.end.cmp(&hunk.buffer_range.end, &buffer_snapshot)) + }) { + buffer_revert_changes.insert(i, (hunk.buffer_range.clone(), original_text)); + Some(()) + } else { + None + } + } + + pub fn reverse_lines(&mut self, _: &ReverseLines, window: &mut Window, cx: &mut Context) { + self.manipulate_lines(window, cx, |lines| lines.reverse()) + } + + pub fn shuffle_lines(&mut self, _: &ShuffleLines, window: &mut Window, cx: &mut Context) { + self.manipulate_lines(window, cx, |lines| lines.shuffle(&mut thread_rng())) + } + + fn manipulate_lines( + &mut self, + window: &mut Window, + cx: &mut Context, + mut callback: Fn, + ) where + Fn: FnMut(&mut Vec<&str>), + { + self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); + + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let buffer = self.buffer.read(cx).snapshot(cx); + + let mut edits = Vec::new(); + + let selections = self.selections.all::(cx); + let mut selections = selections.iter().peekable(); + let mut contiguous_row_selections = Vec::new(); + let mut new_selections = Vec::new(); + let mut added_lines = 0; + let mut removed_lines = 0; + + while let Some(selection) = selections.next() { + let (start_row, end_row) = consume_contiguous_rows( + &mut contiguous_row_selections, + selection, + &display_map, + &mut selections, + ); + + let start_point = Point::new(start_row.0, 0); + let end_point = Point::new( + end_row.previous_row().0, + buffer.line_len(end_row.previous_row()), + ); + let text = buffer + .text_for_range(start_point..end_point) + .collect::(); + + let mut lines = text.split('\n').collect_vec(); + + let lines_before = lines.len(); + callback(&mut lines); + let lines_after = lines.len(); + + edits.push((start_point..end_point, lines.join("\n"))); + + // Selections must change based on added and removed line count + let start_row = + MultiBufferRow(start_point.row + added_lines as u32 - removed_lines as u32); + let end_row = MultiBufferRow(start_row.0 + lines_after.saturating_sub(1) as u32); + new_selections.push(Selection { + id: selection.id, + start: start_row, + end: end_row, + goal: SelectionGoal::None, + reversed: selection.reversed, + }); + + if lines_after > lines_before { + added_lines += lines_after - lines_before; + } else if lines_before > lines_after { + removed_lines += lines_before - lines_after; + } + } + + self.transact(window, cx, |this, window, cx| { + let buffer = this.buffer.update(cx, |buffer, cx| { + buffer.edit(edits, None, cx); + buffer.snapshot(cx) + }); + + // Recalculate offsets on newly edited buffer + let new_selections = new_selections + .iter() + .map(|s| { + let start_point = Point::new(s.start.0, 0); + let end_point = Point::new(s.end.0, buffer.line_len(s.end)); + Selection { + id: s.id, + start: buffer.point_to_offset(start_point), + end: buffer.point_to_offset(end_point), + goal: s.goal, + reversed: s.reversed, + } + }) + .collect(); + + this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.select(new_selections); + }); + + this.request_autoscroll(Autoscroll::fit(), cx); + }); + } + + pub fn toggle_case(&mut self, _: &ToggleCase, window: &mut Window, cx: &mut Context) { + self.manipulate_text(window, cx, |text| { + let has_upper_case_characters = text.chars().any(|c| c.is_uppercase()); + if has_upper_case_characters { + text.to_lowercase() + } else { + text.to_uppercase() + } + }) + } + + pub fn convert_to_upper_case( + &mut self, + _: &ConvertToUpperCase, + window: &mut Window, + cx: &mut Context, + ) { + self.manipulate_text(window, cx, |text| text.to_uppercase()) + } + + pub fn convert_to_lower_case( + &mut self, + _: &ConvertToLowerCase, + window: &mut Window, + cx: &mut Context, + ) { + self.manipulate_text(window, cx, |text| text.to_lowercase()) + } + + pub fn convert_to_title_case( + &mut self, + _: &ConvertToTitleCase, + window: &mut Window, + cx: &mut Context, + ) { + self.manipulate_text(window, cx, |text| { + text.split('\n') + .map(|line| line.to_case(Case::Title)) + .join("\n") + }) + } + + pub fn convert_to_snake_case( + &mut self, + _: &ConvertToSnakeCase, + window: &mut Window, + cx: &mut Context, + ) { + self.manipulate_text(window, cx, |text| text.to_case(Case::Snake)) + } + + pub fn convert_to_kebab_case( + &mut self, + _: &ConvertToKebabCase, + window: &mut Window, + cx: &mut Context, + ) { + self.manipulate_text(window, cx, |text| text.to_case(Case::Kebab)) + } + + pub fn convert_to_upper_camel_case( + &mut self, + _: &ConvertToUpperCamelCase, + window: &mut Window, + cx: &mut Context, + ) { + self.manipulate_text(window, cx, |text| { + text.split('\n') + .map(|line| line.to_case(Case::UpperCamel)) + .join("\n") + }) + } + + pub fn convert_to_lower_camel_case( + &mut self, + _: &ConvertToLowerCamelCase, + window: &mut Window, + cx: &mut Context, + ) { + self.manipulate_text(window, cx, |text| text.to_case(Case::Camel)) + } + + pub fn convert_to_opposite_case( + &mut self, + _: &ConvertToOppositeCase, + window: &mut Window, + cx: &mut Context, + ) { + self.manipulate_text(window, cx, |text| { + text.chars() + .fold(String::with_capacity(text.len()), |mut t, c| { + if c.is_uppercase() { + t.extend(c.to_lowercase()); + } else { + t.extend(c.to_uppercase()); + } + t + }) + }) + } + + pub fn convert_to_rot13( + &mut self, + _: &ConvertToRot13, + window: &mut Window, + cx: &mut Context, + ) { + self.manipulate_text(window, cx, |text| { + text.chars() + .map(|c| match c { + 'A'..='M' | 'a'..='m' => ((c as u8) + 13) as char, + 'N'..='Z' | 'n'..='z' => ((c as u8) - 13) as char, + _ => c, + }) + .collect() + }) + } + + pub fn convert_to_rot47( + &mut self, + _: &ConvertToRot47, + window: &mut Window, + cx: &mut Context, + ) { + self.manipulate_text(window, cx, |text| { + text.chars() + .map(|c| { + let code_point = c as u32; + if code_point >= 33 && code_point <= 126 { + return char::from_u32(33 + ((code_point + 14) % 94)).unwrap(); + } + c + }) + .collect() + }) + } + + fn manipulate_text(&mut self, window: &mut Window, cx: &mut Context, mut callback: Fn) + where + Fn: FnMut(&str) -> String, + { + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let buffer = self.buffer.read(cx).snapshot(cx); + + let mut new_selections = Vec::new(); + let mut edits = Vec::new(); + let mut selection_adjustment = 0i32; + + for selection in self.selections.all::(cx) { + let selection_is_empty = selection.is_empty(); + + let (start, end) = if selection_is_empty { + let word_range = movement::surrounding_word( + &display_map, + selection.start.to_display_point(&display_map), + ); + let start = word_range.start.to_offset(&display_map, Bias::Left); + let end = word_range.end.to_offset(&display_map, Bias::Left); + (start, end) + } else { + (selection.start, selection.end) + }; + + let text = buffer.text_for_range(start..end).collect::(); + let old_length = text.len() as i32; + let text = callback(&text); + + new_selections.push(Selection { + start: (start as i32 - selection_adjustment) as usize, + end: ((start + text.len()) as i32 - selection_adjustment) as usize, + goal: SelectionGoal::None, + ..selection + }); + + selection_adjustment += old_length - text.len() as i32; + + edits.push((start..end, text)); + } + + self.transact(window, cx, |this, window, cx| { + this.buffer.update(cx, |buffer, cx| { + buffer.edit(edits, None, cx); + }); + + this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.select(new_selections); + }); + + this.request_autoscroll(Autoscroll::fit(), cx); + }); + } + + pub fn duplicate( + &mut self, + upwards: bool, + whole_lines: bool, + window: &mut Window, + cx: &mut Context, + ) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); + + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let buffer = &display_map.buffer_snapshot; + let selections = self.selections.all::(cx); + + let mut edits = Vec::new(); + let mut selections_iter = selections.iter().peekable(); + while let Some(selection) = selections_iter.next() { + let mut rows = selection.spanned_rows(false, &display_map); + // duplicate line-wise + if whole_lines || selection.start == selection.end { + // Avoid duplicating the same lines twice. + while let Some(next_selection) = selections_iter.peek() { + let next_rows = next_selection.spanned_rows(false, &display_map); + if next_rows.start < rows.end { + rows.end = next_rows.end; + selections_iter.next().unwrap(); + } else { + break; + } + } + + // Copy the text from the selected row region and splice it either at the start + // or end of the region. + let start = Point::new(rows.start.0, 0); + let end = Point::new( + rows.end.previous_row().0, + buffer.line_len(rows.end.previous_row()), + ); + let text = buffer + .text_for_range(start..end) + .chain(Some("\n")) + .collect::(); + let insert_location = if upwards { + Point::new(rows.end.0, 0) + } else { + start + }; + edits.push((insert_location..insert_location, text)); + } else { + // duplicate character-wise + let start = selection.start; + let end = selection.end; + let text = buffer.text_for_range(start..end).collect::(); + edits.push((selection.end..selection.end, text)); + } + } + + self.transact(window, cx, |this, _, cx| { + this.buffer.update(cx, |buffer, cx| { + buffer.edit(edits, None, cx); + }); + + this.request_autoscroll(Autoscroll::fit(), cx); + }); + } + + pub fn duplicate_line_up( + &mut self, + _: &DuplicateLineUp, + window: &mut Window, + cx: &mut Context, + ) { + self.duplicate(true, true, window, cx); + } + + pub fn duplicate_line_down( + &mut self, + _: &DuplicateLineDown, + window: &mut Window, + cx: &mut Context, + ) { + self.duplicate(false, true, window, cx); + } + + pub fn duplicate_selection( + &mut self, + _: &DuplicateSelection, + window: &mut Window, + cx: &mut Context, + ) { + self.duplicate(false, false, window, cx); + } + + pub fn move_line_up(&mut self, _: &MoveLineUp, window: &mut Window, cx: &mut Context) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); + + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let buffer = self.buffer.read(cx).snapshot(cx); + + let mut edits = Vec::new(); + let mut unfold_ranges = Vec::new(); + let mut refold_creases = Vec::new(); + + let selections = self.selections.all::(cx); + let mut selections = selections.iter().peekable(); + let mut contiguous_row_selections = Vec::new(); + let mut new_selections = Vec::new(); + + while let Some(selection) = selections.next() { + // Find all the selections that span a contiguous row range + let (start_row, end_row) = consume_contiguous_rows( + &mut contiguous_row_selections, + selection, + &display_map, + &mut selections, + ); + + // Move the text spanned by the row range to be before the line preceding the row range + if start_row.0 > 0 { + let range_to_move = Point::new( + start_row.previous_row().0, + buffer.line_len(start_row.previous_row()), + ) + ..Point::new( + end_row.previous_row().0, + buffer.line_len(end_row.previous_row()), + ); + let insertion_point = display_map + .prev_line_boundary(Point::new(start_row.previous_row().0, 0)) + .0; + + // Don't move lines across excerpts + if buffer + .excerpt_containing(insertion_point..range_to_move.end) + .is_some() + { + let text = buffer + .text_for_range(range_to_move.clone()) + .flat_map(|s| s.chars()) + .skip(1) + .chain(['\n']) + .collect::(); + + edits.push(( + buffer.anchor_after(range_to_move.start) + ..buffer.anchor_before(range_to_move.end), + String::new(), + )); + let insertion_anchor = buffer.anchor_after(insertion_point); + edits.push((insertion_anchor..insertion_anchor, text)); + + let row_delta = range_to_move.start.row - insertion_point.row + 1; + + // Move selections up + new_selections.extend(contiguous_row_selections.drain(..).map( + |mut selection| { + selection.start.row -= row_delta; + selection.end.row -= row_delta; + selection + }, + )); + + // Move folds up + unfold_ranges.push(range_to_move.clone()); + for fold in display_map.folds_in_range( + buffer.anchor_before(range_to_move.start) + ..buffer.anchor_after(range_to_move.end), + ) { + let mut start = fold.range.start.to_point(&buffer); + let mut end = fold.range.end.to_point(&buffer); + start.row -= row_delta; + end.row -= row_delta; + refold_creases.push(Crease::simple(start..end, fold.placeholder.clone())); + } + } + } + + // If we didn't move line(s), preserve the existing selections + new_selections.append(&mut contiguous_row_selections); + } + + self.transact(window, cx, |this, window, cx| { + this.unfold_ranges(&unfold_ranges, true, true, cx); + this.buffer.update(cx, |buffer, cx| { + for (range, text) in edits { + buffer.edit([(range, text)], None, cx); + } + }); + this.fold_creases(refold_creases, true, window, cx); + this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.select(new_selections); + }) + }); + } + + pub fn move_line_down( + &mut self, + _: &MoveLineDown, + window: &mut Window, + cx: &mut Context, + ) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); + + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let buffer = self.buffer.read(cx).snapshot(cx); + + let mut edits = Vec::new(); + let mut unfold_ranges = Vec::new(); + let mut refold_creases = Vec::new(); + + let selections = self.selections.all::(cx); + let mut selections = selections.iter().peekable(); + let mut contiguous_row_selections = Vec::new(); + let mut new_selections = Vec::new(); + + while let Some(selection) = selections.next() { + // Find all the selections that span a contiguous row range + let (start_row, end_row) = consume_contiguous_rows( + &mut contiguous_row_selections, + selection, + &display_map, + &mut selections, + ); + + // Move the text spanned by the row range to be after the last line of the row range + if end_row.0 <= buffer.max_point().row { + let range_to_move = + MultiBufferPoint::new(start_row.0, 0)..MultiBufferPoint::new(end_row.0, 0); + let insertion_point = display_map + .next_line_boundary(MultiBufferPoint::new(end_row.0, 0)) + .0; + + // Don't move lines across excerpt boundaries + if buffer + .excerpt_containing(range_to_move.start..insertion_point) + .is_some() + { + let mut text = String::from("\n"); + text.extend(buffer.text_for_range(range_to_move.clone())); + text.pop(); // Drop trailing newline + edits.push(( + buffer.anchor_after(range_to_move.start) + ..buffer.anchor_before(range_to_move.end), + String::new(), + )); + let insertion_anchor = buffer.anchor_after(insertion_point); + edits.push((insertion_anchor..insertion_anchor, text)); + + let row_delta = insertion_point.row - range_to_move.end.row + 1; + + // Move selections down + new_selections.extend(contiguous_row_selections.drain(..).map( + |mut selection| { + selection.start.row += row_delta; + selection.end.row += row_delta; + selection + }, + )); + + // Move folds down + unfold_ranges.push(range_to_move.clone()); + for fold in display_map.folds_in_range( + buffer.anchor_before(range_to_move.start) + ..buffer.anchor_after(range_to_move.end), + ) { + let mut start = fold.range.start.to_point(&buffer); + let mut end = fold.range.end.to_point(&buffer); + start.row += row_delta; + end.row += row_delta; + refold_creases.push(Crease::simple(start..end, fold.placeholder.clone())); + } + } + } + + // If we didn't move line(s), preserve the existing selections + new_selections.append(&mut contiguous_row_selections); + } + + self.transact(window, cx, |this, window, cx| { + this.unfold_ranges(&unfold_ranges, true, true, cx); + this.buffer.update(cx, |buffer, cx| { + for (range, text) in edits { + buffer.edit([(range, text)], None, cx); + } + }); + this.fold_creases(refold_creases, true, window, cx); + this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.select(new_selections) + }); + }); + } + + pub fn transpose(&mut self, _: &Transpose, window: &mut Window, cx: &mut Context) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); + let text_layout_details = &self.text_layout_details(window); + self.transact(window, cx, |this, window, cx| { + let edits = this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + let mut edits: Vec<(Range, String)> = Default::default(); + s.move_with(|display_map, selection| { + if !selection.is_empty() { + return; + } + + let mut head = selection.head(); + let mut transpose_offset = head.to_offset(display_map, Bias::Right); + if head.column() == display_map.line_len(head.row()) { + transpose_offset = display_map + .buffer_snapshot + .clip_offset(transpose_offset.saturating_sub(1), Bias::Left); + } + + if transpose_offset == 0 { + return; + } + + *head.column_mut() += 1; + head = display_map.clip_point(head, Bias::Right); + let goal = SelectionGoal::HorizontalPosition( + display_map + .x_for_display_point(head, text_layout_details) + .into(), + ); + selection.collapse_to(head, goal); + + let transpose_start = display_map + .buffer_snapshot + .clip_offset(transpose_offset.saturating_sub(1), Bias::Left); + if edits.last().map_or(true, |e| e.0.end <= transpose_start) { + let transpose_end = display_map + .buffer_snapshot + .clip_offset(transpose_offset + 1, Bias::Right); + if let Some(ch) = + display_map.buffer_snapshot.chars_at(transpose_start).next() + { + edits.push((transpose_start..transpose_offset, String::new())); + edits.push((transpose_end..transpose_end, ch.to_string())); + } + } + }); + edits + }); + this.buffer + .update(cx, |buffer, cx| buffer.edit(edits, None, cx)); + let selections = this.selections.all::(cx); + this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.select(selections); + }); + }); + } + + pub fn rewrap(&mut self, _: &Rewrap, _: &mut Window, cx: &mut Context) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); + self.rewrap_impl(RewrapOptions::default(), cx) + } + + pub fn rewrap_impl(&mut self, options: RewrapOptions, cx: &mut Context) { + let buffer = self.buffer.read(cx).snapshot(cx); + let selections = self.selections.all::(cx); + let mut selections = selections.iter().peekable(); + + let mut edits = Vec::new(); + let mut rewrapped_row_ranges = Vec::>::new(); + + while let Some(selection) = selections.next() { + let mut start_row = selection.start.row; + let mut end_row = selection.end.row; + + // Skip selections that overlap with a range that has already been rewrapped. + let selection_range = start_row..end_row; + if rewrapped_row_ranges + .iter() + .any(|range| range.overlaps(&selection_range)) + { + continue; + } + + let tab_size = buffer.language_settings_at(selection.head(), cx).tab_size; + + // Since not all lines in the selection may be at the same indent + // level, choose the indent size that is the most common between all + // of the lines. + // + // If there is a tie, we use the deepest indent. + let (indent_size, indent_end) = { + let mut indent_size_occurrences = HashMap::default(); + let mut rows_by_indent_size = HashMap::>::default(); + + for row in start_row..=end_row { + let indent = buffer.indent_size_for_line(MultiBufferRow(row)); + rows_by_indent_size.entry(indent).or_default().push(row); + *indent_size_occurrences.entry(indent).or_insert(0) += 1; + } + + let indent_size = indent_size_occurrences + .into_iter() + .max_by_key(|(indent, count)| (*count, indent.len_with_expanded_tabs(tab_size))) + .map(|(indent, _)| indent) + .unwrap_or_default(); + let row = rows_by_indent_size[&indent_size][0]; + let indent_end = Point::new(row, indent_size.len); + + (indent_size, indent_end) + }; + + let mut line_prefix = indent_size.chars().collect::(); + + let mut inside_comment = false; + if let Some(comment_prefix) = + buffer + .language_scope_at(selection.head()) + .and_then(|language| { + language + .line_comment_prefixes() + .iter() + .find(|prefix| buffer.contains_str_at(indent_end, prefix)) + .cloned() + }) + { + line_prefix.push_str(&comment_prefix); + inside_comment = true; + } + + let language_settings = buffer.language_settings_at(selection.head(), cx); + let allow_rewrap_based_on_language = match language_settings.allow_rewrap { + RewrapBehavior::InComments => inside_comment, + RewrapBehavior::InSelections => !selection.is_empty(), + RewrapBehavior::Anywhere => true, + }; + + let should_rewrap = options.override_language_settings + || allow_rewrap_based_on_language + || self.hard_wrap.is_some(); + if !should_rewrap { + continue; + } + + if selection.is_empty() { + 'expand_upwards: while start_row > 0 { + let prev_row = start_row - 1; + if buffer.contains_str_at(Point::new(prev_row, 0), &line_prefix) + && buffer.line_len(MultiBufferRow(prev_row)) as usize > line_prefix.len() + { + start_row = prev_row; + } else { + break 'expand_upwards; + } + } + + 'expand_downwards: while end_row < buffer.max_point().row { + let next_row = end_row + 1; + if buffer.contains_str_at(Point::new(next_row, 0), &line_prefix) + && buffer.line_len(MultiBufferRow(next_row)) as usize > line_prefix.len() + { + end_row = next_row; + } else { + break 'expand_downwards; + } + } + } + + let start = Point::new(start_row, 0); + let start_offset = start.to_offset(&buffer); + let end = Point::new(end_row, buffer.line_len(MultiBufferRow(end_row))); + let selection_text = buffer.text_for_range(start..end).collect::(); + let Some(lines_without_prefixes) = selection_text + .lines() + .map(|line| { + line.strip_prefix(&line_prefix) + .or_else(|| line.trim_start().strip_prefix(&line_prefix.trim_start())) + .with_context(|| { + format!("line did not start with prefix {line_prefix:?}: {line:?}") + }) + }) + .collect::, _>>() + .log_err() + else { + continue; + }; + + let wrap_column = self.hard_wrap.unwrap_or_else(|| { + buffer + .language_settings_at(Point::new(start_row, 0), cx) + .preferred_line_length as usize + }); + let wrapped_text = wrap_with_prefix( + line_prefix, + lines_without_prefixes.join("\n"), + wrap_column, + tab_size, + options.preserve_existing_whitespace, + ); + + // TODO: should always use char-based diff while still supporting cursor behavior that + // matches vim. + let mut diff_options = DiffOptions::default(); + if options.override_language_settings { + diff_options.max_word_diff_len = 0; + diff_options.max_word_diff_line_count = 0; + } else { + diff_options.max_word_diff_len = usize::MAX; + diff_options.max_word_diff_line_count = usize::MAX; + } + + for (old_range, new_text) in + text_diff_with_options(&selection_text, &wrapped_text, diff_options) + { + let edit_start = buffer.anchor_after(start_offset + old_range.start); + let edit_end = buffer.anchor_after(start_offset + old_range.end); + edits.push((edit_start..edit_end, new_text)); + } + + rewrapped_row_ranges.push(start_row..=end_row); + } + + self.buffer + .update(cx, |buffer, cx| buffer.edit(edits, None, cx)); + } + + pub fn cut_common(&mut self, window: &mut Window, cx: &mut Context) -> ClipboardItem { + let mut text = String::new(); + let buffer = self.buffer.read(cx).snapshot(cx); + let mut selections = self.selections.all::(cx); + let mut clipboard_selections = Vec::with_capacity(selections.len()); + { + let max_point = buffer.max_point(); + let mut is_first = true; + for selection in &mut selections { + let is_entire_line = selection.is_empty() || self.selections.line_mode; + if is_entire_line { + selection.start = Point::new(selection.start.row, 0); + if !selection.is_empty() && selection.end.column == 0 { + selection.end = cmp::min(max_point, selection.end); + } else { + selection.end = cmp::min(max_point, Point::new(selection.end.row + 1, 0)); + } + selection.goal = SelectionGoal::None; + } + if is_first { + is_first = false; + } else { + text += "\n"; + } + let mut len = 0; + for chunk in buffer.text_for_range(selection.start..selection.end) { + text.push_str(chunk); + len += chunk.len(); + } + clipboard_selections.push(ClipboardSelection { + len, + is_entire_line, + first_line_indent: buffer + .indent_size_for_line(MultiBufferRow(selection.start.row)) + .len, + }); + } + } + + self.transact(window, cx, |this, window, cx| { + this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.select(selections); + }); + this.insert("", window, cx); + }); + ClipboardItem::new_string_with_json_metadata(text, clipboard_selections) + } + + pub fn cut(&mut self, _: &Cut, window: &mut Window, cx: &mut Context) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); + let item = self.cut_common(window, cx); + cx.write_to_clipboard(item); + } + + pub fn kill_ring_cut(&mut self, _: &KillRingCut, window: &mut Window, cx: &mut Context) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); + self.change_selections(None, window, cx, |s| { + s.move_with(|snapshot, sel| { + if sel.is_empty() { + sel.end = DisplayPoint::new(sel.end.row(), snapshot.line_len(sel.end.row())) + } + }); + }); + let item = self.cut_common(window, cx); + cx.set_global(KillRing(item)) + } + + pub fn kill_ring_yank( + &mut self, + _: &KillRingYank, + window: &mut Window, + cx: &mut Context, + ) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); + let (text, metadata) = if let Some(KillRing(item)) = cx.try_global() { + if let Some(ClipboardEntry::String(kill_ring)) = item.entries().first() { + (kill_ring.text().to_string(), kill_ring.metadata_json()) + } else { + return; + } + } else { + return; + }; + self.do_paste(&text, metadata, false, window, cx); + } + + pub fn copy_and_trim(&mut self, _: &CopyAndTrim, _: &mut Window, cx: &mut Context) { + self.do_copy(true, cx); + } + + pub fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context) { + self.do_copy(false, cx); + } + + fn do_copy(&self, strip_leading_indents: bool, cx: &mut Context) { + let selections = self.selections.all::(cx); + let buffer = self.buffer.read(cx).read(cx); + let mut text = String::new(); + + let mut clipboard_selections = Vec::with_capacity(selections.len()); + { + let max_point = buffer.max_point(); + let mut is_first = true; + for selection in &selections { + let mut start = selection.start; + let mut end = selection.end; + let is_entire_line = selection.is_empty() || self.selections.line_mode; + if is_entire_line { + start = Point::new(start.row, 0); + end = cmp::min(max_point, Point::new(end.row + 1, 0)); + } + + let mut trimmed_selections = Vec::new(); + if strip_leading_indents && end.row.saturating_sub(start.row) > 0 { + let row = MultiBufferRow(start.row); + let first_indent = buffer.indent_size_for_line(row); + if first_indent.len == 0 || start.column > first_indent.len { + trimmed_selections.push(start..end); + } else { + trimmed_selections.push( + Point::new(row.0, first_indent.len) + ..Point::new(row.0, buffer.line_len(row)), + ); + for row in start.row + 1..=end.row { + let mut line_len = buffer.line_len(MultiBufferRow(row)); + if row == end.row { + line_len = end.column; + } + if line_len == 0 { + trimmed_selections + .push(Point::new(row, 0)..Point::new(row, line_len)); + continue; + } + let row_indent_size = buffer.indent_size_for_line(MultiBufferRow(row)); + if row_indent_size.len >= first_indent.len { + trimmed_selections.push( + Point::new(row, first_indent.len)..Point::new(row, line_len), + ); + } else { + trimmed_selections.clear(); + trimmed_selections.push(start..end); + break; + } + } + } + } else { + trimmed_selections.push(start..end); + } + + for trimmed_range in trimmed_selections { + if is_first { + is_first = false; + } else { + text += "\n"; + } + let mut len = 0; + for chunk in buffer.text_for_range(trimmed_range.start..trimmed_range.end) { + text.push_str(chunk); + len += chunk.len(); + } + clipboard_selections.push(ClipboardSelection { + len, + is_entire_line, + first_line_indent: buffer + .indent_size_for_line(MultiBufferRow(trimmed_range.start.row)) + .len, + }); + } + } + } + + cx.write_to_clipboard(ClipboardItem::new_string_with_json_metadata( + text, + clipboard_selections, + )); + } + + pub fn do_paste( + &mut self, + text: &String, + clipboard_selections: Option>, + handle_entire_lines: bool, + window: &mut Window, + cx: &mut Context, + ) { + if self.read_only(cx) { + return; + } + + let clipboard_text = Cow::Borrowed(text); + + self.transact(window, cx, |this, window, cx| { + if let Some(mut clipboard_selections) = clipboard_selections { + let old_selections = this.selections.all::(cx); + let all_selections_were_entire_line = + clipboard_selections.iter().all(|s| s.is_entire_line); + let first_selection_indent_column = + clipboard_selections.first().map(|s| s.first_line_indent); + if clipboard_selections.len() != old_selections.len() { + clipboard_selections.drain(..); + } + let cursor_offset = this.selections.last::(cx).head(); + let mut auto_indent_on_paste = true; + + this.buffer.update(cx, |buffer, cx| { + let snapshot = buffer.read(cx); + auto_indent_on_paste = snapshot + .language_settings_at(cursor_offset, cx) + .auto_indent_on_paste; + + let mut start_offset = 0; + let mut edits = Vec::new(); + let mut original_indent_columns = Vec::new(); + for (ix, selection) in old_selections.iter().enumerate() { + let to_insert; + let entire_line; + let original_indent_column; + if let Some(clipboard_selection) = clipboard_selections.get(ix) { + let end_offset = start_offset + clipboard_selection.len; + to_insert = &clipboard_text[start_offset..end_offset]; + entire_line = clipboard_selection.is_entire_line; + start_offset = end_offset + 1; + original_indent_column = Some(clipboard_selection.first_line_indent); + } else { + to_insert = clipboard_text.as_str(); + entire_line = all_selections_were_entire_line; + original_indent_column = first_selection_indent_column + } + + // If the corresponding selection was empty when this slice of the + // clipboard text was written, then the entire line containing the + // selection was copied. If this selection is also currently empty, + // then paste the line before the current line of the buffer. + let range = if selection.is_empty() && handle_entire_lines && entire_line { + let column = selection.start.to_point(&snapshot).column as usize; + let line_start = selection.start - column; + line_start..line_start + } else { + selection.range() + }; + + edits.push((range, to_insert)); + original_indent_columns.push(original_indent_column); + } + drop(snapshot); + + buffer.edit( + edits, + if auto_indent_on_paste { + Some(AutoindentMode::Block { + original_indent_columns, + }) + } else { + None + }, + cx, + ); + }); + + let selections = this.selections.all::(cx); + this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.select(selections) + }); + } else { + this.insert(&clipboard_text, window, cx); + } + }); + } + + pub fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); + if let Some(item) = cx.read_from_clipboard() { + let entries = item.entries(); + + match entries.first() { + // For now, we only support applying metadata if there's one string. In the future, we can incorporate all the selections + // of all the pasted entries. + Some(ClipboardEntry::String(clipboard_string)) if entries.len() == 1 => self + .do_paste( + clipboard_string.text(), + clipboard_string.metadata_json::>(), + true, + window, + cx, + ), + _ => self.do_paste(&item.text().unwrap_or_default(), None, true, window, cx), + } + } + } + + pub fn undo(&mut self, _: &Undo, window: &mut Window, cx: &mut Context) { + if self.read_only(cx) { + return; + } + + self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); + + if let Some(transaction_id) = self.buffer.update(cx, |buffer, cx| buffer.undo(cx)) { + if let Some((selections, _)) = + self.selection_history.transaction(transaction_id).cloned() + { + self.change_selections(None, window, cx, |s| { + s.select_anchors(selections.to_vec()); + }); + } else { + log::error!( + "No entry in selection_history found for undo. \ + This may correspond to a bug where undo does not update the selection. \ + If this is occurring, please add details to \ + https://github.com/zed-industries/zed/issues/22692" + ); + } + self.request_autoscroll(Autoscroll::fit(), cx); + self.unmark_text(window, cx); + self.refresh_inline_completion(true, false, window, cx); + cx.emit(EditorEvent::Edited { transaction_id }); + cx.emit(EditorEvent::TransactionUndone { transaction_id }); + } + } + + pub fn redo(&mut self, _: &Redo, window: &mut Window, cx: &mut Context) { + if self.read_only(cx) { + return; + } + + self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); + + if let Some(transaction_id) = self.buffer.update(cx, |buffer, cx| buffer.redo(cx)) { + if let Some((_, Some(selections))) = + self.selection_history.transaction(transaction_id).cloned() + { + self.change_selections(None, window, cx, |s| { + s.select_anchors(selections.to_vec()); + }); + } else { + log::error!( + "No entry in selection_history found for redo. \ + This may correspond to a bug where undo does not update the selection. \ + If this is occurring, please add details to \ + https://github.com/zed-industries/zed/issues/22692" + ); + } + self.request_autoscroll(Autoscroll::fit(), cx); + self.unmark_text(window, cx); + self.refresh_inline_completion(true, false, window, cx); + cx.emit(EditorEvent::Edited { transaction_id }); + } + } + + pub fn finalize_last_transaction(&mut self, cx: &mut Context) { + self.buffer + .update(cx, |buffer, cx| buffer.finalize_last_transaction(cx)); + } + + pub fn group_until_transaction(&mut self, tx_id: TransactionId, cx: &mut Context) { + self.buffer + .update(cx, |buffer, cx| buffer.group_until_transaction(tx_id, cx)); + } + + pub fn move_left(&mut self, _: &MoveLeft, window: &mut Window, cx: &mut Context) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.move_with(|map, selection| { + let cursor = if selection.is_empty() { + movement::left(map, selection.start) + } else { + selection.start + }; + selection.collapse_to(cursor, SelectionGoal::None); + }); + }) + } + + pub fn select_left(&mut self, _: &SelectLeft, window: &mut Window, cx: &mut Context) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.move_heads_with(|map, head, _| (movement::left(map, head), SelectionGoal::None)); + }) + } + + pub fn move_right(&mut self, _: &MoveRight, window: &mut Window, cx: &mut Context) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.move_with(|map, selection| { + let cursor = if selection.is_empty() { + movement::right(map, selection.end) + } else { + selection.end + }; + selection.collapse_to(cursor, SelectionGoal::None) + }); + }) + } + + pub fn select_right(&mut self, _: &SelectRight, window: &mut Window, cx: &mut Context) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.move_heads_with(|map, head, _| (movement::right(map, head), SelectionGoal::None)); + }) + } + + pub fn move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context) { + if self.take_rename(true, window, cx).is_some() { + return; + } + + if matches!(self.mode, EditorMode::SingleLine { .. }) { + cx.propagate(); + return; + } + + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + + let text_layout_details = &self.text_layout_details(window); + let selection_count = self.selections.count(); + let first_selection = self.selections.first_anchor(); + + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.move_with(|map, selection| { + if !selection.is_empty() { + selection.goal = SelectionGoal::None; + } + let (cursor, goal) = movement::up( + map, + selection.start, + selection.goal, + false, + text_layout_details, + ); + selection.collapse_to(cursor, goal); + }); + }); + + if selection_count == 1 && first_selection.range() == self.selections.first_anchor().range() + { + cx.propagate(); + } + } + + pub fn move_up_by_lines( + &mut self, + action: &MoveUpByLines, + window: &mut Window, + cx: &mut Context, + ) { + if self.take_rename(true, window, cx).is_some() { + return; + } + + if matches!(self.mode, EditorMode::SingleLine { .. }) { + cx.propagate(); + return; + } + + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + + let text_layout_details = &self.text_layout_details(window); + + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.move_with(|map, selection| { + if !selection.is_empty() { + selection.goal = SelectionGoal::None; + } + let (cursor, goal) = movement::up_by_rows( + map, + selection.start, + action.lines, + selection.goal, + false, + text_layout_details, + ); + selection.collapse_to(cursor, goal); + }); + }) + } + + pub fn move_down_by_lines( + &mut self, + action: &MoveDownByLines, + window: &mut Window, + cx: &mut Context, + ) { + if self.take_rename(true, window, cx).is_some() { + return; + } + + if matches!(self.mode, EditorMode::SingleLine { .. }) { + cx.propagate(); + return; + } + + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + + let text_layout_details = &self.text_layout_details(window); + + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.move_with(|map, selection| { + if !selection.is_empty() { + selection.goal = SelectionGoal::None; + } + let (cursor, goal) = movement::down_by_rows( + map, + selection.start, + action.lines, + selection.goal, + false, + text_layout_details, + ); + selection.collapse_to(cursor, goal); + }); + }) + } + + pub fn select_down_by_lines( + &mut self, + action: &SelectDownByLines, + window: &mut Window, + cx: &mut Context, + ) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + let text_layout_details = &self.text_layout_details(window); + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.move_heads_with(|map, head, goal| { + movement::down_by_rows(map, head, action.lines, goal, false, text_layout_details) + }) + }) + } + + pub fn select_up_by_lines( + &mut self, + action: &SelectUpByLines, + window: &mut Window, + cx: &mut Context, + ) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + let text_layout_details = &self.text_layout_details(window); + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.move_heads_with(|map, head, goal| { + movement::up_by_rows(map, head, action.lines, goal, false, text_layout_details) + }) + }) + } + + pub fn select_page_up( + &mut self, + _: &SelectPageUp, + window: &mut Window, + cx: &mut Context, + ) { + let Some(row_count) = self.visible_row_count() else { + return; + }; + + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + + let text_layout_details = &self.text_layout_details(window); + + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.move_heads_with(|map, head, goal| { + movement::up_by_rows(map, head, row_count, goal, false, text_layout_details) + }) + }) + } + + pub fn move_page_up( + &mut self, + action: &MovePageUp, + window: &mut Window, + cx: &mut Context, + ) { + if self.take_rename(true, window, cx).is_some() { + return; + } + + if self + .context_menu + .borrow_mut() + .as_mut() + .map(|menu| menu.select_first(self.completion_provider.as_deref(), cx)) + .unwrap_or(false) + { + return; + } + + if matches!(self.mode, EditorMode::SingleLine { .. }) { + cx.propagate(); + return; + } + + let Some(row_count) = self.visible_row_count() else { + return; + }; + + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + + let autoscroll = if action.center_cursor { + Autoscroll::center() + } else { + Autoscroll::fit() + }; + + let text_layout_details = &self.text_layout_details(window); + + self.change_selections(Some(autoscroll), window, cx, |s| { + s.move_with(|map, selection| { + if !selection.is_empty() { + selection.goal = SelectionGoal::None; + } + let (cursor, goal) = movement::up_by_rows( + map, + selection.end, + row_count, + selection.goal, + false, + text_layout_details, + ); + selection.collapse_to(cursor, goal); + }); + }); + } + + pub fn select_up(&mut self, _: &SelectUp, window: &mut Window, cx: &mut Context) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + let text_layout_details = &self.text_layout_details(window); + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.move_heads_with(|map, head, goal| { + movement::up(map, head, goal, false, text_layout_details) + }) + }) + } + + pub fn move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context) { + self.take_rename(true, window, cx); + + if matches!(self.mode, EditorMode::SingleLine { .. }) { + cx.propagate(); + return; + } + + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + + let text_layout_details = &self.text_layout_details(window); + let selection_count = self.selections.count(); + let first_selection = self.selections.first_anchor(); + + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.move_with(|map, selection| { + if !selection.is_empty() { + selection.goal = SelectionGoal::None; + } + let (cursor, goal) = movement::down( + map, + selection.end, + selection.goal, + false, + text_layout_details, + ); + selection.collapse_to(cursor, goal); + }); + }); + + if selection_count == 1 && first_selection.range() == self.selections.first_anchor().range() + { + cx.propagate(); + } + } + + pub fn select_page_down( + &mut self, + _: &SelectPageDown, + window: &mut Window, + cx: &mut Context, + ) { + let Some(row_count) = self.visible_row_count() else { + return; + }; + + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + + let text_layout_details = &self.text_layout_details(window); + + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.move_heads_with(|map, head, goal| { + movement::down_by_rows(map, head, row_count, goal, false, text_layout_details) + }) + }) + } + + pub fn move_page_down( + &mut self, + action: &MovePageDown, + window: &mut Window, + cx: &mut Context, + ) { + if self.take_rename(true, window, cx).is_some() { + return; + } + + if self + .context_menu + .borrow_mut() + .as_mut() + .map(|menu| menu.select_last(self.completion_provider.as_deref(), cx)) + .unwrap_or(false) + { + return; + } + + if matches!(self.mode, EditorMode::SingleLine { .. }) { + cx.propagate(); + return; + } + + let Some(row_count) = self.visible_row_count() else { + return; + }; + + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + + let autoscroll = if action.center_cursor { + Autoscroll::center() + } else { + Autoscroll::fit() + }; + + let text_layout_details = &self.text_layout_details(window); + self.change_selections(Some(autoscroll), window, cx, |s| { + s.move_with(|map, selection| { + if !selection.is_empty() { + selection.goal = SelectionGoal::None; + } + let (cursor, goal) = movement::down_by_rows( + map, + selection.end, + row_count, + selection.goal, + false, + text_layout_details, + ); + selection.collapse_to(cursor, goal); + }); + }); + } + + pub fn select_down(&mut self, _: &SelectDown, window: &mut Window, cx: &mut Context) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + let text_layout_details = &self.text_layout_details(window); + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.move_heads_with(|map, head, goal| { + movement::down(map, head, goal, false, text_layout_details) + }) + }); + } + + pub fn context_menu_first( + &mut self, + _: &ContextMenuFirst, + _window: &mut Window, + cx: &mut Context, + ) { + if let Some(context_menu) = self.context_menu.borrow_mut().as_mut() { + context_menu.select_first(self.completion_provider.as_deref(), cx); + } + } + + pub fn context_menu_prev( + &mut self, + _: &ContextMenuPrevious, + _window: &mut Window, + cx: &mut Context, + ) { + if let Some(context_menu) = self.context_menu.borrow_mut().as_mut() { + context_menu.select_prev(self.completion_provider.as_deref(), cx); + } + } + + pub fn context_menu_next( + &mut self, + _: &ContextMenuNext, + _window: &mut Window, + cx: &mut Context, + ) { + if let Some(context_menu) = self.context_menu.borrow_mut().as_mut() { + context_menu.select_next(self.completion_provider.as_deref(), cx); + } + } + + pub fn context_menu_last( + &mut self, + _: &ContextMenuLast, + _window: &mut Window, + cx: &mut Context, + ) { + if let Some(context_menu) = self.context_menu.borrow_mut().as_mut() { + context_menu.select_last(self.completion_provider.as_deref(), cx); + } + } + + pub fn move_to_previous_word_start( + &mut self, + _: &MoveToPreviousWordStart, + window: &mut Window, + cx: &mut Context, + ) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.move_cursors_with(|map, head, _| { + ( + movement::previous_word_start(map, head), + SelectionGoal::None, + ) + }); + }) + } + + pub fn move_to_previous_subword_start( + &mut self, + _: &MoveToPreviousSubwordStart, + window: &mut Window, + cx: &mut Context, + ) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.move_cursors_with(|map, head, _| { + ( + movement::previous_subword_start(map, head), + SelectionGoal::None, + ) + }); + }) + } + + pub fn select_to_previous_word_start( + &mut self, + _: &SelectToPreviousWordStart, + window: &mut Window, + cx: &mut Context, + ) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.move_heads_with(|map, head, _| { + ( + movement::previous_word_start(map, head), + SelectionGoal::None, + ) + }); + }) + } + + pub fn select_to_previous_subword_start( + &mut self, + _: &SelectToPreviousSubwordStart, + window: &mut Window, + cx: &mut Context, + ) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.move_heads_with(|map, head, _| { + ( + movement::previous_subword_start(map, head), + SelectionGoal::None, + ) + }); + }) + } + + pub fn delete_to_previous_word_start( + &mut self, + action: &DeleteToPreviousWordStart, + window: &mut Window, + cx: &mut Context, + ) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); + self.transact(window, cx, |this, window, cx| { + this.select_autoclose_pair(window, cx); + this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.move_with(|map, selection| { + if selection.is_empty() { + let cursor = if action.ignore_newlines { + movement::previous_word_start(map, selection.head()) + } else { + movement::previous_word_start_or_newline(map, selection.head()) + }; + selection.set_head(cursor, SelectionGoal::None); + } + }); + }); + this.insert("", window, cx); + }); + } + + pub fn delete_to_previous_subword_start( + &mut self, + _: &DeleteToPreviousSubwordStart, + window: &mut Window, + cx: &mut Context, + ) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); + self.transact(window, cx, |this, window, cx| { + this.select_autoclose_pair(window, cx); + this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.move_with(|map, selection| { + if selection.is_empty() { + let cursor = movement::previous_subword_start(map, selection.head()); + selection.set_head(cursor, SelectionGoal::None); + } + }); + }); + this.insert("", window, cx); + }); + } + + pub fn move_to_next_word_end( + &mut self, + _: &MoveToNextWordEnd, + window: &mut Window, + cx: &mut Context, + ) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.move_cursors_with(|map, head, _| { + (movement::next_word_end(map, head), SelectionGoal::None) + }); + }) + } + + pub fn move_to_next_subword_end( + &mut self, + _: &MoveToNextSubwordEnd, + window: &mut Window, + cx: &mut Context, + ) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.move_cursors_with(|map, head, _| { + (movement::next_subword_end(map, head), SelectionGoal::None) + }); + }) + } + + pub fn select_to_next_word_end( + &mut self, + _: &SelectToNextWordEnd, + window: &mut Window, + cx: &mut Context, + ) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.move_heads_with(|map, head, _| { + (movement::next_word_end(map, head), SelectionGoal::None) + }); + }) + } + + pub fn select_to_next_subword_end( + &mut self, + _: &SelectToNextSubwordEnd, + window: &mut Window, + cx: &mut Context, + ) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.move_heads_with(|map, head, _| { + (movement::next_subword_end(map, head), SelectionGoal::None) + }); + }) + } + + pub fn delete_to_next_word_end( + &mut self, + action: &DeleteToNextWordEnd, + window: &mut Window, + cx: &mut Context, + ) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); + self.transact(window, cx, |this, window, cx| { + this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.move_with(|map, selection| { + if selection.is_empty() { + let cursor = if action.ignore_newlines { + movement::next_word_end(map, selection.head()) + } else { + movement::next_word_end_or_newline(map, selection.head()) + }; + selection.set_head(cursor, SelectionGoal::None); + } + }); + }); + this.insert("", window, cx); + }); + } + + pub fn delete_to_next_subword_end( + &mut self, + _: &DeleteToNextSubwordEnd, + window: &mut Window, + cx: &mut Context, + ) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); + self.transact(window, cx, |this, window, cx| { + this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.move_with(|map, selection| { + if selection.is_empty() { + let cursor = movement::next_subword_end(map, selection.head()); + selection.set_head(cursor, SelectionGoal::None); + } + }); + }); + this.insert("", window, cx); + }); + } + + pub fn move_to_beginning_of_line( + &mut self, + action: &MoveToBeginningOfLine, + window: &mut Window, + cx: &mut Context, + ) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.move_cursors_with(|map, head, _| { + ( + movement::indented_line_beginning( + map, + head, + action.stop_at_soft_wraps, + action.stop_at_indent, + ), + SelectionGoal::None, + ) + }); + }) + } + + pub fn select_to_beginning_of_line( + &mut self, + action: &SelectToBeginningOfLine, + window: &mut Window, + cx: &mut Context, + ) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.move_heads_with(|map, head, _| { + ( + movement::indented_line_beginning( + map, + head, + action.stop_at_soft_wraps, + action.stop_at_indent, + ), + SelectionGoal::None, + ) + }); + }); + } + + pub fn delete_to_beginning_of_line( + &mut self, + action: &DeleteToBeginningOfLine, + window: &mut Window, + cx: &mut Context, + ) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); + self.transact(window, cx, |this, window, cx| { + this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.move_with(|_, selection| { + selection.reversed = true; + }); + }); + + this.select_to_beginning_of_line( + &SelectToBeginningOfLine { + stop_at_soft_wraps: false, + stop_at_indent: action.stop_at_indent, + }, + window, + cx, + ); + this.backspace(&Backspace, window, cx); + }); + } + + pub fn move_to_end_of_line( + &mut self, + action: &MoveToEndOfLine, + window: &mut Window, + cx: &mut Context, + ) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.move_cursors_with(|map, head, _| { + ( + movement::line_end(map, head, action.stop_at_soft_wraps), + SelectionGoal::None, + ) + }); + }) + } + + pub fn select_to_end_of_line( + &mut self, + action: &SelectToEndOfLine, + window: &mut Window, + cx: &mut Context, + ) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.move_heads_with(|map, head, _| { + ( + movement::line_end(map, head, action.stop_at_soft_wraps), + SelectionGoal::None, + ) + }); + }) + } + + pub fn delete_to_end_of_line( + &mut self, + _: &DeleteToEndOfLine, + window: &mut Window, + cx: &mut Context, + ) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); + self.transact(window, cx, |this, window, cx| { + this.select_to_end_of_line( + &SelectToEndOfLine { + stop_at_soft_wraps: false, + }, + window, + cx, + ); + this.delete(&Delete, window, cx); + }); + } + + pub fn cut_to_end_of_line( + &mut self, + _: &CutToEndOfLine, + window: &mut Window, + cx: &mut Context, + ) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); + self.transact(window, cx, |this, window, cx| { + this.select_to_end_of_line( + &SelectToEndOfLine { + stop_at_soft_wraps: false, + }, + window, + cx, + ); + this.cut(&Cut, window, cx); + }); + } + + pub fn move_to_start_of_paragraph( + &mut self, + _: &MoveToStartOfParagraph, + window: &mut Window, + cx: &mut Context, + ) { + if matches!(self.mode, EditorMode::SingleLine { .. }) { + cx.propagate(); + return; + } + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.move_with(|map, selection| { + selection.collapse_to( + movement::start_of_paragraph(map, selection.head(), 1), + SelectionGoal::None, + ) + }); + }) + } + + pub fn move_to_end_of_paragraph( + &mut self, + _: &MoveToEndOfParagraph, + window: &mut Window, + cx: &mut Context, + ) { + if matches!(self.mode, EditorMode::SingleLine { .. }) { + cx.propagate(); + return; + } + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.move_with(|map, selection| { + selection.collapse_to( + movement::end_of_paragraph(map, selection.head(), 1), + SelectionGoal::None, + ) + }); + }) + } + + pub fn select_to_start_of_paragraph( + &mut self, + _: &SelectToStartOfParagraph, + window: &mut Window, + cx: &mut Context, + ) { + if matches!(self.mode, EditorMode::SingleLine { .. }) { + cx.propagate(); + return; + } + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.move_heads_with(|map, head, _| { + ( + movement::start_of_paragraph(map, head, 1), + SelectionGoal::None, + ) + }); + }) + } + + pub fn select_to_end_of_paragraph( + &mut self, + _: &SelectToEndOfParagraph, + window: &mut Window, + cx: &mut Context, + ) { + if matches!(self.mode, EditorMode::SingleLine { .. }) { + cx.propagate(); + return; + } + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.move_heads_with(|map, head, _| { + ( + movement::end_of_paragraph(map, head, 1), + SelectionGoal::None, + ) + }); + }) + } + + pub fn move_to_start_of_excerpt( + &mut self, + _: &MoveToStartOfExcerpt, + window: &mut Window, + cx: &mut Context, + ) { + if matches!(self.mode, EditorMode::SingleLine { .. }) { + cx.propagate(); + return; + } + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.move_with(|map, selection| { + selection.collapse_to( + movement::start_of_excerpt( + map, + selection.head(), + workspace::searchable::Direction::Prev, + ), + SelectionGoal::None, + ) + }); + }) + } + + pub fn move_to_start_of_next_excerpt( + &mut self, + _: &MoveToStartOfNextExcerpt, + window: &mut Window, + cx: &mut Context, + ) { + if matches!(self.mode, EditorMode::SingleLine { .. }) { + cx.propagate(); + return; + } + + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.move_with(|map, selection| { + selection.collapse_to( + movement::start_of_excerpt( + map, + selection.head(), + workspace::searchable::Direction::Next, + ), + SelectionGoal::None, + ) + }); + }) + } + + pub fn move_to_end_of_excerpt( + &mut self, + _: &MoveToEndOfExcerpt, + window: &mut Window, + cx: &mut Context, + ) { + if matches!(self.mode, EditorMode::SingleLine { .. }) { + cx.propagate(); + return; + } + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.move_with(|map, selection| { + selection.collapse_to( + movement::end_of_excerpt( + map, + selection.head(), + workspace::searchable::Direction::Next, + ), + SelectionGoal::None, + ) + }); + }) + } + + pub fn move_to_end_of_previous_excerpt( + &mut self, + _: &MoveToEndOfPreviousExcerpt, + window: &mut Window, + cx: &mut Context, + ) { + if matches!(self.mode, EditorMode::SingleLine { .. }) { + cx.propagate(); + return; + } + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.move_with(|map, selection| { + selection.collapse_to( + movement::end_of_excerpt( + map, + selection.head(), + workspace::searchable::Direction::Prev, + ), + SelectionGoal::None, + ) + }); + }) + } + + pub fn select_to_start_of_excerpt( + &mut self, + _: &SelectToStartOfExcerpt, + window: &mut Window, + cx: &mut Context, + ) { + if matches!(self.mode, EditorMode::SingleLine { .. }) { + cx.propagate(); + return; + } + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.move_heads_with(|map, head, _| { + ( + movement::start_of_excerpt(map, head, workspace::searchable::Direction::Prev), + SelectionGoal::None, + ) + }); + }) + } + + pub fn select_to_start_of_next_excerpt( + &mut self, + _: &SelectToStartOfNextExcerpt, + window: &mut Window, + cx: &mut Context, + ) { + if matches!(self.mode, EditorMode::SingleLine { .. }) { + cx.propagate(); + return; + } + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.move_heads_with(|map, head, _| { + ( + movement::start_of_excerpt(map, head, workspace::searchable::Direction::Next), + SelectionGoal::None, + ) + }); + }) + } + + pub fn select_to_end_of_excerpt( + &mut self, + _: &SelectToEndOfExcerpt, + window: &mut Window, + cx: &mut Context, + ) { + if matches!(self.mode, EditorMode::SingleLine { .. }) { + cx.propagate(); + return; + } + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.move_heads_with(|map, head, _| { + ( + movement::end_of_excerpt(map, head, workspace::searchable::Direction::Next), + SelectionGoal::None, + ) + }); + }) + } + + pub fn select_to_end_of_previous_excerpt( + &mut self, + _: &SelectToEndOfPreviousExcerpt, + window: &mut Window, + cx: &mut Context, + ) { + if matches!(self.mode, EditorMode::SingleLine { .. }) { + cx.propagate(); + return; + } + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.move_heads_with(|map, head, _| { + ( + movement::end_of_excerpt(map, head, workspace::searchable::Direction::Prev), + SelectionGoal::None, + ) + }); + }) + } + + pub fn move_to_beginning( + &mut self, + _: &MoveToBeginning, + window: &mut Window, + cx: &mut Context, + ) { + if matches!(self.mode, EditorMode::SingleLine { .. }) { + cx.propagate(); + return; + } + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.select_ranges(vec![0..0]); + }); + } + + pub fn select_to_beginning( + &mut self, + _: &SelectToBeginning, + window: &mut Window, + cx: &mut Context, + ) { + let mut selection = self.selections.last::(cx); + selection.set_head(Point::zero(), SelectionGoal::None); + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.select(vec![selection]); + }); + } + + pub fn move_to_end(&mut self, _: &MoveToEnd, window: &mut Window, cx: &mut Context) { + if matches!(self.mode, EditorMode::SingleLine { .. }) { + cx.propagate(); + return; + } + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + let cursor = self.buffer.read(cx).read(cx).len(); + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.select_ranges(vec![cursor..cursor]) + }); + } + + pub fn set_nav_history(&mut self, nav_history: Option) { + self.nav_history = nav_history; + } + + pub fn nav_history(&self) -> Option<&ItemNavHistory> { + self.nav_history.as_ref() + } + + pub fn create_nav_history_entry(&mut self, cx: &mut Context) { + self.push_to_nav_history(self.selections.newest_anchor().head(), None, false, cx); + } + + fn push_to_nav_history( + &mut self, + cursor_anchor: Anchor, + new_position: Option, + is_deactivate: bool, + cx: &mut Context, + ) { + if let Some(nav_history) = self.nav_history.as_mut() { + let buffer = self.buffer.read(cx).read(cx); + let cursor_position = cursor_anchor.to_point(&buffer); + let scroll_state = self.scroll_manager.anchor(); + let scroll_top_row = scroll_state.top_row(&buffer); + drop(buffer); + + if let Some(new_position) = new_position { + let row_delta = (new_position.row as i64 - cursor_position.row as i64).abs(); + if row_delta < MIN_NAVIGATION_HISTORY_ROW_DELTA { + return; + } + } + + nav_history.push( + Some(NavigationData { + cursor_anchor, + cursor_position, + scroll_anchor: scroll_state, + scroll_top_row, + }), + cx, + ); + cx.emit(EditorEvent::PushedToNavHistory { + anchor: cursor_anchor, + is_deactivate, + }) + } + } + + pub fn select_to_end(&mut self, _: &SelectToEnd, window: &mut Window, cx: &mut Context) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + let buffer = self.buffer.read(cx).snapshot(cx); + let mut selection = self.selections.first::(cx); + selection.set_head(buffer.len(), SelectionGoal::None); + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.select(vec![selection]); + }); + } + + pub fn select_all(&mut self, _: &SelectAll, window: &mut Window, cx: &mut Context) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + let end = self.buffer.read(cx).read(cx).len(); + self.change_selections(None, window, cx, |s| { + s.select_ranges(vec![0..end]); + }); + } + + pub fn select_line(&mut self, _: &SelectLine, window: &mut Window, cx: &mut Context) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let mut selections = self.selections.all::(cx); + let max_point = display_map.buffer_snapshot.max_point(); + for selection in &mut selections { + let rows = selection.spanned_rows(true, &display_map); + selection.start = Point::new(rows.start.0, 0); + selection.end = cmp::min(max_point, Point::new(rows.end.0, 0)); + selection.reversed = false; + } + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.select(selections); + }); + } + + pub fn split_selection_into_lines( + &mut self, + _: &SplitSelectionIntoLines, + window: &mut Window, + cx: &mut Context, + ) { + let selections = self + .selections + .all::(cx) + .into_iter() + .map(|selection| selection.start..selection.end) + .collect::>(); + self.unfold_ranges(&selections, true, true, cx); + + let mut new_selection_ranges = Vec::new(); + { + let buffer = self.buffer.read(cx).read(cx); + for selection in selections { + for row in selection.start.row..selection.end.row { + let cursor = Point::new(row, buffer.line_len(MultiBufferRow(row))); + new_selection_ranges.push(cursor..cursor); + } + + let is_multiline_selection = selection.start.row != selection.end.row; + // Don't insert last one if it's a multi-line selection ending at the start of a line, + // so this action feels more ergonomic when paired with other selection operations + let should_skip_last = is_multiline_selection && selection.end.column == 0; + if !should_skip_last { + new_selection_ranges.push(selection.end..selection.end); + } + } + } + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.select_ranges(new_selection_ranges); + }); + } + + pub fn add_selection_above( + &mut self, + _: &AddSelectionAbove, + window: &mut Window, + cx: &mut Context, + ) { + self.add_selection(true, window, cx); + } + + pub fn add_selection_below( + &mut self, + _: &AddSelectionBelow, + window: &mut Window, + cx: &mut Context, + ) { + self.add_selection(false, window, cx); + } + + fn add_selection(&mut self, above: bool, window: &mut Window, cx: &mut Context) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let mut selections = self.selections.all::(cx); + let text_layout_details = self.text_layout_details(window); + let mut state = self.add_selections_state.take().unwrap_or_else(|| { + let oldest_selection = selections.iter().min_by_key(|s| s.id).unwrap().clone(); + let range = oldest_selection.display_range(&display_map).sorted(); + + let start_x = display_map.x_for_display_point(range.start, &text_layout_details); + let end_x = display_map.x_for_display_point(range.end, &text_layout_details); + let positions = start_x.min(end_x)..start_x.max(end_x); + + selections.clear(); + let mut stack = Vec::new(); + for row in range.start.row().0..=range.end.row().0 { + if let Some(selection) = self.selections.build_columnar_selection( + &display_map, + DisplayRow(row), + &positions, + oldest_selection.reversed, + &text_layout_details, + ) { + stack.push(selection.id); + selections.push(selection); + } + } + + if above { + stack.reverse(); + } + + AddSelectionsState { above, stack } + }); + + let last_added_selection = *state.stack.last().unwrap(); + let mut new_selections = Vec::new(); + if above == state.above { + let end_row = if above { + DisplayRow(0) + } else { + display_map.max_point().row() + }; + + 'outer: for selection in selections { + if selection.id == last_added_selection { + let range = selection.display_range(&display_map).sorted(); + debug_assert_eq!(range.start.row(), range.end.row()); + let mut row = range.start.row(); + let positions = + if let SelectionGoal::HorizontalRange { start, end } = selection.goal { + px(start)..px(end) + } else { + let start_x = + display_map.x_for_display_point(range.start, &text_layout_details); + let end_x = + display_map.x_for_display_point(range.end, &text_layout_details); + start_x.min(end_x)..start_x.max(end_x) + }; + + while row != end_row { + if above { + row.0 -= 1; + } else { + row.0 += 1; + } + + if let Some(new_selection) = self.selections.build_columnar_selection( + &display_map, + row, + &positions, + selection.reversed, + &text_layout_details, + ) { + state.stack.push(new_selection.id); + if above { + new_selections.push(new_selection); + new_selections.push(selection); + } else { + new_selections.push(selection); + new_selections.push(new_selection); + } + + continue 'outer; + } + } + } + + new_selections.push(selection); + } + } else { + new_selections = selections; + new_selections.retain(|s| s.id != last_added_selection); + state.stack.pop(); + } + + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.select(new_selections); + }); + if state.stack.len() > 1 { + self.add_selections_state = Some(state); + } + } + + pub fn select_next_match_internal( + &mut self, + display_map: &DisplaySnapshot, + replace_newest: bool, + autoscroll: Option, + window: &mut Window, + cx: &mut Context, + ) -> Result<()> { + fn select_next_match_ranges( + this: &mut Editor, + range: Range, + reversed: bool, + replace_newest: bool, + auto_scroll: Option, + window: &mut Window, + cx: &mut Context, + ) { + this.unfold_ranges(&[range.clone()], false, auto_scroll.is_some(), cx); + this.change_selections(auto_scroll, window, cx, |s| { + if replace_newest { + s.delete(s.newest_anchor().id); + } + if reversed { + s.insert_range(range.end..range.start); + } else { + s.insert_range(range); + } + }); + } + + let buffer = &display_map.buffer_snapshot; + let mut selections = self.selections.all::(cx); + if let Some(mut select_next_state) = self.select_next_state.take() { + let query = &select_next_state.query; + if !select_next_state.done { + let first_selection = selections.iter().min_by_key(|s| s.id).unwrap(); + let last_selection = selections.iter().max_by_key(|s| s.id).unwrap(); + let mut next_selected_range = None; + + let bytes_after_last_selection = + buffer.bytes_in_range(last_selection.end..buffer.len()); + let bytes_before_first_selection = buffer.bytes_in_range(0..first_selection.start); + let query_matches = query + .stream_find_iter(bytes_after_last_selection) + .map(|result| (last_selection.end, result)) + .chain( + query + .stream_find_iter(bytes_before_first_selection) + .map(|result| (0, result)), + ); + + for (start_offset, query_match) in query_matches { + let query_match = query_match.unwrap(); // can only fail due to I/O + let offset_range = + start_offset + query_match.start()..start_offset + query_match.end(); + let display_range = offset_range.start.to_display_point(display_map) + ..offset_range.end.to_display_point(display_map); + + if !select_next_state.wordwise + || (!movement::is_inside_word(display_map, display_range.start) + && !movement::is_inside_word(display_map, display_range.end)) + { + // TODO: This is n^2, because we might check all the selections + if !selections + .iter() + .any(|selection| selection.range().overlaps(&offset_range)) + { + next_selected_range = Some(offset_range); + break; + } + } + } + + if let Some(next_selected_range) = next_selected_range { + select_next_match_ranges( + self, + next_selected_range, + last_selection.reversed, + replace_newest, + autoscroll, + window, + cx, + ); + } else { + select_next_state.done = true; + } + } + + self.select_next_state = Some(select_next_state); + } else { + let mut only_carets = true; + let mut same_text_selected = true; + let mut selected_text = None; + + let mut selections_iter = selections.iter().peekable(); + while let Some(selection) = selections_iter.next() { + if selection.start != selection.end { + only_carets = false; + } + + if same_text_selected { + if selected_text.is_none() { + selected_text = + Some(buffer.text_for_range(selection.range()).collect::()); + } + + if let Some(next_selection) = selections_iter.peek() { + if next_selection.range().len() == selection.range().len() { + let next_selected_text = buffer + .text_for_range(next_selection.range()) + .collect::(); + if Some(next_selected_text) != selected_text { + same_text_selected = false; + selected_text = None; + } + } else { + same_text_selected = false; + selected_text = None; + } + } + } + } + + if only_carets { + for selection in &mut selections { + let word_range = movement::surrounding_word( + display_map, + selection.start.to_display_point(display_map), + ); + selection.start = word_range.start.to_offset(display_map, Bias::Left); + selection.end = word_range.end.to_offset(display_map, Bias::Left); + selection.goal = SelectionGoal::None; + selection.reversed = false; + select_next_match_ranges( + self, + selection.start..selection.end, + selection.reversed, + replace_newest, + autoscroll, + window, + cx, + ); + } + + if selections.len() == 1 { + let selection = selections + .last() + .expect("ensured that there's only one selection"); + let query = buffer + .text_for_range(selection.start..selection.end) + .collect::(); + let is_empty = query.is_empty(); + let select_state = SelectNextState { + query: AhoCorasick::new(&[query])?, + wordwise: true, + done: is_empty, + }; + self.select_next_state = Some(select_state); + } else { + self.select_next_state = None; + } + } else if let Some(selected_text) = selected_text { + self.select_next_state = Some(SelectNextState { + query: AhoCorasick::new(&[selected_text])?, + wordwise: false, + done: false, + }); + self.select_next_match_internal( + display_map, + replace_newest, + autoscroll, + window, + cx, + )?; + } + } + Ok(()) + } + + pub fn select_all_matches( + &mut self, + _action: &SelectAllMatches, + window: &mut Window, + cx: &mut Context, + ) -> Result<()> { + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + + self.push_to_selection_history(); + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + + self.select_next_match_internal(&display_map, false, None, window, cx)?; + let Some(select_next_state) = self.select_next_state.as_mut() else { + return Ok(()); + }; + if select_next_state.done { + return Ok(()); + } + + let mut new_selections = Vec::new(); + + let reversed = self.selections.oldest::(cx).reversed; + let buffer = &display_map.buffer_snapshot; + let query_matches = select_next_state + .query + .stream_find_iter(buffer.bytes_in_range(0..buffer.len())); + + for query_match in query_matches.into_iter() { + let query_match = query_match.context("query match for select all action")?; // can only fail due to I/O + let offset_range = if reversed { + query_match.end()..query_match.start() + } else { + query_match.start()..query_match.end() + }; + let display_range = offset_range.start.to_display_point(&display_map) + ..offset_range.end.to_display_point(&display_map); + + if !select_next_state.wordwise + || (!movement::is_inside_word(&display_map, display_range.start) + && !movement::is_inside_word(&display_map, display_range.end)) + { + new_selections.push(offset_range.start..offset_range.end); + } + } + + select_next_state.done = true; + self.unfold_ranges(&new_selections.clone(), false, false, cx); + self.change_selections(None, window, cx, |selections| { + selections.select_ranges(new_selections) + }); + + Ok(()) + } + + pub fn select_next( + &mut self, + action: &SelectNext, + window: &mut Window, + cx: &mut Context, + ) -> Result<()> { + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + self.push_to_selection_history(); + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + self.select_next_match_internal( + &display_map, + action.replace_newest, + Some(Autoscroll::newest()), + window, + cx, + )?; + Ok(()) + } + + pub fn select_previous( + &mut self, + action: &SelectPrevious, + window: &mut Window, + cx: &mut Context, + ) -> Result<()> { + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + self.push_to_selection_history(); + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let buffer = &display_map.buffer_snapshot; + let mut selections = self.selections.all::(cx); + if let Some(mut select_prev_state) = self.select_prev_state.take() { + let query = &select_prev_state.query; + if !select_prev_state.done { + let first_selection = selections.iter().min_by_key(|s| s.id).unwrap(); + let last_selection = selections.iter().max_by_key(|s| s.id).unwrap(); + let mut next_selected_range = None; + // When we're iterating matches backwards, the oldest match will actually be the furthest one in the buffer. + let bytes_before_last_selection = + buffer.reversed_bytes_in_range(0..last_selection.start); + let bytes_after_first_selection = + buffer.reversed_bytes_in_range(first_selection.end..buffer.len()); + let query_matches = query + .stream_find_iter(bytes_before_last_selection) + .map(|result| (last_selection.start, result)) + .chain( + query + .stream_find_iter(bytes_after_first_selection) + .map(|result| (buffer.len(), result)), + ); + for (end_offset, query_match) in query_matches { + let query_match = query_match.unwrap(); // can only fail due to I/O + let offset_range = + end_offset - query_match.end()..end_offset - query_match.start(); + let display_range = offset_range.start.to_display_point(&display_map) + ..offset_range.end.to_display_point(&display_map); + + if !select_prev_state.wordwise + || (!movement::is_inside_word(&display_map, display_range.start) + && !movement::is_inside_word(&display_map, display_range.end)) + { + next_selected_range = Some(offset_range); + break; + } + } + + if let Some(next_selected_range) = next_selected_range { + self.unfold_ranges(&[next_selected_range.clone()], false, true, cx); + self.change_selections(Some(Autoscroll::newest()), window, cx, |s| { + if action.replace_newest { + s.delete(s.newest_anchor().id); + } + if last_selection.reversed { + s.insert_range(next_selected_range.end..next_selected_range.start); + } else { + s.insert_range(next_selected_range); + } + }); + } else { + select_prev_state.done = true; + } + } + + self.select_prev_state = Some(select_prev_state); + } else { + let mut only_carets = true; + let mut same_text_selected = true; + let mut selected_text = None; + + let mut selections_iter = selections.iter().peekable(); + while let Some(selection) = selections_iter.next() { + if selection.start != selection.end { + only_carets = false; + } + + if same_text_selected { + if selected_text.is_none() { + selected_text = + Some(buffer.text_for_range(selection.range()).collect::()); + } + + if let Some(next_selection) = selections_iter.peek() { + if next_selection.range().len() == selection.range().len() { + let next_selected_text = buffer + .text_for_range(next_selection.range()) + .collect::(); + if Some(next_selected_text) != selected_text { + same_text_selected = false; + selected_text = None; + } + } else { + same_text_selected = false; + selected_text = None; + } + } + } + } + + if only_carets { + for selection in &mut selections { + let word_range = movement::surrounding_word( + &display_map, + selection.start.to_display_point(&display_map), + ); + selection.start = word_range.start.to_offset(&display_map, Bias::Left); + selection.end = word_range.end.to_offset(&display_map, Bias::Left); + selection.goal = SelectionGoal::None; + selection.reversed = false; + } + if selections.len() == 1 { + let selection = selections + .last() + .expect("ensured that there's only one selection"); + let query = buffer + .text_for_range(selection.start..selection.end) + .collect::(); + let is_empty = query.is_empty(); + let select_state = SelectNextState { + query: AhoCorasick::new(&[query.chars().rev().collect::()])?, + wordwise: true, + done: is_empty, + }; + self.select_prev_state = Some(select_state); + } else { + self.select_prev_state = None; + } + + self.unfold_ranges( + &selections.iter().map(|s| s.range()).collect::>(), + false, + true, + cx, + ); + self.change_selections(Some(Autoscroll::newest()), window, cx, |s| { + s.select(selections); + }); + } else if let Some(selected_text) = selected_text { + self.select_prev_state = Some(SelectNextState { + query: AhoCorasick::new(&[selected_text.chars().rev().collect::()])?, + wordwise: false, + done: false, + }); + self.select_previous(action, window, cx)?; + } + } + Ok(()) + } + + pub fn find_next_match( + &mut self, + _: &FindNextMatch, + window: &mut Window, + cx: &mut Context, + ) -> Result<()> { + let selections = self.selections.disjoint_anchors(); + match selections.first() { + Some(first) if selections.len() >= 2 => { + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.select_ranges([first.range()]); + }); + } + _ => self.select_next( + &SelectNext { + replace_newest: true, + }, + window, + cx, + )?, + } + Ok(()) + } + + pub fn find_previous_match( + &mut self, + _: &FindPreviousMatch, + window: &mut Window, + cx: &mut Context, + ) -> Result<()> { + let selections = self.selections.disjoint_anchors(); + match selections.last() { + Some(last) if selections.len() >= 2 => { + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.select_ranges([last.range()]); + }); + } + _ => self.select_previous( + &SelectPrevious { + replace_newest: true, + }, + window, + cx, + )?, + } + Ok(()) + } + + pub fn toggle_comments( + &mut self, + action: &ToggleComments, + window: &mut Window, + cx: &mut Context, + ) { + if self.read_only(cx) { + return; + } + self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); + let text_layout_details = &self.text_layout_details(window); + self.transact(window, cx, |this, window, cx| { + let mut selections = this.selections.all::(cx); + let mut edits = Vec::new(); + let mut selection_edit_ranges = Vec::new(); + let mut last_toggled_row = None; + let snapshot = this.buffer.read(cx).read(cx); + let empty_str: Arc = Arc::default(); + let mut suffixes_inserted = Vec::new(); + let ignore_indent = action.ignore_indent; + + fn comment_prefix_range( + snapshot: &MultiBufferSnapshot, + row: MultiBufferRow, + comment_prefix: &str, + comment_prefix_whitespace: &str, + ignore_indent: bool, + ) -> Range { + let indent_size = if ignore_indent { + 0 + } else { + snapshot.indent_size_for_line(row).len + }; + + let start = Point::new(row.0, indent_size); + + let mut line_bytes = snapshot + .bytes_in_range(start..snapshot.max_point()) + .flatten() + .copied(); + + // If this line currently begins with the line comment prefix, then record + // the range containing the prefix. + if line_bytes + .by_ref() + .take(comment_prefix.len()) + .eq(comment_prefix.bytes()) + { + // Include any whitespace that matches the comment prefix. + let matching_whitespace_len = line_bytes + .zip(comment_prefix_whitespace.bytes()) + .take_while(|(a, b)| a == b) + .count() as u32; + let end = Point::new( + start.row, + start.column + comment_prefix.len() as u32 + matching_whitespace_len, + ); + start..end + } else { + start..start + } + } + + fn comment_suffix_range( + snapshot: &MultiBufferSnapshot, + row: MultiBufferRow, + comment_suffix: &str, + comment_suffix_has_leading_space: bool, + ) -> Range { + let end = Point::new(row.0, snapshot.line_len(row)); + let suffix_start_column = end.column.saturating_sub(comment_suffix.len() as u32); + + let mut line_end_bytes = snapshot + .bytes_in_range(Point::new(end.row, suffix_start_column.saturating_sub(1))..end) + .flatten() + .copied(); + + let leading_space_len = if suffix_start_column > 0 + && line_end_bytes.next() == Some(b' ') + && comment_suffix_has_leading_space + { + 1 + } else { + 0 + }; + + // If this line currently begins with the line comment prefix, then record + // the range containing the prefix. + if line_end_bytes.by_ref().eq(comment_suffix.bytes()) { + let start = Point::new(end.row, suffix_start_column - leading_space_len); + start..end + } else { + end..end + } + } + + // TODO: Handle selections that cross excerpts + for selection in &mut selections { + let start_column = snapshot + .indent_size_for_line(MultiBufferRow(selection.start.row)) + .len; + let language = if let Some(language) = + snapshot.language_scope_at(Point::new(selection.start.row, start_column)) + { + language + } else { + continue; + }; + + selection_edit_ranges.clear(); + + // If multiple selections contain a given row, avoid processing that + // row more than once. + let mut start_row = MultiBufferRow(selection.start.row); + if last_toggled_row == Some(start_row) { + start_row = start_row.next_row(); + } + let end_row = + if selection.end.row > selection.start.row && selection.end.column == 0 { + MultiBufferRow(selection.end.row - 1) + } else { + MultiBufferRow(selection.end.row) + }; + last_toggled_row = Some(end_row); + + if start_row > end_row { + continue; + } + + // If the language has line comments, toggle those. + let mut full_comment_prefixes = language.line_comment_prefixes().to_vec(); + + // If ignore_indent is set, trim spaces from the right side of all full_comment_prefixes + if ignore_indent { + full_comment_prefixes = full_comment_prefixes + .into_iter() + .map(|s| Arc::from(s.trim_end())) + .collect(); + } + + if !full_comment_prefixes.is_empty() { + let first_prefix = full_comment_prefixes + .first() + .expect("prefixes is non-empty"); + let prefix_trimmed_lengths = full_comment_prefixes + .iter() + .map(|p| p.trim_end_matches(' ').len()) + .collect::>(); + + let mut all_selection_lines_are_comments = true; + + for row in start_row.0..=end_row.0 { + let row = MultiBufferRow(row); + if start_row < end_row && snapshot.is_line_blank(row) { + continue; + } + + let prefix_range = full_comment_prefixes + .iter() + .zip(prefix_trimmed_lengths.iter().copied()) + .map(|(prefix, trimmed_prefix_len)| { + comment_prefix_range( + snapshot.deref(), + row, + &prefix[..trimmed_prefix_len], + &prefix[trimmed_prefix_len..], + ignore_indent, + ) + }) + .max_by_key(|range| range.end.column - range.start.column) + .expect("prefixes is non-empty"); + + if prefix_range.is_empty() { + all_selection_lines_are_comments = false; + } + + selection_edit_ranges.push(prefix_range); + } + + if all_selection_lines_are_comments { + edits.extend( + selection_edit_ranges + .iter() + .cloned() + .map(|range| (range, empty_str.clone())), + ); + } else { + let min_column = selection_edit_ranges + .iter() + .map(|range| range.start.column) + .min() + .unwrap_or(0); + edits.extend(selection_edit_ranges.iter().map(|range| { + let position = Point::new(range.start.row, min_column); + (position..position, first_prefix.clone()) + })); + } + } else if let Some((full_comment_prefix, comment_suffix)) = + language.block_comment_delimiters() + { + let comment_prefix = full_comment_prefix.trim_end_matches(' '); + let comment_prefix_whitespace = &full_comment_prefix[comment_prefix.len()..]; + let prefix_range = comment_prefix_range( + snapshot.deref(), + start_row, + comment_prefix, + comment_prefix_whitespace, + ignore_indent, + ); + let suffix_range = comment_suffix_range( + snapshot.deref(), + end_row, + comment_suffix.trim_start_matches(' '), + comment_suffix.starts_with(' '), + ); + + if prefix_range.is_empty() || suffix_range.is_empty() { + edits.push(( + prefix_range.start..prefix_range.start, + full_comment_prefix.clone(), + )); + edits.push((suffix_range.end..suffix_range.end, comment_suffix.clone())); + suffixes_inserted.push((end_row, comment_suffix.len())); + } else { + edits.push((prefix_range, empty_str.clone())); + edits.push((suffix_range, empty_str.clone())); + } + } else { + continue; + } + } + + drop(snapshot); + this.buffer.update(cx, |buffer, cx| { + buffer.edit(edits, None, cx); + }); + + // Adjust selections so that they end before any comment suffixes that + // were inserted. + let mut suffixes_inserted = suffixes_inserted.into_iter().peekable(); + let mut selections = this.selections.all::(cx); + let snapshot = this.buffer.read(cx).read(cx); + for selection in &mut selections { + while let Some((row, suffix_len)) = suffixes_inserted.peek().copied() { + match row.cmp(&MultiBufferRow(selection.end.row)) { + Ordering::Less => { + suffixes_inserted.next(); + continue; + } + Ordering::Greater => break, + Ordering::Equal => { + if selection.end.column == snapshot.line_len(row) { + if selection.is_empty() { + selection.start.column -= suffix_len as u32; + } + selection.end.column -= suffix_len as u32; + } + break; + } + } + } + } + + drop(snapshot); + this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.select(selections) + }); + + let selections = this.selections.all::(cx); + let selections_on_single_row = selections.windows(2).all(|selections| { + selections[0].start.row == selections[1].start.row + && selections[0].end.row == selections[1].end.row + && selections[0].start.row == selections[0].end.row + }); + let selections_selecting = selections + .iter() + .any(|selection| selection.start != selection.end); + let advance_downwards = action.advance_downwards + && selections_on_single_row + && !selections_selecting + && !matches!(this.mode, EditorMode::SingleLine { .. }); + + if advance_downwards { + let snapshot = this.buffer.read(cx).snapshot(cx); + + this.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.move_cursors_with(|display_snapshot, display_point, _| { + let mut point = display_point.to_point(display_snapshot); + point.row += 1; + point = snapshot.clip_point(point, Bias::Left); + let display_point = point.to_display_point(display_snapshot); + let goal = SelectionGoal::HorizontalPosition( + display_snapshot + .x_for_display_point(display_point, text_layout_details) + .into(), + ); + (display_point, goal) + }) + }); + } + }); + } + + pub fn select_enclosing_symbol( + &mut self, + _: &SelectEnclosingSymbol, + window: &mut Window, + cx: &mut Context, + ) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + + let buffer = self.buffer.read(cx).snapshot(cx); + let old_selections = self.selections.all::(cx).into_boxed_slice(); + + fn update_selection( + selection: &Selection, + buffer_snap: &MultiBufferSnapshot, + ) -> Option> { + let cursor = selection.head(); + let (_buffer_id, symbols) = buffer_snap.symbols_containing(cursor, None)?; + for symbol in symbols.iter().rev() { + let start = symbol.range.start.to_offset(buffer_snap); + let end = symbol.range.end.to_offset(buffer_snap); + let new_range = start..end; + if start < selection.start || end > selection.end { + return Some(Selection { + id: selection.id, + start: new_range.start, + end: new_range.end, + goal: SelectionGoal::None, + reversed: selection.reversed, + }); + } + } + None + } + + let mut selected_larger_symbol = false; + let new_selections = old_selections + .iter() + .map(|selection| match update_selection(selection, &buffer) { + Some(new_selection) => { + if new_selection.range() != selection.range() { + selected_larger_symbol = true; + } + new_selection + } + None => selection.clone(), + }) + .collect::>(); + + if selected_larger_symbol { + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.select(new_selections); + }); + } + } + + pub fn select_larger_syntax_node( + &mut self, + _: &SelectLargerSyntaxNode, + window: &mut Window, + cx: &mut Context, + ) { + let Some(visible_row_count) = self.visible_row_count() else { + return; + }; + let old_selections: Box<[_]> = self.selections.all::(cx).into(); + if old_selections.is_empty() { + return; + } + + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let buffer = self.buffer.read(cx).snapshot(cx); + + let mut selected_larger_node = false; + let mut new_selections = old_selections + .iter() + .map(|selection| { + let old_range = selection.start..selection.end; + + if let Some((node, _)) = buffer.syntax_ancestor(old_range.clone()) { + // manually select word at selection + if ["string_content", "inline"].contains(&node.kind()) { + let word_range = { + let display_point = buffer + .offset_to_point(old_range.start) + .to_display_point(&display_map); + let Range { start, end } = + movement::surrounding_word(&display_map, display_point); + start.to_point(&display_map).to_offset(&buffer) + ..end.to_point(&display_map).to_offset(&buffer) + }; + // ignore if word is already selected + if !word_range.is_empty() && old_range != word_range { + let last_word_range = { + let display_point = buffer + .offset_to_point(old_range.end) + .to_display_point(&display_map); + let Range { start, end } = + movement::surrounding_word(&display_map, display_point); + start.to_point(&display_map).to_offset(&buffer) + ..end.to_point(&display_map).to_offset(&buffer) + }; + // only select word if start and end point belongs to same word + if word_range == last_word_range { + selected_larger_node = true; + return Selection { + id: selection.id, + start: word_range.start, + end: word_range.end, + goal: SelectionGoal::None, + reversed: selection.reversed, + }; + } + } + } + } + + let mut new_range = old_range.clone(); + let mut new_node = None; + while let Some((node, containing_range)) = buffer.syntax_ancestor(new_range.clone()) + { + new_node = Some(node); + new_range = match containing_range { + MultiOrSingleBufferOffsetRange::Single(_) => break, + MultiOrSingleBufferOffsetRange::Multi(range) => range, + }; + if !display_map.intersects_fold(new_range.start) + && !display_map.intersects_fold(new_range.end) + { + break; + } + } + + if let Some(node) = new_node { + // Log the ancestor, to support using this action as a way to explore TreeSitter + // nodes. Parent and grandparent are also logged because this operation will not + // visit nodes that have the same range as their parent. + log::info!("Node: {node:?}"); + let parent = node.parent(); + log::info!("Parent: {parent:?}"); + let grandparent = parent.and_then(|x| x.parent()); + log::info!("Grandparent: {grandparent:?}"); + } + + selected_larger_node |= new_range != old_range; + Selection { + id: selection.id, + start: new_range.start, + end: new_range.end, + goal: SelectionGoal::None, + reversed: selection.reversed, + } + }) + .collect::>(); + + if !selected_larger_node { + return; // don't put this call in the history + } + + // scroll based on transformation done to the last selection created by the user + let (last_old, last_new) = old_selections + .last() + .zip(new_selections.last().cloned()) + .expect("old_selections isn't empty"); + + // revert selection + let is_selection_reversed = { + let should_newest_selection_be_reversed = last_old.start != last_new.start; + new_selections.last_mut().expect("checked above").reversed = + should_newest_selection_be_reversed; + should_newest_selection_be_reversed + }; + + if selected_larger_node { + self.select_syntax_node_history.disable_clearing = true; + self.change_selections(None, window, cx, |s| { + s.select(new_selections.clone()); + }); + self.select_syntax_node_history.disable_clearing = false; + } + + let start_row = last_new.start.to_display_point(&display_map).row().0; + let end_row = last_new.end.to_display_point(&display_map).row().0; + let selection_height = end_row - start_row + 1; + let scroll_margin_rows = self.vertical_scroll_margin() as u32; + + let fits_on_the_screen = visible_row_count >= selection_height + scroll_margin_rows * 2; + let scroll_behavior = if fits_on_the_screen { + self.request_autoscroll(Autoscroll::fit(), cx); + SelectSyntaxNodeScrollBehavior::FitSelection + } else if is_selection_reversed { + self.scroll_cursor_top(&ScrollCursorTop, window, cx); + SelectSyntaxNodeScrollBehavior::CursorTop + } else { + self.scroll_cursor_bottom(&ScrollCursorBottom, window, cx); + SelectSyntaxNodeScrollBehavior::CursorBottom + }; + + self.select_syntax_node_history.push(( + old_selections, + scroll_behavior, + is_selection_reversed, + )); + } + + pub fn select_smaller_syntax_node( + &mut self, + _: &SelectSmallerSyntaxNode, + window: &mut Window, + cx: &mut Context, + ) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + + if let Some((mut selections, scroll_behavior, is_selection_reversed)) = + self.select_syntax_node_history.pop() + { + if let Some(selection) = selections.last_mut() { + selection.reversed = is_selection_reversed; + } + + self.select_syntax_node_history.disable_clearing = true; + self.change_selections(None, window, cx, |s| { + s.select(selections.to_vec()); + }); + self.select_syntax_node_history.disable_clearing = false; + + match scroll_behavior { + SelectSyntaxNodeScrollBehavior::CursorTop => { + self.scroll_cursor_top(&ScrollCursorTop, window, cx); + } + SelectSyntaxNodeScrollBehavior::FitSelection => { + self.request_autoscroll(Autoscroll::fit(), cx); + } + SelectSyntaxNodeScrollBehavior::CursorBottom => { + self.scroll_cursor_bottom(&ScrollCursorBottom, window, cx); + } + } + } + } + + fn refresh_runnables(&mut self, window: &mut Window, cx: &mut Context) -> Task<()> { + if !EditorSettings::get_global(cx).gutter.runnables { + self.clear_tasks(); + return Task::ready(()); + } + let project = self.project.as_ref().map(Entity::downgrade); + let task_sources = self.lsp_task_sources(cx); + cx.spawn_in(window, async move |editor, cx| { + cx.background_executor().timer(UPDATE_DEBOUNCE).await; + let Some(project) = project.and_then(|p| p.upgrade()) else { + return; + }; + let Ok(display_snapshot) = editor.update(cx, |this, cx| { + this.display_map.update(cx, |map, cx| map.snapshot(cx)) + }) else { + return; + }; + + let hide_runnables = project + .update(cx, |project, cx| { + // Do not display any test indicators in non-dev server remote projects. + project.is_via_collab() && project.ssh_connection_string(cx).is_none() + }) + .unwrap_or(true); + if hide_runnables { + return; + } + let new_rows = + cx.background_spawn({ + let snapshot = display_snapshot.clone(); + async move { + Self::fetch_runnable_ranges(&snapshot, Anchor::min()..Anchor::max()) + } + }) + .await; + let Ok(lsp_tasks) = + cx.update(|_, cx| crate::lsp_tasks(project.clone(), &task_sources, None, cx)) + else { + return; + }; + let lsp_tasks = lsp_tasks.await; + + let Ok(mut lsp_tasks_by_rows) = cx.update(|_, cx| { + lsp_tasks + .into_iter() + .flat_map(|(kind, tasks)| { + tasks.into_iter().filter_map(move |(location, task)| { + Some((kind.clone(), location?, task)) + }) + }) + .fold(HashMap::default(), |mut acc, (kind, location, task)| { + let buffer = location.target.buffer; + let buffer_snapshot = buffer.read(cx).snapshot(); + let offset = display_snapshot.buffer_snapshot.excerpts().find_map( + |(excerpt_id, snapshot, _)| { + if snapshot.remote_id() == buffer_snapshot.remote_id() { + display_snapshot + .buffer_snapshot + .anchor_in_excerpt(excerpt_id, location.target.range.start) + } else { + None + } + }, + ); + if let Some(offset) = offset { + let task_buffer_range = + location.target.range.to_point(&buffer_snapshot); + let context_buffer_range = + task_buffer_range.to_offset(&buffer_snapshot); + let context_range = BufferOffset(context_buffer_range.start) + ..BufferOffset(context_buffer_range.end); + + acc.entry((buffer_snapshot.remote_id(), task_buffer_range.start.row)) + .or_insert_with(|| RunnableTasks { + templates: Vec::new(), + offset, + column: task_buffer_range.start.column, + extra_variables: HashMap::default(), + context_range, + }) + .templates + .push((kind, task.original_task().clone())); + } + + acc + }) + }) else { + return; + }; + + let rows = Self::runnable_rows(project, display_snapshot, new_rows, cx.clone()); + editor + .update(cx, |editor, _| { + editor.clear_tasks(); + for (key, mut value) in rows { + if let Some(lsp_tasks) = lsp_tasks_by_rows.remove(&key) { + value.templates.extend(lsp_tasks.templates); + } + + editor.insert_tasks(key, value); + } + for (key, value) in lsp_tasks_by_rows { + editor.insert_tasks(key, value); + } + }) + .ok(); + }) + } + fn fetch_runnable_ranges( + snapshot: &DisplaySnapshot, + range: Range, + ) -> Vec { + snapshot.buffer_snapshot.runnable_ranges(range).collect() + } + + fn runnable_rows( + project: Entity, + snapshot: DisplaySnapshot, + runnable_ranges: Vec, + mut cx: AsyncWindowContext, + ) -> Vec<((BufferId, BufferRow), RunnableTasks)> { + runnable_ranges + .into_iter() + .filter_map(|mut runnable| { + let tasks = cx + .update(|_, cx| Self::templates_with_tags(&project, &mut runnable.runnable, cx)) + .ok()?; + if tasks.is_empty() { + return None; + } + + let point = runnable.run_range.start.to_point(&snapshot.buffer_snapshot); + + let row = snapshot + .buffer_snapshot + .buffer_line_for_row(MultiBufferRow(point.row))? + .1 + .start + .row; + + let context_range = + BufferOffset(runnable.full_range.start)..BufferOffset(runnable.full_range.end); + Some(( + (runnable.buffer_id, row), + RunnableTasks { + templates: tasks, + offset: snapshot + .buffer_snapshot + .anchor_before(runnable.run_range.start), + context_range, + column: point.column, + extra_variables: runnable.extra_captures, + }, + )) + }) + .collect() + } + + fn templates_with_tags( + project: &Entity, + runnable: &mut Runnable, + cx: &mut App, + ) -> Vec<(TaskSourceKind, TaskTemplate)> { + let (inventory, worktree_id, file) = project.read_with(cx, |project, cx| { + let (worktree_id, file) = project + .buffer_for_id(runnable.buffer, cx) + .and_then(|buffer| buffer.read(cx).file()) + .map(|file| (file.worktree_id(cx), file.clone())) + .unzip(); + + ( + project.task_store().read(cx).task_inventory().cloned(), + worktree_id, + file, + ) + }); + + let mut templates_with_tags = mem::take(&mut runnable.tags) + .into_iter() + .flat_map(|RunnableTag(tag)| { + inventory + .as_ref() + .into_iter() + .flat_map(|inventory| { + inventory.read(cx).list_tasks( + file.clone(), + Some(runnable.language.clone()), + worktree_id, + cx, + ) + }) + .filter(move |(_, template)| { + template.tags.iter().any(|source_tag| source_tag == &tag) + }) + }) + .sorted_by_key(|(kind, _)| kind.to_owned()) + .collect::>(); + if let Some((leading_tag_source, _)) = templates_with_tags.first() { + // Strongest source wins; if we have worktree tag binding, prefer that to + // global and language bindings; + // if we have a global binding, prefer that to language binding. + let first_mismatch = templates_with_tags + .iter() + .position(|(tag_source, _)| tag_source != leading_tag_source); + if let Some(index) = first_mismatch { + templates_with_tags.truncate(index); + } + } + + templates_with_tags + } + + pub fn move_to_enclosing_bracket( + &mut self, + _: &MoveToEnclosingBracket, + window: &mut Window, + cx: &mut Context, + ) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.move_offsets_with(|snapshot, selection| { + let Some(enclosing_bracket_ranges) = + snapshot.enclosing_bracket_ranges(selection.start..selection.end) + else { + return; + }; + + let mut best_length = usize::MAX; + let mut best_inside = false; + let mut best_in_bracket_range = false; + let mut best_destination = None; + for (open, close) in enclosing_bracket_ranges { + let close = close.to_inclusive(); + let length = close.end() - open.start; + let inside = selection.start >= open.end && selection.end <= *close.start(); + let in_bracket_range = open.to_inclusive().contains(&selection.head()) + || close.contains(&selection.head()); + + // If best is next to a bracket and current isn't, skip + if !in_bracket_range && best_in_bracket_range { + continue; + } + + // Prefer smaller lengths unless best is inside and current isn't + if length > best_length && (best_inside || !inside) { + continue; + } + + best_length = length; + best_inside = inside; + best_in_bracket_range = in_bracket_range; + best_destination = Some( + if close.contains(&selection.start) && close.contains(&selection.end) { + if inside { open.end } else { open.start } + } else if inside { + *close.start() + } else { + *close.end() + }, + ); + } + + if let Some(destination) = best_destination { + selection.collapse_to(destination, SelectionGoal::None); + } + }) + }); + } + + pub fn undo_selection( + &mut self, + _: &UndoSelection, + window: &mut Window, + cx: &mut Context, + ) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + self.end_selection(window, cx); + self.selection_history.mode = SelectionHistoryMode::Undoing; + if let Some(entry) = self.selection_history.undo_stack.pop_back() { + self.change_selections(None, window, cx, |s| { + s.select_anchors(entry.selections.to_vec()) + }); + self.select_next_state = entry.select_next_state; + self.select_prev_state = entry.select_prev_state; + self.add_selections_state = entry.add_selections_state; + self.request_autoscroll(Autoscroll::newest(), cx); + } + self.selection_history.mode = SelectionHistoryMode::Normal; + } + + pub fn redo_selection( + &mut self, + _: &RedoSelection, + window: &mut Window, + cx: &mut Context, + ) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + self.end_selection(window, cx); + self.selection_history.mode = SelectionHistoryMode::Redoing; + if let Some(entry) = self.selection_history.redo_stack.pop_back() { + self.change_selections(None, window, cx, |s| { + s.select_anchors(entry.selections.to_vec()) + }); + self.select_next_state = entry.select_next_state; + self.select_prev_state = entry.select_prev_state; + self.add_selections_state = entry.add_selections_state; + self.request_autoscroll(Autoscroll::newest(), cx); + } + self.selection_history.mode = SelectionHistoryMode::Normal; + } + + pub fn expand_excerpts( + &mut self, + action: &ExpandExcerpts, + _: &mut Window, + cx: &mut Context, + ) { + self.expand_excerpts_for_direction(action.lines, ExpandExcerptDirection::UpAndDown, cx) + } + + pub fn expand_excerpts_down( + &mut self, + action: &ExpandExcerptsDown, + _: &mut Window, + cx: &mut Context, + ) { + self.expand_excerpts_for_direction(action.lines, ExpandExcerptDirection::Down, cx) + } + + pub fn expand_excerpts_up( + &mut self, + action: &ExpandExcerptsUp, + _: &mut Window, + cx: &mut Context, + ) { + self.expand_excerpts_for_direction(action.lines, ExpandExcerptDirection::Up, cx) + } + + pub fn expand_excerpts_for_direction( + &mut self, + lines: u32, + direction: ExpandExcerptDirection, + + cx: &mut Context, + ) { + let selections = self.selections.disjoint_anchors(); + + let lines = if lines == 0 { + EditorSettings::get_global(cx).expand_excerpt_lines + } else { + lines + }; + + self.buffer.update(cx, |buffer, cx| { + let snapshot = buffer.snapshot(cx); + let mut excerpt_ids = selections + .iter() + .flat_map(|selection| snapshot.excerpt_ids_for_range(selection.range())) + .collect::>(); + excerpt_ids.sort(); + excerpt_ids.dedup(); + buffer.expand_excerpts(excerpt_ids, lines, direction, cx) + }) + } + + pub fn expand_excerpt( + &mut self, + excerpt: ExcerptId, + direction: ExpandExcerptDirection, + window: &mut Window, + cx: &mut Context, + ) { + let current_scroll_position = self.scroll_position(cx); + let lines_to_expand = EditorSettings::get_global(cx).expand_excerpt_lines; + let mut should_scroll_up = false; + + if direction == ExpandExcerptDirection::Down { + let multi_buffer = self.buffer.read(cx); + let snapshot = multi_buffer.snapshot(cx); + if let Some(buffer_id) = snapshot.buffer_id_for_excerpt(excerpt) { + if let Some(buffer) = multi_buffer.buffer(buffer_id) { + if let Some(excerpt_range) = snapshot.buffer_range_for_excerpt(excerpt) { + let buffer_snapshot = buffer.read(cx).snapshot(); + let excerpt_end_row = + Point::from_anchor(&excerpt_range.end, &buffer_snapshot).row; + let last_row = buffer_snapshot.max_point().row; + let lines_below = last_row.saturating_sub(excerpt_end_row); + should_scroll_up = lines_below >= lines_to_expand; + } + } + } + } + + self.buffer.update(cx, |buffer, cx| { + buffer.expand_excerpts([excerpt], lines_to_expand, direction, cx) + }); + + if should_scroll_up { + let new_scroll_position = + current_scroll_position + gpui::Point::new(0.0, lines_to_expand as f32); + self.set_scroll_position(new_scroll_position, window, cx); + } + } + + pub fn go_to_singleton_buffer_point( + &mut self, + point: Point, + window: &mut Window, + cx: &mut Context, + ) { + self.go_to_singleton_buffer_range(point..point, window, cx); + } + + pub fn go_to_singleton_buffer_range( + &mut self, + range: Range, + window: &mut Window, + cx: &mut Context, + ) { + let multibuffer = self.buffer().read(cx); + let Some(buffer) = multibuffer.as_singleton() else { + return; + }; + let Some(start) = multibuffer.buffer_point_to_anchor(&buffer, range.start, cx) else { + return; + }; + let Some(end) = multibuffer.buffer_point_to_anchor(&buffer, range.end, cx) else { + return; + }; + self.change_selections(Some(Autoscroll::center()), window, cx, |s| { + s.select_anchor_ranges([start..end]) + }); + } + + pub fn go_to_diagnostic( + &mut self, + _: &GoToDiagnostic, + window: &mut Window, + cx: &mut Context, + ) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + self.go_to_diagnostic_impl(Direction::Next, window, cx) + } + + pub fn go_to_prev_diagnostic( + &mut self, + _: &GoToPreviousDiagnostic, + window: &mut Window, + cx: &mut Context, + ) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + self.go_to_diagnostic_impl(Direction::Prev, window, cx) + } + + pub fn go_to_diagnostic_impl( + &mut self, + direction: Direction, + window: &mut Window, + cx: &mut Context, + ) { + let buffer = self.buffer.read(cx).snapshot(cx); + let selection = self.selections.newest::(cx); + + let mut active_group_id = None; + if let ActiveDiagnostic::Group(active_group) = &self.active_diagnostics { + if active_group.active_range.start.to_offset(&buffer) == selection.start { + active_group_id = Some(active_group.group_id); + } + } + + fn filtered( + snapshot: EditorSnapshot, + diagnostics: impl Iterator>, + ) -> impl Iterator> { + diagnostics + .filter(|entry| entry.range.start != entry.range.end) + .filter(|entry| !entry.diagnostic.is_unnecessary) + .filter(move |entry| !snapshot.intersects_fold(entry.range.start)) + } + + let snapshot = self.snapshot(window, cx); + let before = filtered( + snapshot.clone(), + buffer + .diagnostics_in_range(0..selection.start) + .filter(|entry| entry.range.start <= selection.start), + ); + let after = filtered( + snapshot, + buffer + .diagnostics_in_range(selection.start..buffer.len()) + .filter(|entry| entry.range.start >= selection.start), + ); + + let mut found: Option> = None; + if direction == Direction::Prev { + 'outer: for prev_diagnostics in [before.collect::>(), after.collect::>()] + { + for diagnostic in prev_diagnostics.into_iter().rev() { + if diagnostic.range.start != selection.start + || active_group_id + .is_some_and(|active| diagnostic.diagnostic.group_id < active) + { + found = Some(diagnostic); + break 'outer; + } + } + } + } else { + for diagnostic in after.chain(before) { + if diagnostic.range.start != selection.start + || active_group_id.is_some_and(|active| diagnostic.diagnostic.group_id > active) + { + found = Some(diagnostic); + break; + } + } + } + let Some(next_diagnostic) = found else { + return; + }; + + let Some(buffer_id) = buffer.anchor_after(next_diagnostic.range.start).buffer_id else { + return; + }; + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.select_ranges(vec![ + next_diagnostic.range.start..next_diagnostic.range.start, + ]) + }); + self.activate_diagnostics(buffer_id, next_diagnostic, window, cx); + self.refresh_inline_completion(false, true, window, cx); + } + + fn go_to_next_hunk(&mut self, _: &GoToHunk, window: &mut Window, cx: &mut Context) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + let snapshot = self.snapshot(window, cx); + let selection = self.selections.newest::(cx); + self.go_to_hunk_before_or_after_position( + &snapshot, + selection.head(), + Direction::Next, + window, + cx, + ); + } + + pub fn go_to_hunk_before_or_after_position( + &mut self, + snapshot: &EditorSnapshot, + position: Point, + direction: Direction, + window: &mut Window, + cx: &mut Context, + ) { + let row = if direction == Direction::Next { + self.hunk_after_position(snapshot, position) + .map(|hunk| hunk.row_range.start) + } else { + self.hunk_before_position(snapshot, position) + }; + + if let Some(row) = row { + let destination = Point::new(row.0, 0); + let autoscroll = Autoscroll::center(); + + self.unfold_ranges(&[destination..destination], false, false, cx); + self.change_selections(Some(autoscroll), window, cx, |s| { + s.select_ranges([destination..destination]); + }); + } + } + + fn hunk_after_position( + &mut self, + snapshot: &EditorSnapshot, + position: Point, + ) -> Option { + snapshot + .buffer_snapshot + .diff_hunks_in_range(position..snapshot.buffer_snapshot.max_point()) + .find(|hunk| hunk.row_range.start.0 > position.row) + .or_else(|| { + snapshot + .buffer_snapshot + .diff_hunks_in_range(Point::zero()..position) + .find(|hunk| hunk.row_range.end.0 < position.row) + }) + } + + fn go_to_prev_hunk( + &mut self, + _: &GoToPreviousHunk, + window: &mut Window, + cx: &mut Context, + ) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction); + let snapshot = self.snapshot(window, cx); + let selection = self.selections.newest::(cx); + self.go_to_hunk_before_or_after_position( + &snapshot, + selection.head(), + Direction::Prev, + window, + cx, + ); + } + + fn hunk_before_position( + &mut self, + snapshot: &EditorSnapshot, + position: Point, + ) -> Option { + snapshot + .buffer_snapshot + .diff_hunk_before(position) + .or_else(|| snapshot.buffer_snapshot.diff_hunk_before(Point::MAX)) + } + + fn go_to_next_change( + &mut self, + _: &GoToNextChange, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(selections) = self + .change_list + .next_change(1, Direction::Next) + .map(|s| s.to_vec()) + { + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + let map = s.display_map(); + s.select_display_ranges(selections.iter().map(|a| { + let point = a.to_display_point(&map); + point..point + })) + }) + } + } + + fn go_to_previous_change( + &mut self, + _: &GoToPreviousChange, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(selections) = self + .change_list + .next_change(1, Direction::Prev) + .map(|s| s.to_vec()) + { + self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + let map = s.display_map(); + s.select_display_ranges(selections.iter().map(|a| { + let point = a.to_display_point(&map); + point..point + })) + }) + } + } + + fn go_to_line( + &mut self, + position: Anchor, + highlight_color: Option, + window: &mut Window, + cx: &mut Context, + ) { + let snapshot = self.snapshot(window, cx).display_snapshot; + let position = position.to_point(&snapshot.buffer_snapshot); + let start = snapshot + .buffer_snapshot + .clip_point(Point::new(position.row, 0), Bias::Left); + let end = start + Point::new(1, 0); + let start = snapshot.buffer_snapshot.anchor_before(start); + let end = snapshot.buffer_snapshot.anchor_before(end); + + self.highlight_rows::( + start..end, + highlight_color + .unwrap_or_else(|| cx.theme().colors().editor_highlighted_line_background), + Default::default(), + cx, + ); + self.request_autoscroll(Autoscroll::center().for_anchor(start), cx); + } + + pub fn go_to_definition( + &mut self, + _: &GoToDefinition, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + let definition = + self.go_to_definition_of_kind(GotoDefinitionKind::Symbol, false, window, cx); + let fallback_strategy = EditorSettings::get_global(cx).go_to_definition_fallback; + cx.spawn_in(window, async move |editor, cx| { + if definition.await? == Navigated::Yes { + return Ok(Navigated::Yes); + } + match fallback_strategy { + GoToDefinitionFallback::None => Ok(Navigated::No), + GoToDefinitionFallback::FindAllReferences => { + match editor.update_in(cx, |editor, window, cx| { + editor.find_all_references(&FindAllReferences, window, cx) + })? { + Some(references) => references.await, + None => Ok(Navigated::No), + } + } + } + }) + } + + pub fn go_to_declaration( + &mut self, + _: &GoToDeclaration, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + self.go_to_definition_of_kind(GotoDefinitionKind::Declaration, false, window, cx) + } + + pub fn go_to_declaration_split( + &mut self, + _: &GoToDeclaration, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + self.go_to_definition_of_kind(GotoDefinitionKind::Declaration, true, window, cx) + } + + pub fn go_to_implementation( + &mut self, + _: &GoToImplementation, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + self.go_to_definition_of_kind(GotoDefinitionKind::Implementation, false, window, cx) + } + + pub fn go_to_implementation_split( + &mut self, + _: &GoToImplementationSplit, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + self.go_to_definition_of_kind(GotoDefinitionKind::Implementation, true, window, cx) + } + + pub fn go_to_type_definition( + &mut self, + _: &GoToTypeDefinition, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + self.go_to_definition_of_kind(GotoDefinitionKind::Type, false, window, cx) + } + + pub fn go_to_definition_split( + &mut self, + _: &GoToDefinitionSplit, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + self.go_to_definition_of_kind(GotoDefinitionKind::Symbol, true, window, cx) + } + + pub fn go_to_type_definition_split( + &mut self, + _: &GoToTypeDefinitionSplit, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + self.go_to_definition_of_kind(GotoDefinitionKind::Type, true, window, cx) + } + + fn go_to_definition_of_kind( + &mut self, + kind: GotoDefinitionKind, + split: bool, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + let Some(provider) = self.semantics_provider.clone() else { + return Task::ready(Ok(Navigated::No)); + }; + let head = self.selections.newest::(cx).head(); + let buffer = self.buffer.read(cx); + let (buffer, head) = if let Some(text_anchor) = buffer.text_anchor_for_position(head, cx) { + text_anchor + } else { + return Task::ready(Ok(Navigated::No)); + }; + + let Some(definitions) = provider.definitions(&buffer, head, kind, cx) else { + return Task::ready(Ok(Navigated::No)); + }; + + cx.spawn_in(window, async move |editor, cx| { + let definitions = definitions.await?; + let navigated = editor + .update_in(cx, |editor, window, cx| { + editor.navigate_to_hover_links( + Some(kind), + definitions + .into_iter() + .filter(|location| { + hover_links::exclude_link_to_position(&buffer, &head, location, cx) + }) + .map(HoverLink::Text) + .collect::>(), + split, + window, + cx, + ) + })? + .await?; + anyhow::Ok(navigated) + }) + } + + pub fn open_url(&mut self, _: &OpenUrl, window: &mut Window, cx: &mut Context) { + let selection = self.selections.newest_anchor(); + let head = selection.head(); + let tail = selection.tail(); + + let Some((buffer, start_position)) = + self.buffer.read(cx).text_anchor_for_position(head, cx) + else { + return; + }; + + let end_position = if head != tail { + let Some((_, pos)) = self.buffer.read(cx).text_anchor_for_position(tail, cx) else { + return; + }; + Some(pos) + } else { + None + }; + + let url_finder = cx.spawn_in(window, async move |editor, cx| { + let url = if let Some(end_pos) = end_position { + find_url_from_range(&buffer, start_position..end_pos, cx.clone()) + } else { + find_url(&buffer, start_position, cx.clone()).map(|(_, url)| url) + }; + + if let Some(url) = url { + editor.update(cx, |_, cx| { + cx.open_url(&url); + }) + } else { + Ok(()) + } + }); + + url_finder.detach(); + } + + pub fn open_selected_filename( + &mut self, + _: &OpenSelectedFilename, + window: &mut Window, + cx: &mut Context, + ) { + let Some(workspace) = self.workspace() else { + return; + }; + + let position = self.selections.newest_anchor().head(); + + let Some((buffer, buffer_position)) = + self.buffer.read(cx).text_anchor_for_position(position, cx) + else { + return; + }; + + let project = self.project.clone(); + + cx.spawn_in(window, async move |_, cx| { + let result = find_file(&buffer, project, buffer_position, cx).await; + + if let Some((_, path)) = result { + workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_resolved_path(path, window, cx) + })? + .await?; + } + anyhow::Ok(()) + }) + .detach(); + } + + pub(crate) fn navigate_to_hover_links( + &mut self, + kind: Option, + mut definitions: Vec, + split: bool, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + // If there is one definition, just open it directly + if definitions.len() == 1 { + let definition = definitions.pop().unwrap(); + + enum TargetTaskResult { + Location(Option), + AlreadyNavigated, + } + + let target_task = match definition { + HoverLink::Text(link) => { + Task::ready(anyhow::Ok(TargetTaskResult::Location(Some(link.target)))) + } + HoverLink::InlayHint(lsp_location, server_id) => { + let computation = + self.compute_target_location(lsp_location, server_id, window, cx); + cx.background_spawn(async move { + let location = computation.await?; + Ok(TargetTaskResult::Location(location)) + }) + } + HoverLink::Url(url) => { + cx.open_url(&url); + Task::ready(Ok(TargetTaskResult::AlreadyNavigated)) + } + HoverLink::File(path) => { + if let Some(workspace) = self.workspace() { + cx.spawn_in(window, async move |_, cx| { + workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_resolved_path(path, window, cx) + })? + .await + .map(|_| TargetTaskResult::AlreadyNavigated) + }) + } else { + Task::ready(Ok(TargetTaskResult::Location(None))) + } + } + }; + cx.spawn_in(window, async move |editor, cx| { + let target = match target_task.await.context("target resolution task")? { + TargetTaskResult::AlreadyNavigated => return Ok(Navigated::Yes), + TargetTaskResult::Location(None) => return Ok(Navigated::No), + TargetTaskResult::Location(Some(target)) => target, + }; + + editor.update_in(cx, |editor, window, cx| { + let Some(workspace) = editor.workspace() else { + return Navigated::No; + }; + let pane = workspace.read(cx).active_pane().clone(); + + let range = target.range.to_point(target.buffer.read(cx)); + let range = editor.range_for_match(&range); + let range = collapse_multiline_range(range); + + if !split + && Some(&target.buffer) == editor.buffer.read(cx).as_singleton().as_ref() + { + editor.go_to_singleton_buffer_range(range.clone(), window, cx); + } else { + window.defer(cx, move |window, cx| { + let target_editor: Entity = + workspace.update(cx, |workspace, cx| { + let pane = if split { + workspace.adjacent_pane(window, cx) + } else { + workspace.active_pane().clone() + }; + + workspace.open_project_item( + pane, + target.buffer.clone(), + true, + true, + window, + cx, + ) + }); + target_editor.update(cx, |target_editor, cx| { + // When selecting a definition in a different buffer, disable the nav history + // to avoid creating a history entry at the previous cursor location. + pane.update(cx, |pane, _| pane.disable_history()); + target_editor.go_to_singleton_buffer_range(range, window, cx); + pane.update(cx, |pane, _| pane.enable_history()); + }); + }); + } + Navigated::Yes + }) + }) + } else if !definitions.is_empty() { + cx.spawn_in(window, async move |editor, cx| { + let (title, location_tasks, workspace) = editor + .update_in(cx, |editor, window, cx| { + let tab_kind = match kind { + Some(GotoDefinitionKind::Implementation) => "Implementations", + _ => "Definitions", + }; + let title = definitions + .iter() + .find_map(|definition| match definition { + HoverLink::Text(link) => link.origin.as_ref().map(|origin| { + let buffer = origin.buffer.read(cx); + format!( + "{} for {}", + tab_kind, + buffer + .text_for_range(origin.range.clone()) + .collect::() + ) + }), + HoverLink::InlayHint(_, _) => None, + HoverLink::Url(_) => None, + HoverLink::File(_) => None, + }) + .unwrap_or(tab_kind.to_string()); + let location_tasks = definitions + .into_iter() + .map(|definition| match definition { + HoverLink::Text(link) => Task::ready(Ok(Some(link.target))), + HoverLink::InlayHint(lsp_location, server_id) => editor + .compute_target_location(lsp_location, server_id, window, cx), + HoverLink::Url(_) => Task::ready(Ok(None)), + HoverLink::File(_) => Task::ready(Ok(None)), + }) + .collect::>(); + (title, location_tasks, editor.workspace().clone()) + }) + .context("location tasks preparation")?; + + let locations = future::join_all(location_tasks) + .await + .into_iter() + .filter_map(|location| location.transpose()) + .collect::>() + .context("location tasks")?; + + let Some(workspace) = workspace else { + return Ok(Navigated::No); + }; + let opened = workspace + .update_in(cx, |workspace, window, cx| { + Self::open_locations_in_multibuffer( + workspace, + locations, + title, + split, + MultibufferSelectionMode::First, + window, + cx, + ) + }) + .ok(); + + anyhow::Ok(Navigated::from_bool(opened.is_some())) + }) + } else { + Task::ready(Ok(Navigated::No)) + } + } + + fn compute_target_location( + &self, + lsp_location: lsp::Location, + server_id: LanguageServerId, + window: &mut Window, + cx: &mut Context, + ) -> Task>> { + let Some(project) = self.project.clone() else { + return Task::ready(Ok(None)); + }; + + cx.spawn_in(window, async move |editor, cx| { + let location_task = editor.update(cx, |_, cx| { + project.update(cx, |project, cx| { + let language_server_name = project + .language_server_statuses(cx) + .find(|(id, _)| server_id == *id) + .map(|(_, status)| LanguageServerName::from(status.name.as_str())); + language_server_name.map(|language_server_name| { + project.open_local_buffer_via_lsp( + lsp_location.uri.clone(), + server_id, + language_server_name, + cx, + ) + }) + }) + })?; + let location = match location_task { + Some(task) => Some({ + let target_buffer_handle = task.await.context("open local buffer")?; + let range = target_buffer_handle.update(cx, |target_buffer, _| { + let target_start = target_buffer + .clip_point_utf16(point_from_lsp(lsp_location.range.start), Bias::Left); + let target_end = target_buffer + .clip_point_utf16(point_from_lsp(lsp_location.range.end), Bias::Left); + target_buffer.anchor_after(target_start) + ..target_buffer.anchor_before(target_end) + })?; + Location { + buffer: target_buffer_handle, + range, + } + }), + None => None, + }; + Ok(location) + }) + } + + pub fn find_all_references( + &mut self, + _: &FindAllReferences, + window: &mut Window, + cx: &mut Context, + ) -> Option>> { + let selection = self.selections.newest::(cx); + let multi_buffer = self.buffer.read(cx); + let head = selection.head(); + + let multi_buffer_snapshot = multi_buffer.snapshot(cx); + let head_anchor = multi_buffer_snapshot.anchor_at( + head, + if head < selection.tail() { + Bias::Right + } else { + Bias::Left + }, + ); + + match self + .find_all_references_task_sources + .binary_search_by(|anchor| anchor.cmp(&head_anchor, &multi_buffer_snapshot)) + { + Ok(_) => { + log::info!( + "Ignoring repeated FindAllReferences invocation with the position of already running task" + ); + return None; + } + Err(i) => { + self.find_all_references_task_sources.insert(i, head_anchor); + } + } + + let (buffer, head) = multi_buffer.text_anchor_for_position(head, cx)?; + let workspace = self.workspace()?; + let project = workspace.read(cx).project().clone(); + let references = project.update(cx, |project, cx| project.references(&buffer, head, cx)); + Some(cx.spawn_in(window, async move |editor, cx| { + let _cleanup = cx.on_drop(&editor, move |editor, _| { + if let Ok(i) = editor + .find_all_references_task_sources + .binary_search_by(|anchor| anchor.cmp(&head_anchor, &multi_buffer_snapshot)) + { + editor.find_all_references_task_sources.remove(i); + } + }); + + let locations = references.await?; + if locations.is_empty() { + return anyhow::Ok(Navigated::No); + } + + workspace.update_in(cx, |workspace, window, cx| { + let title = locations + .first() + .as_ref() + .map(|location| { + let buffer = location.buffer.read(cx); + format!( + "References to `{}`", + buffer + .text_for_range(location.range.clone()) + .collect::() + ) + }) + .unwrap(); + Self::open_locations_in_multibuffer( + workspace, + locations, + title, + false, + MultibufferSelectionMode::First, + window, + cx, + ); + Navigated::Yes + }) + })) + } + + /// Opens a multibuffer with the given project locations in it + pub fn open_locations_in_multibuffer( + workspace: &mut Workspace, + mut locations: Vec, + title: String, + split: bool, + multibuffer_selection_mode: MultibufferSelectionMode, + window: &mut Window, + cx: &mut Context, + ) { + // If there are multiple definitions, open them in a multibuffer + locations.sort_by_key(|location| location.buffer.read(cx).remote_id()); + let mut locations = locations.into_iter().peekable(); + let mut ranges: Vec> = Vec::new(); + let capability = workspace.project().read(cx).capability(); + + let excerpt_buffer = cx.new(|cx| { + let mut multibuffer = MultiBuffer::new(capability); + while let Some(location) = locations.next() { + let buffer = location.buffer.read(cx); + let mut ranges_for_buffer = Vec::new(); + let range = location.range.to_point(buffer); + ranges_for_buffer.push(range.clone()); + + while let Some(next_location) = locations.peek() { + if next_location.buffer == location.buffer { + ranges_for_buffer.push(next_location.range.to_point(buffer)); + locations.next(); + } else { + break; + } + } + + ranges_for_buffer.sort_by_key(|range| (range.start, Reverse(range.end))); + let (new_ranges, _) = multibuffer.set_excerpts_for_path( + PathKey::for_buffer(&location.buffer, cx), + location.buffer.clone(), + ranges_for_buffer, + DEFAULT_MULTIBUFFER_CONTEXT, + cx, + ); + ranges.extend(new_ranges) + } + + multibuffer.with_title(title) + }); + + let editor = cx.new(|cx| { + Editor::for_multibuffer( + excerpt_buffer, + Some(workspace.project().clone()), + window, + cx, + ) + }); + editor.update(cx, |editor, cx| { + match multibuffer_selection_mode { + MultibufferSelectionMode::First => { + if let Some(first_range) = ranges.first() { + editor.change_selections(None, window, cx, |selections| { + selections.clear_disjoint(); + selections.select_anchor_ranges(std::iter::once(first_range.clone())); + }); + } + editor.highlight_background::( + &ranges, + |theme| theme.editor_highlighted_line_background, + cx, + ); + } + MultibufferSelectionMode::All => { + editor.change_selections(None, window, cx, |selections| { + selections.clear_disjoint(); + selections.select_anchor_ranges(ranges); + }); + } + } + editor.register_buffers_with_language_servers(cx); + }); + + let item = Box::new(editor); + let item_id = item.item_id(); + + if split { + workspace.split_item(SplitDirection::Right, item.clone(), window, cx); + } else { + if PreviewTabsSettings::get_global(cx).enable_preview_from_code_navigation { + let (preview_item_id, preview_item_idx) = + workspace.active_pane().update(cx, |pane, _| { + (pane.preview_item_id(), pane.preview_item_idx()) + }); + + workspace.add_item_to_active_pane(item.clone(), preview_item_idx, true, window, cx); + + if let Some(preview_item_id) = preview_item_id { + workspace.active_pane().update(cx, |pane, cx| { + pane.remove_item(preview_item_id, false, false, window, cx); + }); + } + } else { + workspace.add_item_to_active_pane(item.clone(), None, true, window, cx); + } + } + workspace.active_pane().update(cx, |pane, cx| { + pane.set_preview_item_id(Some(item_id), cx); + }); + } + + pub fn rename( + &mut self, + _: &Rename, + window: &mut Window, + cx: &mut Context, + ) -> Option>> { + use language::ToOffset as _; + + let provider = self.semantics_provider.clone()?; + let selection = self.selections.newest_anchor().clone(); + let (cursor_buffer, cursor_buffer_position) = self + .buffer + .read(cx) + .text_anchor_for_position(selection.head(), cx)?; + let (tail_buffer, cursor_buffer_position_end) = self + .buffer + .read(cx) + .text_anchor_for_position(selection.tail(), cx)?; + if tail_buffer != cursor_buffer { + return None; + } + + let snapshot = cursor_buffer.read(cx).snapshot(); + let cursor_buffer_offset = cursor_buffer_position.to_offset(&snapshot); + let cursor_buffer_offset_end = cursor_buffer_position_end.to_offset(&snapshot); + let prepare_rename = provider + .range_for_rename(&cursor_buffer, cursor_buffer_position, cx) + .unwrap_or_else(|| Task::ready(Ok(None))); + drop(snapshot); + + Some(cx.spawn_in(window, async move |this, cx| { + let rename_range = if let Some(range) = prepare_rename.await? { + Some(range) + } else { + this.update(cx, |this, cx| { + let buffer = this.buffer.read(cx).snapshot(cx); + let mut buffer_highlights = this + .document_highlights_for_position(selection.head(), &buffer) + .filter(|highlight| { + highlight.start.excerpt_id == selection.head().excerpt_id + && highlight.end.excerpt_id == selection.head().excerpt_id + }); + buffer_highlights + .next() + .map(|highlight| highlight.start.text_anchor..highlight.end.text_anchor) + })? + }; + if let Some(rename_range) = rename_range { + this.update_in(cx, |this, window, cx| { + let snapshot = cursor_buffer.read(cx).snapshot(); + let rename_buffer_range = rename_range.to_offset(&snapshot); + let cursor_offset_in_rename_range = + cursor_buffer_offset.saturating_sub(rename_buffer_range.start); + let cursor_offset_in_rename_range_end = + cursor_buffer_offset_end.saturating_sub(rename_buffer_range.start); + + this.take_rename(false, window, cx); + let buffer = this.buffer.read(cx).read(cx); + let cursor_offset = selection.head().to_offset(&buffer); + let rename_start = cursor_offset.saturating_sub(cursor_offset_in_rename_range); + let rename_end = rename_start + rename_buffer_range.len(); + let range = buffer.anchor_before(rename_start)..buffer.anchor_after(rename_end); + let mut old_highlight_id = None; + let old_name: Arc = buffer + .chunks(rename_start..rename_end, true) + .map(|chunk| { + if old_highlight_id.is_none() { + old_highlight_id = chunk.syntax_highlight_id; + } + chunk.text + }) + .collect::() + .into(); + + drop(buffer); + + // Position the selection in the rename editor so that it matches the current selection. + this.show_local_selections = false; + let rename_editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.buffer.update(cx, |buffer, cx| { + buffer.edit([(0..0, old_name.clone())], None, cx) + }); + let rename_selection_range = match cursor_offset_in_rename_range + .cmp(&cursor_offset_in_rename_range_end) + { + Ordering::Equal => { + editor.select_all(&SelectAll, window, cx); + return editor; + } + Ordering::Less => { + cursor_offset_in_rename_range..cursor_offset_in_rename_range_end + } + Ordering::Greater => { + cursor_offset_in_rename_range_end..cursor_offset_in_rename_range + } + }; + if rename_selection_range.end > old_name.len() { + editor.select_all(&SelectAll, window, cx); + } else { + editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.select_ranges([rename_selection_range]); + }); + } + editor + }); + cx.subscribe(&rename_editor, |_, _, e: &EditorEvent, cx| { + if e == &EditorEvent::Focused { + cx.emit(EditorEvent::FocusedIn) + } + }) + .detach(); + + let write_highlights = + this.clear_background_highlights::(cx); + let read_highlights = + this.clear_background_highlights::(cx); + let ranges = write_highlights + .iter() + .flat_map(|(_, ranges)| ranges.iter()) + .chain(read_highlights.iter().flat_map(|(_, ranges)| ranges.iter())) + .cloned() + .collect(); + + this.highlight_text::( + ranges, + HighlightStyle { + fade_out: Some(0.6), + ..Default::default() + }, + cx, + ); + let rename_focus_handle = rename_editor.focus_handle(cx); + window.focus(&rename_focus_handle); + let block_id = this.insert_blocks( + [BlockProperties { + style: BlockStyle::Flex, + placement: BlockPlacement::Below(range.start), + height: Some(1), + render: Arc::new({ + let rename_editor = rename_editor.clone(); + move |cx: &mut BlockContext| { + let mut text_style = cx.editor_style.text.clone(); + if let Some(highlight_style) = old_highlight_id + .and_then(|h| h.style(&cx.editor_style.syntax)) + { + text_style = text_style.highlight(highlight_style); + } + div() + .block_mouse_down() + .pl(cx.anchor_x) + .child(EditorElement::new( + &rename_editor, + EditorStyle { + background: cx.theme().system().transparent, + local_player: cx.editor_style.local_player, + text: text_style, + scrollbar_width: cx.editor_style.scrollbar_width, + syntax: cx.editor_style.syntax.clone(), + status: cx.editor_style.status.clone(), + inlay_hints_style: HighlightStyle { + font_weight: Some(FontWeight::BOLD), + ..make_inlay_hints_style(cx.app) + }, + inline_completion_styles: make_suggestion_styles( + cx.app, + ), + ..EditorStyle::default() + }, + )) + .into_any_element() + } + }), + priority: 0, + }], + Some(Autoscroll::fit()), + cx, + )[0]; + this.pending_rename = Some(RenameState { + range, + old_name, + editor: rename_editor, + block_id, + }); + })?; + } + + Ok(()) + })) + } + + pub fn confirm_rename( + &mut self, + _: &ConfirmRename, + window: &mut Window, + cx: &mut Context, + ) -> Option>> { + let rename = self.take_rename(false, window, cx)?; + let workspace = self.workspace()?.downgrade(); + let (buffer, start) = self + .buffer + .read(cx) + .text_anchor_for_position(rename.range.start, cx)?; + let (end_buffer, _) = self + .buffer + .read(cx) + .text_anchor_for_position(rename.range.end, cx)?; + if buffer != end_buffer { + return None; + } + + let old_name = rename.old_name; + let new_name = rename.editor.read(cx).text(cx); + + let rename = self.semantics_provider.as_ref()?.perform_rename( + &buffer, + start, + new_name.clone(), + cx, + )?; + + Some(cx.spawn_in(window, async move |editor, cx| { + let project_transaction = rename.await?; + Self::open_project_transaction( + &editor, + workspace, + project_transaction, + format!("Rename: {} → {}", old_name, new_name), + cx, + ) + .await?; + + editor.update(cx, |editor, cx| { + editor.refresh_document_highlights(cx); + })?; + Ok(()) + })) + } + + fn take_rename( + &mut self, + moving_cursor: bool, + window: &mut Window, + cx: &mut Context, + ) -> Option { + let rename = self.pending_rename.take()?; + if rename.editor.focus_handle(cx).is_focused(window) { + window.focus(&self.focus_handle); + } + + self.remove_blocks( + [rename.block_id].into_iter().collect(), + Some(Autoscroll::fit()), + cx, + ); + self.clear_highlights::(cx); + self.show_local_selections = true; + + if moving_cursor { + let cursor_in_rename_editor = rename.editor.update(cx, |editor, cx| { + editor.selections.newest::(cx).head() + }); + + // Update the selection to match the position of the selection inside + // the rename editor. + let snapshot = self.buffer.read(cx).read(cx); + let rename_range = rename.range.to_offset(&snapshot); + let cursor_in_editor = snapshot + .clip_offset(rename_range.start + cursor_in_rename_editor, Bias::Left) + .min(rename_range.end); + drop(snapshot); + + self.change_selections(None, window, cx, |s| { + s.select_ranges(vec![cursor_in_editor..cursor_in_editor]) + }); + } else { + self.refresh_document_highlights(cx); + } + + Some(rename) + } + + pub fn pending_rename(&self) -> Option<&RenameState> { + self.pending_rename.as_ref() + } + + fn format( + &mut self, + _: &Format, + window: &mut Window, + cx: &mut Context, + ) -> Option>> { + self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); + + let project = match &self.project { + Some(project) => project.clone(), + None => return None, + }; + + Some(self.perform_format( + project, + FormatTrigger::Manual, + FormatTarget::Buffers, + window, + cx, + )) + } + + fn format_selections( + &mut self, + _: &FormatSelections, + window: &mut Window, + cx: &mut Context, + ) -> Option>> { + self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); + + let project = match &self.project { + Some(project) => project.clone(), + None => return None, + }; + + let ranges = self + .selections + .all_adjusted(cx) + .into_iter() + .map(|selection| selection.range()) + .collect_vec(); + + Some(self.perform_format( + project, + FormatTrigger::Manual, + FormatTarget::Ranges(ranges), + window, + cx, + )) + } + + fn perform_format( + &mut self, + project: Entity, + trigger: FormatTrigger, + target: FormatTarget, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + let buffer = self.buffer.clone(); + let (buffers, target) = match target { + FormatTarget::Buffers => { + let mut buffers = buffer.read(cx).all_buffers(); + if trigger == FormatTrigger::Save { + buffers.retain(|buffer| buffer.read(cx).is_dirty()); + } + (buffers, LspFormatTarget::Buffers) + } + FormatTarget::Ranges(selection_ranges) => { + let multi_buffer = buffer.read(cx); + let snapshot = multi_buffer.read(cx); + let mut buffers = HashSet::default(); + let mut buffer_id_to_ranges: BTreeMap>> = + BTreeMap::new(); + for selection_range in selection_ranges { + for (buffer, buffer_range, _) in + snapshot.range_to_buffer_ranges(selection_range) + { + let buffer_id = buffer.remote_id(); + let start = buffer.anchor_before(buffer_range.start); + let end = buffer.anchor_after(buffer_range.end); + buffers.insert(multi_buffer.buffer(buffer_id).unwrap()); + buffer_id_to_ranges + .entry(buffer_id) + .and_modify(|buffer_ranges| buffer_ranges.push(start..end)) + .or_insert_with(|| vec![start..end]); + } + } + (buffers, LspFormatTarget::Ranges(buffer_id_to_ranges)) + } + }; + + let transaction_id_prev = buffer.read_with(cx, |b, cx| b.last_transaction_id(cx)); + let selections_prev = transaction_id_prev + .and_then(|transaction_id_prev| { + // default to selections as they were after the last edit, if we have them, + // instead of how they are now. + // This will make it so that editing, moving somewhere else, formatting, then undoing the format + // will take you back to where you made the last edit, instead of staying where you scrolled + self.selection_history + .transaction(transaction_id_prev) + .map(|t| t.0.clone()) + }) + .unwrap_or_else(|| { + log::info!("Failed to determine selections from before format. Falling back to selections when format was initiated"); + self.selections.disjoint_anchors() + }); + + let mut timeout = cx.background_executor().timer(FORMAT_TIMEOUT).fuse(); + let format = project.update(cx, |project, cx| { + project.format(buffers, target, true, trigger, cx) + }); + + cx.spawn_in(window, async move |editor, cx| { + let transaction = futures::select_biased! { + transaction = format.log_err().fuse() => transaction, + () = timeout => { + log::warn!("timed out waiting for formatting"); + None + } + }; + + buffer + .update(cx, |buffer, cx| { + if let Some(transaction) = transaction { + if !buffer.is_singleton() { + buffer.push_transaction(&transaction.0, cx); + } + } + cx.notify(); + }) + .ok(); + + if let Some(transaction_id_now) = + buffer.read_with(cx, |b, cx| b.last_transaction_id(cx))? + { + let has_new_transaction = transaction_id_prev != Some(transaction_id_now); + if has_new_transaction { + _ = editor.update(cx, |editor, _| { + editor + .selection_history + .insert_transaction(transaction_id_now, selections_prev); + }); + } + } + + Ok(()) + }) + } + + fn organize_imports( + &mut self, + _: &OrganizeImports, + window: &mut Window, + cx: &mut Context, + ) -> Option>> { + self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); + let project = match &self.project { + Some(project) => project.clone(), + None => return None, + }; + Some(self.perform_code_action_kind( + project, + CodeActionKind::SOURCE_ORGANIZE_IMPORTS, + window, + cx, + )) + } + + fn perform_code_action_kind( + &mut self, + project: Entity, + kind: CodeActionKind, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + let buffer = self.buffer.clone(); + let buffers = buffer.read(cx).all_buffers(); + let mut timeout = cx.background_executor().timer(CODE_ACTION_TIMEOUT).fuse(); + let apply_action = project.update(cx, |project, cx| { + project.apply_code_action_kind(buffers, kind, true, cx) + }); + cx.spawn_in(window, async move |_, cx| { + let transaction = futures::select_biased! { + () = timeout => { + log::warn!("timed out waiting for executing code action"); + None + } + transaction = apply_action.log_err().fuse() => transaction, + }; + buffer + .update(cx, |buffer, cx| { + // check if we need this + if let Some(transaction) = transaction { + if !buffer.is_singleton() { + buffer.push_transaction(&transaction.0, cx); + } + } + cx.notify(); + }) + .ok(); + Ok(()) + }) + } + + fn restart_language_server( + &mut self, + _: &RestartLanguageServer, + _: &mut Window, + cx: &mut Context, + ) { + if let Some(project) = self.project.clone() { + self.buffer.update(cx, |multi_buffer, cx| { + project.update(cx, |project, cx| { + project.restart_language_servers_for_buffers( + multi_buffer.all_buffers().into_iter().collect(), + cx, + ); + }); + }) + } + } + + fn stop_language_server( + &mut self, + _: &StopLanguageServer, + _: &mut Window, + cx: &mut Context, + ) { + if let Some(project) = self.project.clone() { + self.buffer.update(cx, |multi_buffer, cx| { + project.update(cx, |project, cx| { + project.stop_language_servers_for_buffers( + multi_buffer.all_buffers().into_iter().collect(), + cx, + ); + cx.emit(project::Event::RefreshInlayHints); + }); + }); + } + } + + fn cancel_language_server_work( + workspace: &mut Workspace, + _: &actions::CancelLanguageServerWork, + _: &mut Window, + cx: &mut Context, + ) { + let project = workspace.project(); + let buffers = workspace + .active_item(cx) + .and_then(|item| item.act_as::(cx)) + .map_or(HashSet::default(), |editor| { + editor.read(cx).buffer.read(cx).all_buffers() + }); + project.update(cx, |project, cx| { + project.cancel_language_server_work_for_buffers(buffers, cx); + }); + } + + fn show_character_palette( + &mut self, + _: &ShowCharacterPalette, + window: &mut Window, + _: &mut Context, + ) { + window.show_character_palette(); + } + + fn refresh_active_diagnostics(&mut self, cx: &mut Context) { + if let ActiveDiagnostic::Group(active_diagnostics) = &mut self.active_diagnostics { + let buffer = self.buffer.read(cx).snapshot(cx); + let primary_range_start = active_diagnostics.active_range.start.to_offset(&buffer); + let primary_range_end = active_diagnostics.active_range.end.to_offset(&buffer); + let is_valid = buffer + .diagnostics_in_range::(primary_range_start..primary_range_end) + .any(|entry| { + entry.diagnostic.is_primary + && !entry.range.is_empty() + && entry.range.start == primary_range_start + && entry.diagnostic.message == active_diagnostics.active_message + }); + + if !is_valid { + self.dismiss_diagnostics(cx); + } + } + } + + pub fn active_diagnostic_group(&self) -> Option<&ActiveDiagnosticGroup> { + match &self.active_diagnostics { + ActiveDiagnostic::Group(group) => Some(group), + _ => None, + } + } + + pub fn set_all_diagnostics_active(&mut self, cx: &mut Context) { + self.dismiss_diagnostics(cx); + self.active_diagnostics = ActiveDiagnostic::All; + } + + fn activate_diagnostics( + &mut self, + buffer_id: BufferId, + diagnostic: DiagnosticEntry, + window: &mut Window, + cx: &mut Context, + ) { + if matches!(self.active_diagnostics, ActiveDiagnostic::All) { + return; + } + self.dismiss_diagnostics(cx); + let snapshot = self.snapshot(window, cx); + let buffer = self.buffer.read(cx).snapshot(cx); + let Some(renderer) = GlobalDiagnosticRenderer::global(cx) else { + return; + }; + + let diagnostic_group = buffer + .diagnostic_group(buffer_id, diagnostic.diagnostic.group_id) + .collect::>(); + + let blocks = + renderer.render_group(diagnostic_group, buffer_id, snapshot, cx.weak_entity(), cx); + + let blocks = self.display_map.update(cx, |display_map, cx| { + display_map.insert_blocks(blocks, cx).into_iter().collect() + }); + self.active_diagnostics = ActiveDiagnostic::Group(ActiveDiagnosticGroup { + active_range: buffer.anchor_before(diagnostic.range.start) + ..buffer.anchor_after(diagnostic.range.end), + active_message: diagnostic.diagnostic.message.clone(), + group_id: diagnostic.diagnostic.group_id, + blocks, + }); + cx.notify(); + } + + fn dismiss_diagnostics(&mut self, cx: &mut Context) { + if matches!(self.active_diagnostics, ActiveDiagnostic::All) { + return; + }; + + let prev = mem::replace(&mut self.active_diagnostics, ActiveDiagnostic::None); + if let ActiveDiagnostic::Group(group) = prev { + self.display_map.update(cx, |display_map, cx| { + display_map.remove_blocks(group.blocks, cx); + }); + cx.notify(); + } + } + + /// Disable inline diagnostics rendering for this editor. + pub fn disable_inline_diagnostics(&mut self) { + self.inline_diagnostics_enabled = false; + self.inline_diagnostics_update = Task::ready(()); + self.inline_diagnostics.clear(); + } + + pub fn inline_diagnostics_enabled(&self) -> bool { + self.inline_diagnostics_enabled + } + + pub fn show_inline_diagnostics(&self) -> bool { + self.show_inline_diagnostics + } + + pub fn toggle_inline_diagnostics( + &mut self, + _: &ToggleInlineDiagnostics, + window: &mut Window, + cx: &mut Context, + ) { + self.show_inline_diagnostics = !self.show_inline_diagnostics; + self.refresh_inline_diagnostics(false, window, cx); + } + + fn refresh_inline_diagnostics( + &mut self, + debounce: bool, + window: &mut Window, + cx: &mut Context, + ) { + if !self.inline_diagnostics_enabled || !self.show_inline_diagnostics { + self.inline_diagnostics_update = Task::ready(()); + self.inline_diagnostics.clear(); + return; + } + + let debounce_ms = ProjectSettings::get_global(cx) + .diagnostics + .inline + .update_debounce_ms; + let debounce = if debounce && debounce_ms > 0 { + Some(Duration::from_millis(debounce_ms)) + } else { + None + }; + self.inline_diagnostics_update = cx.spawn_in(window, async move |editor, cx| { + let editor = editor.upgrade().unwrap(); + + if let Some(debounce) = debounce { + cx.background_executor().timer(debounce).await; + } + let Some(snapshot) = editor + .update(cx, |editor, cx| editor.buffer().read(cx).snapshot(cx)) + .ok() + else { + return; + }; + + let new_inline_diagnostics = cx + .background_spawn(async move { + let mut inline_diagnostics = Vec::<(Anchor, InlineDiagnostic)>::new(); + for diagnostic_entry in snapshot.diagnostics_in_range(0..snapshot.len()) { + let message = diagnostic_entry + .diagnostic + .message + .split_once('\n') + .map(|(line, _)| line) + .map(SharedString::new) + .unwrap_or_else(|| { + SharedString::from(diagnostic_entry.diagnostic.message) + }); + let start_anchor = snapshot.anchor_before(diagnostic_entry.range.start); + let (Ok(i) | Err(i)) = inline_diagnostics + .binary_search_by(|(probe, _)| probe.cmp(&start_anchor, &snapshot)); + inline_diagnostics.insert( + i, + ( + start_anchor, + InlineDiagnostic { + message, + group_id: diagnostic_entry.diagnostic.group_id, + start: diagnostic_entry.range.start.to_point(&snapshot), + is_primary: diagnostic_entry.diagnostic.is_primary, + severity: diagnostic_entry.diagnostic.severity, + }, + ), + ); + } + inline_diagnostics + }) + .await; + + editor + .update(cx, |editor, cx| { + editor.inline_diagnostics = new_inline_diagnostics; + cx.notify(); + }) + .ok(); + }); + } + + pub fn set_selections_from_remote( + &mut self, + selections: Vec>, + pending_selection: Option>, + window: &mut Window, + cx: &mut Context, + ) { + let old_cursor_position = self.selections.newest_anchor().head(); + self.selections.change_with(cx, |s| { + s.select_anchors(selections); + if let Some(pending_selection) = pending_selection { + s.set_pending(pending_selection, SelectMode::Character); + } else { + s.clear_pending(); + } + }); + self.selections_did_change(false, &old_cursor_position, true, window, cx); + } + + fn push_to_selection_history(&mut self) { + self.selection_history.push(SelectionHistoryEntry { + selections: self.selections.disjoint_anchors(), + select_next_state: self.select_next_state.clone(), + select_prev_state: self.select_prev_state.clone(), + add_selections_state: self.add_selections_state.clone(), + }); + } + + pub fn transact( + &mut self, + window: &mut Window, + cx: &mut Context, + update: impl FnOnce(&mut Self, &mut Window, &mut Context), + ) -> Option { + self.start_transaction_at(Instant::now(), window, cx); + update(self, window, cx); + self.end_transaction_at(Instant::now(), cx) + } + + pub fn start_transaction_at( + &mut self, + now: Instant, + window: &mut Window, + cx: &mut Context, + ) { + self.end_selection(window, cx); + if let Some(tx_id) = self + .buffer + .update(cx, |buffer, cx| buffer.start_transaction_at(now, cx)) + { + self.selection_history + .insert_transaction(tx_id, self.selections.disjoint_anchors()); + cx.emit(EditorEvent::TransactionBegun { + transaction_id: tx_id, + }) + } + } + + pub fn end_transaction_at( + &mut self, + now: Instant, + cx: &mut Context, + ) -> Option { + if let Some(transaction_id) = self + .buffer + .update(cx, |buffer, cx| buffer.end_transaction_at(now, cx)) + { + if let Some((_, end_selections)) = + self.selection_history.transaction_mut(transaction_id) + { + *end_selections = Some(self.selections.disjoint_anchors()); + } else { + log::error!("unexpectedly ended a transaction that wasn't started by this editor"); + } + + cx.emit(EditorEvent::Edited { transaction_id }); + Some(transaction_id) + } else { + None + } + } + + pub fn set_mark(&mut self, _: &actions::SetMark, window: &mut Window, cx: &mut Context) { + if self.selection_mark_mode { + self.change_selections(None, window, cx, |s| { + s.move_with(|_, sel| { + sel.collapse_to(sel.head(), SelectionGoal::None); + }); + }) + } + self.selection_mark_mode = true; + cx.notify(); + } + + pub fn swap_selection_ends( + &mut self, + _: &actions::SwapSelectionEnds, + window: &mut Window, + cx: &mut Context, + ) { + self.change_selections(None, window, cx, |s| { + s.move_with(|_, sel| { + if sel.start != sel.end { + sel.reversed = !sel.reversed + } + }); + }); + self.request_autoscroll(Autoscroll::newest(), cx); + cx.notify(); + } + + pub fn toggle_fold( + &mut self, + _: &actions::ToggleFold, + window: &mut Window, + cx: &mut Context, + ) { + if self.is_singleton(cx) { + let selection = self.selections.newest::(cx); + + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let range = if selection.is_empty() { + let point = selection.head().to_display_point(&display_map); + let start = DisplayPoint::new(point.row(), 0).to_point(&display_map); + let end = DisplayPoint::new(point.row(), display_map.line_len(point.row())) + .to_point(&display_map); + start..end + } else { + selection.range() + }; + if display_map.folds_in_range(range).next().is_some() { + self.unfold_lines(&Default::default(), window, cx) + } else { + self.fold(&Default::default(), window, cx) + } + } else { + let multi_buffer_snapshot = self.buffer.read(cx).snapshot(cx); + let buffer_ids: HashSet<_> = self + .selections + .disjoint_anchor_ranges() + .flat_map(|range| multi_buffer_snapshot.buffer_ids_for_range(range)) + .collect(); + + let should_unfold = buffer_ids + .iter() + .any(|buffer_id| self.is_buffer_folded(*buffer_id, cx)); + + for buffer_id in buffer_ids { + if should_unfold { + self.unfold_buffer(buffer_id, cx); + } else { + self.fold_buffer(buffer_id, cx); + } + } + } + } + + pub fn toggle_fold_recursive( + &mut self, + _: &actions::ToggleFoldRecursive, + window: &mut Window, + cx: &mut Context, + ) { + let selection = self.selections.newest::(cx); + + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let range = if selection.is_empty() { + let point = selection.head().to_display_point(&display_map); + let start = DisplayPoint::new(point.row(), 0).to_point(&display_map); + let end = DisplayPoint::new(point.row(), display_map.line_len(point.row())) + .to_point(&display_map); + start..end + } else { + selection.range() + }; + if display_map.folds_in_range(range).next().is_some() { + self.unfold_recursive(&Default::default(), window, cx) + } else { + self.fold_recursive(&Default::default(), window, cx) + } + } + + pub fn fold(&mut self, _: &actions::Fold, window: &mut Window, cx: &mut Context) { + if self.is_singleton(cx) { + let mut to_fold = Vec::new(); + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let selections = self.selections.all_adjusted(cx); + + for selection in selections { + let range = selection.range().sorted(); + let buffer_start_row = range.start.row; + + if range.start.row != range.end.row { + let mut found = false; + let mut row = range.start.row; + while row <= range.end.row { + if let Some(crease) = display_map.crease_for_buffer_row(MultiBufferRow(row)) + { + found = true; + row = crease.range().end.row + 1; + to_fold.push(crease); + } else { + row += 1 + } + } + if found { + continue; + } + } + + for row in (0..=range.start.row).rev() { + if let Some(crease) = display_map.crease_for_buffer_row(MultiBufferRow(row)) { + if crease.range().end.row >= buffer_start_row { + to_fold.push(crease); + if row <= range.start.row { + break; + } + } + } + } + } + + self.fold_creases(to_fold, true, window, cx); + } else { + let multi_buffer_snapshot = self.buffer.read(cx).snapshot(cx); + let buffer_ids = self + .selections + .disjoint_anchor_ranges() + .flat_map(|range| multi_buffer_snapshot.buffer_ids_for_range(range)) + .collect::>(); + for buffer_id in buffer_ids { + self.fold_buffer(buffer_id, cx); + } + } + } + + fn fold_at_level( + &mut self, + fold_at: &FoldAtLevel, + window: &mut Window, + cx: &mut Context, + ) { + if !self.buffer.read(cx).is_singleton() { + return; + } + + let fold_at_level = fold_at.0; + let snapshot = self.buffer.read(cx).snapshot(cx); + let mut to_fold = Vec::new(); + let mut stack = vec![(0, snapshot.max_row().0, 1)]; + + while let Some((mut start_row, end_row, current_level)) = stack.pop() { + while start_row < end_row { + match self + .snapshot(window, cx) + .crease_for_buffer_row(MultiBufferRow(start_row)) + { + Some(crease) => { + let nested_start_row = crease.range().start.row + 1; + let nested_end_row = crease.range().end.row; + + if current_level < fold_at_level { + stack.push((nested_start_row, nested_end_row, current_level + 1)); + } else if current_level == fold_at_level { + to_fold.push(crease); + } + + start_row = nested_end_row + 1; + } + None => start_row += 1, + } + } + } + + self.fold_creases(to_fold, true, window, cx); + } + + pub fn fold_all(&mut self, _: &actions::FoldAll, window: &mut Window, cx: &mut Context) { + if self.buffer.read(cx).is_singleton() { + let mut fold_ranges = Vec::new(); + let snapshot = self.buffer.read(cx).snapshot(cx); + + for row in 0..snapshot.max_row().0 { + if let Some(foldable_range) = self + .snapshot(window, cx) + .crease_for_buffer_row(MultiBufferRow(row)) + { + fold_ranges.push(foldable_range); + } + } + + self.fold_creases(fold_ranges, true, window, cx); + } else { + self.toggle_fold_multiple_buffers = cx.spawn_in(window, async move |editor, cx| { + editor + .update_in(cx, |editor, _, cx| { + for buffer_id in editor.buffer.read(cx).excerpt_buffer_ids() { + editor.fold_buffer(buffer_id, cx); + } + }) + .ok(); + }); + } + } + + pub fn fold_function_bodies( + &mut self, + _: &actions::FoldFunctionBodies, + window: &mut Window, + cx: &mut Context, + ) { + let snapshot = self.buffer.read(cx).snapshot(cx); + + let ranges = snapshot + .text_object_ranges(0..snapshot.len(), TreeSitterOptions::default()) + .filter_map(|(range, obj)| (obj == TextObject::InsideFunction).then_some(range)) + .collect::>(); + + let creases = ranges + .into_iter() + .map(|range| Crease::simple(range, self.display_map.read(cx).fold_placeholder.clone())) + .collect(); + + self.fold_creases(creases, true, window, cx); + } + + pub fn fold_recursive( + &mut self, + _: &actions::FoldRecursive, + window: &mut Window, + cx: &mut Context, + ) { + let mut to_fold = Vec::new(); + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let selections = self.selections.all_adjusted(cx); + + for selection in selections { + let range = selection.range().sorted(); + let buffer_start_row = range.start.row; + + if range.start.row != range.end.row { + let mut found = false; + for row in range.start.row..=range.end.row { + if let Some(crease) = display_map.crease_for_buffer_row(MultiBufferRow(row)) { + found = true; + to_fold.push(crease); + } + } + if found { + continue; + } + } + + for row in (0..=range.start.row).rev() { + if let Some(crease) = display_map.crease_for_buffer_row(MultiBufferRow(row)) { + if crease.range().end.row >= buffer_start_row { + to_fold.push(crease); + } else { + break; + } + } + } + } + + self.fold_creases(to_fold, true, window, cx); + } + + pub fn fold_at( + &mut self, + buffer_row: MultiBufferRow, + window: &mut Window, + cx: &mut Context, + ) { + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + + if let Some(crease) = display_map.crease_for_buffer_row(buffer_row) { + let autoscroll = self + .selections + .all::(cx) + .iter() + .any(|selection| crease.range().overlaps(&selection.range())); + + self.fold_creases(vec![crease], autoscroll, window, cx); + } + } + + pub fn unfold_lines(&mut self, _: &UnfoldLines, _window: &mut Window, cx: &mut Context) { + if self.is_singleton(cx) { + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let buffer = &display_map.buffer_snapshot; + let selections = self.selections.all::(cx); + let ranges = selections + .iter() + .map(|s| { + let range = s.display_range(&display_map).sorted(); + let mut start = range.start.to_point(&display_map); + let mut end = range.end.to_point(&display_map); + start.column = 0; + end.column = buffer.line_len(MultiBufferRow(end.row)); + start..end + }) + .collect::>(); + + self.unfold_ranges(&ranges, true, true, cx); + } else { + let multi_buffer_snapshot = self.buffer.read(cx).snapshot(cx); + let buffer_ids = self + .selections + .disjoint_anchor_ranges() + .flat_map(|range| multi_buffer_snapshot.buffer_ids_for_range(range)) + .collect::>(); + for buffer_id in buffer_ids { + self.unfold_buffer(buffer_id, cx); + } + } + } + + pub fn unfold_recursive( + &mut self, + _: &UnfoldRecursive, + _window: &mut Window, + cx: &mut Context, + ) { + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let selections = self.selections.all::(cx); + let ranges = selections + .iter() + .map(|s| { + let mut range = s.display_range(&display_map).sorted(); + *range.start.column_mut() = 0; + *range.end.column_mut() = display_map.line_len(range.end.row()); + let start = range.start.to_point(&display_map); + let end = range.end.to_point(&display_map); + start..end + }) + .collect::>(); + + self.unfold_ranges(&ranges, true, true, cx); + } + + pub fn unfold_at( + &mut self, + buffer_row: MultiBufferRow, + _window: &mut Window, + cx: &mut Context, + ) { + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + + let intersection_range = Point::new(buffer_row.0, 0) + ..Point::new( + buffer_row.0, + display_map.buffer_snapshot.line_len(buffer_row), + ); + + let autoscroll = self + .selections + .all::(cx) + .iter() + .any(|selection| RangeExt::overlaps(&selection.range(), &intersection_range)); + + self.unfold_ranges(&[intersection_range], true, autoscroll, cx); + } + + pub fn unfold_all( + &mut self, + _: &actions::UnfoldAll, + _window: &mut Window, + cx: &mut Context, + ) { + if self.buffer.read(cx).is_singleton() { + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + self.unfold_ranges(&[0..display_map.buffer_snapshot.len()], true, true, cx); + } else { + self.toggle_fold_multiple_buffers = cx.spawn(async move |editor, cx| { + editor + .update(cx, |editor, cx| { + for buffer_id in editor.buffer.read(cx).excerpt_buffer_ids() { + editor.unfold_buffer(buffer_id, cx); + } + }) + .ok(); + }); + } + } + + pub fn fold_selected_ranges( + &mut self, + _: &FoldSelectedRanges, + window: &mut Window, + cx: &mut Context, + ) { + let selections = self.selections.all_adjusted(cx); + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let ranges = selections + .into_iter() + .map(|s| Crease::simple(s.range(), display_map.fold_placeholder.clone())) + .collect::>(); + self.fold_creases(ranges, true, window, cx); + } + + pub fn fold_ranges( + &mut self, + ranges: Vec>, + auto_scroll: bool, + window: &mut Window, + cx: &mut Context, + ) { + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let ranges = ranges + .into_iter() + .map(|r| Crease::simple(r, display_map.fold_placeholder.clone())) + .collect::>(); + self.fold_creases(ranges, auto_scroll, window, cx); + } + + pub fn fold_creases( + &mut self, + creases: Vec>, + auto_scroll: bool, + _window: &mut Window, + cx: &mut Context, + ) { + if creases.is_empty() { + return; + } + + let mut buffers_affected = HashSet::default(); + let multi_buffer = self.buffer().read(cx); + for crease in &creases { + if let Some((_, buffer, _)) = + multi_buffer.excerpt_containing(crease.range().start.clone(), cx) + { + buffers_affected.insert(buffer.read(cx).remote_id()); + }; + } + + self.display_map.update(cx, |map, cx| map.fold(creases, cx)); + + if auto_scroll { + self.request_autoscroll(Autoscroll::fit(), cx); + } + + cx.notify(); + + self.scrollbar_marker_state.dirty = true; + self.folds_did_change(cx); + } + + /// Removes any folds whose ranges intersect any of the given ranges. + pub fn unfold_ranges( + &mut self, + ranges: &[Range], + inclusive: bool, + auto_scroll: bool, + cx: &mut Context, + ) { + self.remove_folds_with(ranges, auto_scroll, cx, |map, cx| { + map.unfold_intersecting(ranges.iter().cloned(), inclusive, cx) + }); + self.folds_did_change(cx); + } + + pub fn fold_buffer(&mut self, buffer_id: BufferId, cx: &mut Context) { + if self.buffer().read(cx).is_singleton() || self.is_buffer_folded(buffer_id, cx) { + return; + } + let folded_excerpts = self.buffer().read(cx).excerpts_for_buffer(buffer_id, cx); + self.display_map.update(cx, |display_map, cx| { + display_map.fold_buffers([buffer_id], cx) + }); + cx.emit(EditorEvent::BufferFoldToggled { + ids: folded_excerpts.iter().map(|&(id, _)| id).collect(), + folded: true, + }); + cx.notify(); + } + + pub fn unfold_buffer(&mut self, buffer_id: BufferId, cx: &mut Context) { + if self.buffer().read(cx).is_singleton() || !self.is_buffer_folded(buffer_id, cx) { + return; + } + let unfolded_excerpts = self.buffer().read(cx).excerpts_for_buffer(buffer_id, cx); + self.display_map.update(cx, |display_map, cx| { + display_map.unfold_buffers([buffer_id], cx); + }); + cx.emit(EditorEvent::BufferFoldToggled { + ids: unfolded_excerpts.iter().map(|&(id, _)| id).collect(), + folded: false, + }); + cx.notify(); + } + + pub fn is_buffer_folded(&self, buffer: BufferId, cx: &App) -> bool { + self.display_map.read(cx).is_buffer_folded(buffer) + } + + pub fn folded_buffers<'a>(&self, cx: &'a App) -> &'a HashSet { + self.display_map.read(cx).folded_buffers() + } + + pub fn disable_header_for_buffer(&mut self, buffer_id: BufferId, cx: &mut Context) { + self.display_map.update(cx, |display_map, cx| { + display_map.disable_header_for_buffer(buffer_id, cx); + }); + cx.notify(); + } + + /// Removes any folds with the given ranges. + pub fn remove_folds_with_type( + &mut self, + ranges: &[Range], + type_id: TypeId, + auto_scroll: bool, + cx: &mut Context, + ) { + self.remove_folds_with(ranges, auto_scroll, cx, |map, cx| { + map.remove_folds_with_type(ranges.iter().cloned(), type_id, cx) + }); + self.folds_did_change(cx); + } + + fn remove_folds_with( + &mut self, + ranges: &[Range], + auto_scroll: bool, + cx: &mut Context, + update: impl FnOnce(&mut DisplayMap, &mut Context), + ) { + if ranges.is_empty() { + return; + } + + let mut buffers_affected = HashSet::default(); + let multi_buffer = self.buffer().read(cx); + for range in ranges { + if let Some((_, buffer, _)) = multi_buffer.excerpt_containing(range.start.clone(), cx) { + buffers_affected.insert(buffer.read(cx).remote_id()); + }; + } + + self.display_map.update(cx, update); + + if auto_scroll { + self.request_autoscroll(Autoscroll::fit(), cx); + } + + cx.notify(); + self.scrollbar_marker_state.dirty = true; + self.active_indent_guides_state.dirty = true; + } + + pub fn update_fold_widths( + &mut self, + widths: impl IntoIterator, + cx: &mut Context, + ) -> bool { + self.display_map + .update(cx, |map, cx| map.update_fold_widths(widths, cx)) + } + + pub fn default_fold_placeholder(&self, cx: &App) -> FoldPlaceholder { + self.display_map.read(cx).fold_placeholder.clone() + } + + pub fn set_expand_all_diff_hunks(&mut self, cx: &mut App) { + self.buffer.update(cx, |buffer, cx| { + buffer.set_all_diff_hunks_expanded(cx); + }); + } + + pub fn expand_all_diff_hunks( + &mut self, + _: &ExpandAllDiffHunks, + _window: &mut Window, + cx: &mut Context, + ) { + self.buffer.update(cx, |buffer, cx| { + buffer.expand_diff_hunks(vec![Anchor::min()..Anchor::max()], cx) + }); + } + + pub fn toggle_selected_diff_hunks( + &mut self, + _: &ToggleSelectedDiffHunks, + _window: &mut Window, + cx: &mut Context, + ) { + let ranges: Vec<_> = self.selections.disjoint.iter().map(|s| s.range()).collect(); + self.toggle_diff_hunks_in_ranges(ranges, cx); + } + + pub fn diff_hunks_in_ranges<'a>( + &'a self, + ranges: &'a [Range], + buffer: &'a MultiBufferSnapshot, + ) -> impl 'a + Iterator { + ranges.iter().flat_map(move |range| { + let end_excerpt_id = range.end.excerpt_id; + let range = range.to_point(buffer); + let mut peek_end = range.end; + if range.end.row < buffer.max_row().0 { + peek_end = Point::new(range.end.row + 1, 0); + } + buffer + .diff_hunks_in_range(range.start..peek_end) + .filter(move |hunk| hunk.excerpt_id.cmp(&end_excerpt_id, buffer).is_le()) + }) + } + + pub fn has_stageable_diff_hunks_in_ranges( + &self, + ranges: &[Range], + snapshot: &MultiBufferSnapshot, + ) -> bool { + let mut hunks = self.diff_hunks_in_ranges(ranges, &snapshot); + hunks.any(|hunk| hunk.status().has_secondary_hunk()) + } + + pub fn toggle_staged_selected_diff_hunks( + &mut self, + _: &::git::ToggleStaged, + _: &mut Window, + cx: &mut Context, + ) { + let snapshot = self.buffer.read(cx).snapshot(cx); + let ranges: Vec<_> = self.selections.disjoint.iter().map(|s| s.range()).collect(); + let stage = self.has_stageable_diff_hunks_in_ranges(&ranges, &snapshot); + self.stage_or_unstage_diff_hunks(stage, ranges, cx); + } + + pub fn set_render_diff_hunk_controls( + &mut self, + render_diff_hunk_controls: RenderDiffHunkControlsFn, + cx: &mut Context, + ) { + self.render_diff_hunk_controls = render_diff_hunk_controls; + cx.notify(); + } + + pub fn stage_and_next( + &mut self, + _: &::git::StageAndNext, + window: &mut Window, + cx: &mut Context, + ) { + self.do_stage_or_unstage_and_next(true, window, cx); + } + + pub fn unstage_and_next( + &mut self, + _: &::git::UnstageAndNext, + window: &mut Window, + cx: &mut Context, + ) { + self.do_stage_or_unstage_and_next(false, window, cx); + } + + pub fn stage_or_unstage_diff_hunks( + &mut self, + stage: bool, + ranges: Vec>, + cx: &mut Context, + ) { + let task = self.save_buffers_for_ranges_if_needed(&ranges, cx); + cx.spawn(async move |this, cx| { + task.await?; + this.update(cx, |this, cx| { + let snapshot = this.buffer.read(cx).snapshot(cx); + let chunk_by = this + .diff_hunks_in_ranges(&ranges, &snapshot) + .chunk_by(|hunk| hunk.buffer_id); + for (buffer_id, hunks) in &chunk_by { + this.do_stage_or_unstage(stage, buffer_id, hunks, cx); + } + }) + }) + .detach_and_log_err(cx); + } + + fn save_buffers_for_ranges_if_needed( + &mut self, + ranges: &[Range], + cx: &mut Context, + ) -> Task> { + let multibuffer = self.buffer.read(cx); + let snapshot = multibuffer.read(cx); + let buffer_ids: HashSet<_> = ranges + .iter() + .flat_map(|range| snapshot.buffer_ids_for_range(range.clone())) + .collect(); + drop(snapshot); + + let mut buffers = HashSet::default(); + for buffer_id in buffer_ids { + if let Some(buffer_entity) = multibuffer.buffer(buffer_id) { + let buffer = buffer_entity.read(cx); + if buffer.file().is_some_and(|file| file.disk_state().exists()) && buffer.is_dirty() + { + buffers.insert(buffer_entity); + } + } + } + + if let Some(project) = &self.project { + project.update(cx, |project, cx| project.save_buffers(buffers, cx)) + } else { + Task::ready(Ok(())) + } + } + + fn do_stage_or_unstage_and_next( + &mut self, + stage: bool, + window: &mut Window, + cx: &mut Context, + ) { + let ranges = self.selections.disjoint_anchor_ranges().collect::>(); + + if ranges.iter().any(|range| range.start != range.end) { + self.stage_or_unstage_diff_hunks(stage, ranges, cx); + return; + } + + self.stage_or_unstage_diff_hunks(stage, ranges, cx); + let snapshot = self.snapshot(window, cx); + let position = self.selections.newest::(cx).head(); + let mut row = snapshot + .buffer_snapshot + .diff_hunks_in_range(position..snapshot.buffer_snapshot.max_point()) + .find(|hunk| hunk.row_range.start.0 > position.row) + .map(|hunk| hunk.row_range.start); + + let all_diff_hunks_expanded = self.buffer().read(cx).all_diff_hunks_expanded(); + // Outside of the project diff editor, wrap around to the beginning. + if !all_diff_hunks_expanded { + row = row.or_else(|| { + snapshot + .buffer_snapshot + .diff_hunks_in_range(Point::zero()..position) + .find(|hunk| hunk.row_range.end.0 < position.row) + .map(|hunk| hunk.row_range.start) + }); + } + + if let Some(row) = row { + let destination = Point::new(row.0, 0); + let autoscroll = Autoscroll::center(); + + self.unfold_ranges(&[destination..destination], false, false, cx); + self.change_selections(Some(autoscroll), window, cx, |s| { + s.select_ranges([destination..destination]); + }); + } + } + + fn do_stage_or_unstage( + &self, + stage: bool, + buffer_id: BufferId, + hunks: impl Iterator, + cx: &mut App, + ) -> Option<()> { + let project = self.project.as_ref()?; + let buffer = project.read(cx).buffer_for_id(buffer_id, cx)?; + let diff = self.buffer.read(cx).diff_for(buffer_id)?; + let buffer_snapshot = buffer.read(cx).snapshot(); + let file_exists = buffer_snapshot + .file() + .is_some_and(|file| file.disk_state().exists()); + diff.update(cx, |diff, cx| { + diff.stage_or_unstage_hunks( + stage, + &hunks + .map(|hunk| buffer_diff::DiffHunk { + buffer_range: hunk.buffer_range, + diff_base_byte_range: hunk.diff_base_byte_range, + secondary_status: hunk.secondary_status, + range: Point::zero()..Point::zero(), // unused + }) + .collect::>(), + &buffer_snapshot, + file_exists, + cx, + ) + }); + None + } + + pub fn expand_selected_diff_hunks(&mut self, cx: &mut Context) { + let ranges: Vec<_> = self.selections.disjoint.iter().map(|s| s.range()).collect(); + self.buffer + .update(cx, |buffer, cx| buffer.expand_diff_hunks(ranges, cx)) + } + + pub fn clear_expanded_diff_hunks(&mut self, cx: &mut Context) -> bool { + self.buffer.update(cx, |buffer, cx| { + let ranges = vec![Anchor::min()..Anchor::max()]; + if !buffer.all_diff_hunks_expanded() + && buffer.has_expanded_diff_hunks_in_ranges(&ranges, cx) + { + buffer.collapse_diff_hunks(ranges, cx); + true + } else { + false + } + }) + } + + fn toggle_diff_hunks_in_ranges( + &mut self, + ranges: Vec>, + cx: &mut Context, + ) { + self.buffer.update(cx, |buffer, cx| { + let expand = !buffer.has_expanded_diff_hunks_in_ranges(&ranges, cx); + buffer.expand_or_collapse_diff_hunks(ranges, expand, cx); + }) + } + + fn toggle_single_diff_hunk(&mut self, range: Range, cx: &mut Context) { + self.buffer.update(cx, |buffer, cx| { + let snapshot = buffer.snapshot(cx); + let excerpt_id = range.end.excerpt_id; + let point_range = range.to_point(&snapshot); + let expand = !buffer.single_hunk_is_expanded(range, cx); + buffer.expand_or_collapse_diff_hunks_inner([(point_range, excerpt_id)], expand, cx); + }) + } + + pub(crate) fn apply_all_diff_hunks( + &mut self, + _: &ApplyAllDiffHunks, + window: &mut Window, + cx: &mut Context, + ) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); + + let buffers = self.buffer.read(cx).all_buffers(); + for branch_buffer in buffers { + branch_buffer.update(cx, |branch_buffer, cx| { + branch_buffer.merge_into_base(Vec::new(), cx); + }); + } + + if let Some(project) = self.project.clone() { + self.save(true, project, window, cx).detach_and_log_err(cx); + } + } + + pub(crate) fn apply_selected_diff_hunks( + &mut self, + _: &ApplyDiffHunk, + window: &mut Window, + cx: &mut Context, + ) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); + let snapshot = self.snapshot(window, cx); + let hunks = snapshot.hunks_for_ranges(self.selections.ranges(cx)); + let mut ranges_by_buffer = HashMap::default(); + self.transact(window, cx, |editor, _window, cx| { + for hunk in hunks { + if let Some(buffer) = editor.buffer.read(cx).buffer(hunk.buffer_id) { + ranges_by_buffer + .entry(buffer.clone()) + .or_insert_with(Vec::new) + .push(hunk.buffer_range.to_offset(buffer.read(cx))); + } + } + + for (buffer, ranges) in ranges_by_buffer { + buffer.update(cx, |buffer, cx| { + buffer.merge_into_base(ranges, cx); + }); + } + }); + + if let Some(project) = self.project.clone() { + self.save(true, project, window, cx).detach_and_log_err(cx); + } + } + + pub fn set_gutter_hovered(&mut self, hovered: bool, cx: &mut Context) { + if hovered != self.gutter_hovered { + self.gutter_hovered = hovered; + cx.notify(); + } + } + + pub fn insert_blocks( + &mut self, + blocks: impl IntoIterator>, + autoscroll: Option, + cx: &mut Context, + ) -> Vec { + let blocks = self + .display_map + .update(cx, |display_map, cx| display_map.insert_blocks(blocks, cx)); + if let Some(autoscroll) = autoscroll { + self.request_autoscroll(autoscroll, cx); + } + cx.notify(); + blocks + } + + pub fn resize_blocks( + &mut self, + heights: HashMap, + autoscroll: Option, + cx: &mut Context, + ) { + self.display_map + .update(cx, |display_map, cx| display_map.resize_blocks(heights, cx)); + if let Some(autoscroll) = autoscroll { + self.request_autoscroll(autoscroll, cx); + } + cx.notify(); + } + + pub fn replace_blocks( + &mut self, + renderers: HashMap, + autoscroll: Option, + cx: &mut Context, + ) { + self.display_map + .update(cx, |display_map, _cx| display_map.replace_blocks(renderers)); + if let Some(autoscroll) = autoscroll { + self.request_autoscroll(autoscroll, cx); + } + cx.notify(); + } + + pub fn remove_blocks( + &mut self, + block_ids: HashSet, + autoscroll: Option, + cx: &mut Context, + ) { + self.display_map.update(cx, |display_map, cx| { + display_map.remove_blocks(block_ids, cx) + }); + if let Some(autoscroll) = autoscroll { + self.request_autoscroll(autoscroll, cx); + } + cx.notify(); + } + + pub fn row_for_block( + &self, + block_id: CustomBlockId, + cx: &mut Context, + ) -> Option { + self.display_map + .update(cx, |map, cx| map.row_for_block(block_id, cx)) + } + + pub(crate) fn set_focused_block(&mut self, focused_block: FocusedBlock) { + self.focused_block = Some(focused_block); + } + + pub(crate) fn take_focused_block(&mut self) -> Option { + self.focused_block.take() + } + + pub fn insert_creases( + &mut self, + creases: impl IntoIterator>, + cx: &mut Context, + ) -> Vec { + self.display_map + .update(cx, |map, cx| map.insert_creases(creases, cx)) + } + + pub fn remove_creases( + &mut self, + ids: impl IntoIterator, + cx: &mut Context, + ) { + self.display_map + .update(cx, |map, cx| map.remove_creases(ids, cx)); + } + + pub fn longest_row(&self, cx: &mut App) -> DisplayRow { + self.display_map + .update(cx, |map, cx| map.snapshot(cx)) + .longest_row() + } + + pub fn max_point(&self, cx: &mut App) -> DisplayPoint { + self.display_map + .update(cx, |map, cx| map.snapshot(cx)) + .max_point() + } + + pub fn text(&self, cx: &App) -> String { + self.buffer.read(cx).read(cx).text() + } + + pub fn is_empty(&self, cx: &App) -> bool { + self.buffer.read(cx).read(cx).is_empty() + } + + pub fn text_option(&self, cx: &App) -> Option { + let text = self.text(cx); + let text = text.trim(); + + if text.is_empty() { + return None; + } + + Some(text.to_string()) + } + + pub fn set_text( + &mut self, + text: impl Into>, + window: &mut Window, + cx: &mut Context, + ) { + self.transact(window, cx, |this, _, cx| { + this.buffer + .read(cx) + .as_singleton() + .expect("you can only call set_text on editors for singleton buffers") + .update(cx, |buffer, cx| buffer.set_text(text, cx)); + }); + } + + pub fn display_text(&self, cx: &mut App) -> String { + self.display_map + .update(cx, |map, cx| map.snapshot(cx)) + .text() + } + + pub fn wrap_guides(&self, cx: &App) -> SmallVec<[(usize, bool); 2]> { + let mut wrap_guides = smallvec::smallvec![]; + + if self.show_wrap_guides == Some(false) { + return wrap_guides; + } + + let settings = self.buffer.read(cx).language_settings(cx); + if settings.show_wrap_guides { + match self.soft_wrap_mode(cx) { + SoftWrap::Column(soft_wrap) => { + wrap_guides.push((soft_wrap as usize, true)); + } + SoftWrap::Bounded(soft_wrap) => { + wrap_guides.push((soft_wrap as usize, true)); + } + SoftWrap::GitDiff | SoftWrap::None | SoftWrap::EditorWidth => {} + } + wrap_guides.extend(settings.wrap_guides.iter().map(|guide| (*guide, false))) + } + + wrap_guides + } + + pub fn soft_wrap_mode(&self, cx: &App) -> SoftWrap { + let settings = self.buffer.read(cx).language_settings(cx); + let mode = self.soft_wrap_mode_override.unwrap_or(settings.soft_wrap); + match mode { + language_settings::SoftWrap::PreferLine | language_settings::SoftWrap::None => { + SoftWrap::None + } + language_settings::SoftWrap::EditorWidth => SoftWrap::EditorWidth, + language_settings::SoftWrap::PreferredLineLength => { + SoftWrap::Column(settings.preferred_line_length) + } + language_settings::SoftWrap::Bounded => { + SoftWrap::Bounded(settings.preferred_line_length) + } + } + } + + pub fn set_soft_wrap_mode( + &mut self, + mode: language_settings::SoftWrap, + + cx: &mut Context, + ) { + self.soft_wrap_mode_override = Some(mode); + cx.notify(); + } + + pub fn set_hard_wrap(&mut self, hard_wrap: Option, cx: &mut Context) { + self.hard_wrap = hard_wrap; + cx.notify(); + } + + pub fn set_text_style_refinement(&mut self, style: TextStyleRefinement) { + self.text_style_refinement = Some(style); + } + + /// called by the Element so we know what style we were most recently rendered with. + pub(crate) fn set_style( + &mut self, + style: EditorStyle, + window: &mut Window, + cx: &mut Context, + ) { + let rem_size = window.rem_size(); + self.display_map.update(cx, |map, cx| { + map.set_font( + style.text.font(), + style.text.font_size.to_pixels(rem_size), + cx, + ) + }); + self.style = Some(style); + } + + pub fn style(&self) -> Option<&EditorStyle> { + self.style.as_ref() + } + + // Called by the element. This method is not designed to be called outside of the editor + // element's layout code because it does not notify when rewrapping is computed synchronously. + pub(crate) fn set_wrap_width(&self, width: Option, cx: &mut App) -> bool { + self.display_map + .update(cx, |map, cx| map.set_wrap_width(width, cx)) + } + + pub fn set_soft_wrap(&mut self) { + self.soft_wrap_mode_override = Some(language_settings::SoftWrap::EditorWidth) + } + + pub fn toggle_soft_wrap(&mut self, _: &ToggleSoftWrap, _: &mut Window, cx: &mut Context) { + if self.soft_wrap_mode_override.is_some() { + self.soft_wrap_mode_override.take(); + } else { + let soft_wrap = match self.soft_wrap_mode(cx) { + SoftWrap::GitDiff => return, + SoftWrap::None => language_settings::SoftWrap::EditorWidth, + SoftWrap::EditorWidth | SoftWrap::Column(_) | SoftWrap::Bounded(_) => { + language_settings::SoftWrap::None + } + }; + self.soft_wrap_mode_override = Some(soft_wrap); + } + cx.notify(); + } + + pub fn toggle_tab_bar(&mut self, _: &ToggleTabBar, _: &mut Window, cx: &mut Context) { + let Some(workspace) = self.workspace() else { + return; + }; + let fs = workspace.read(cx).app_state().fs.clone(); + let current_show = TabBarSettings::get_global(cx).show; + update_settings_file::(fs, cx, move |setting, _| { + setting.show = Some(!current_show); + }); + } + + pub fn toggle_indent_guides( + &mut self, + _: &ToggleIndentGuides, + _: &mut Window, + cx: &mut Context, + ) { + let currently_enabled = self.should_show_indent_guides().unwrap_or_else(|| { + self.buffer + .read(cx) + .language_settings(cx) + .indent_guides + .enabled + }); + self.show_indent_guides = Some(!currently_enabled); + cx.notify(); + } + + fn should_show_indent_guides(&self) -> Option { + self.show_indent_guides + } + + pub fn toggle_line_numbers( + &mut self, + _: &ToggleLineNumbers, + _: &mut Window, + cx: &mut Context, + ) { + let mut editor_settings = EditorSettings::get_global(cx).clone(); + editor_settings.gutter.line_numbers = !editor_settings.gutter.line_numbers; + EditorSettings::override_global(editor_settings, cx); + } + + pub fn line_numbers_enabled(&self, cx: &App) -> bool { + if let Some(show_line_numbers) = self.show_line_numbers { + return show_line_numbers; + } + EditorSettings::get_global(cx).gutter.line_numbers + } + + pub fn should_use_relative_line_numbers(&self, cx: &mut App) -> bool { + self.use_relative_line_numbers + .unwrap_or(EditorSettings::get_global(cx).relative_line_numbers) + } + + pub fn toggle_relative_line_numbers( + &mut self, + _: &ToggleRelativeLineNumbers, + _: &mut Window, + cx: &mut Context, + ) { + let is_relative = self.should_use_relative_line_numbers(cx); + self.set_relative_line_number(Some(!is_relative), cx) + } + + pub fn set_relative_line_number(&mut self, is_relative: Option, cx: &mut Context) { + self.use_relative_line_numbers = is_relative; + cx.notify(); + } + + pub fn set_show_gutter(&mut self, show_gutter: bool, cx: &mut Context) { + self.show_gutter = show_gutter; + cx.notify(); + } + + pub fn set_show_scrollbars(&mut self, show_scrollbars: bool, cx: &mut Context) { + self.show_scrollbars = show_scrollbars; + cx.notify(); + } + + pub fn disable_scrolling(&mut self, cx: &mut Context) { + self.disable_scrolling = true; + cx.notify(); + } + + pub fn set_show_line_numbers(&mut self, show_line_numbers: bool, cx: &mut Context) { + self.show_line_numbers = Some(show_line_numbers); + cx.notify(); + } + + pub fn disable_expand_excerpt_buttons(&mut self, cx: &mut Context) { + self.disable_expand_excerpt_buttons = true; + cx.notify(); + } + + pub fn set_show_git_diff_gutter(&mut self, show_git_diff_gutter: bool, cx: &mut Context) { + self.show_git_diff_gutter = Some(show_git_diff_gutter); + cx.notify(); + } + + pub fn set_show_code_actions(&mut self, show_code_actions: bool, cx: &mut Context) { + self.show_code_actions = Some(show_code_actions); + cx.notify(); + } + + pub fn set_show_runnables(&mut self, show_runnables: bool, cx: &mut Context) { + self.show_runnables = Some(show_runnables); + cx.notify(); + } + + pub fn set_show_breakpoints(&mut self, show_breakpoints: bool, cx: &mut Context) { + self.show_breakpoints = Some(show_breakpoints); + cx.notify(); + } + + pub fn set_masked(&mut self, masked: bool, cx: &mut Context) { + if self.display_map.read(cx).masked != masked { + self.display_map.update(cx, |map, _| map.masked = masked); + } + cx.notify() + } + + pub fn set_show_wrap_guides(&mut self, show_wrap_guides: bool, cx: &mut Context) { + self.show_wrap_guides = Some(show_wrap_guides); + cx.notify(); + } + + pub fn set_show_indent_guides(&mut self, show_indent_guides: bool, cx: &mut Context) { + self.show_indent_guides = Some(show_indent_guides); + cx.notify(); + } + + pub fn working_directory(&self, cx: &App) -> Option { + if let Some(buffer) = self.buffer().read(cx).as_singleton() { + if let Some(file) = buffer.read(cx).file().and_then(|f| f.as_local()) { + if let Some(dir) = file.abs_path(cx).parent() { + return Some(dir.to_owned()); + } + } + + if let Some(project_path) = buffer.read(cx).project_path(cx) { + return Some(project_path.path.to_path_buf()); + } + } + + None + } + + fn target_file<'a>(&self, cx: &'a App) -> Option<&'a dyn language::LocalFile> { + self.active_excerpt(cx)? + .1 + .read(cx) + .file() + .and_then(|f| f.as_local()) + } + + pub fn target_file_abs_path(&self, cx: &mut Context) -> Option { + self.active_excerpt(cx).and_then(|(_, buffer, _)| { + let buffer = buffer.read(cx); + if let Some(project_path) = buffer.project_path(cx) { + let project = self.project.as_ref()?.read(cx); + project.absolute_path(&project_path, cx) + } else { + buffer + .file() + .and_then(|file| file.as_local().map(|file| file.abs_path(cx))) + } + }) + } + + fn target_file_path(&self, cx: &mut Context) -> Option { + self.active_excerpt(cx).and_then(|(_, buffer, _)| { + let project_path = buffer.read(cx).project_path(cx)?; + let project = self.project.as_ref()?.read(cx); + let entry = project.entry_for_path(&project_path, cx)?; + let path = entry.path.to_path_buf(); + Some(path) + }) + } + + pub fn reveal_in_finder( + &mut self, + _: &RevealInFileManager, + _window: &mut Window, + cx: &mut Context, + ) { + if let Some(target) = self.target_file(cx) { + cx.reveal_path(&target.abs_path(cx)); + } + } + + pub fn copy_path( + &mut self, + _: &zed_actions::workspace::CopyPath, + _window: &mut Window, + cx: &mut Context, + ) { + if let Some(path) = self.target_file_abs_path(cx) { + if let Some(path) = path.to_str() { + cx.write_to_clipboard(ClipboardItem::new_string(path.to_string())); + } + } + } + + pub fn copy_relative_path( + &mut self, + _: &zed_actions::workspace::CopyRelativePath, + _window: &mut Window, + cx: &mut Context, + ) { + if let Some(path) = self.target_file_path(cx) { + if let Some(path) = path.to_str() { + cx.write_to_clipboard(ClipboardItem::new_string(path.to_string())); + } + } + } + + pub fn project_path(&self, cx: &App) -> Option { + if let Some(buffer) = self.buffer.read(cx).as_singleton() { + buffer.read(cx).project_path(cx) + } else { + None + } + } + + // Returns true if the editor handled a go-to-line request + pub fn go_to_active_debug_line(&mut self, window: &mut Window, cx: &mut Context) -> bool { + maybe!({ + let breakpoint_store = self.breakpoint_store.as_ref()?; + + let Some(active_stack_frame) = breakpoint_store.read(cx).active_position().cloned() + else { + self.clear_row_highlights::(); + return None; + }; + + let position = active_stack_frame.position; + let buffer_id = position.buffer_id?; + let snapshot = self + .project + .as_ref()? + .read(cx) + .buffer_for_id(buffer_id, cx)? + .read(cx) + .snapshot(); + + let mut handled = false; + for (id, ExcerptRange { context, .. }) in + self.buffer.read(cx).excerpts_for_buffer(buffer_id, cx) + { + if context.start.cmp(&position, &snapshot).is_ge() + || context.end.cmp(&position, &snapshot).is_lt() + { + continue; + } + let snapshot = self.buffer.read(cx).snapshot(cx); + let multibuffer_anchor = snapshot.anchor_in_excerpt(id, position)?; + + handled = true; + self.clear_row_highlights::(); + self.go_to_line::( + multibuffer_anchor, + Some(cx.theme().colors().editor_debugger_active_line_background), + window, + cx, + ); + + cx.notify(); + } + + handled.then_some(()) + }) + .is_some() + } + + pub fn copy_file_name_without_extension( + &mut self, + _: &CopyFileNameWithoutExtension, + _: &mut Window, + cx: &mut Context, + ) { + if let Some(file) = self.target_file(cx) { + if let Some(file_stem) = file.path().file_stem() { + if let Some(name) = file_stem.to_str() { + cx.write_to_clipboard(ClipboardItem::new_string(name.to_string())); + } + } + } + } + + pub fn copy_file_name(&mut self, _: &CopyFileName, _: &mut Window, cx: &mut Context) { + if let Some(file) = self.target_file(cx) { + if let Some(file_name) = file.path().file_name() { + if let Some(name) = file_name.to_str() { + cx.write_to_clipboard(ClipboardItem::new_string(name.to_string())); + } + } + } + } + + pub fn toggle_git_blame( + &mut self, + _: &::git::Blame, + window: &mut Window, + cx: &mut Context, + ) { + self.show_git_blame_gutter = !self.show_git_blame_gutter; + + if self.show_git_blame_gutter && !self.has_blame_entries(cx) { + self.start_git_blame(true, window, cx); + } + + cx.notify(); + } + + pub fn toggle_git_blame_inline( + &mut self, + _: &ToggleGitBlameInline, + window: &mut Window, + cx: &mut Context, + ) { + self.toggle_git_blame_inline_internal(true, window, cx); + cx.notify(); + } + + pub fn open_git_blame_commit( + &mut self, + _: &OpenGitBlameCommit, + window: &mut Window, + cx: &mut Context, + ) { + self.open_git_blame_commit_internal(window, cx); + } + + fn open_git_blame_commit_internal( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Option<()> { + let blame = self.blame.as_ref()?; + let snapshot = self.snapshot(window, cx); + let cursor = self.selections.newest::(cx).head(); + let (buffer, point, _) = snapshot.buffer_snapshot.point_to_buffer_point(cursor)?; + let blame_entry = blame + .update(cx, |blame, cx| { + blame + .blame_for_rows( + &[RowInfo { + buffer_id: Some(buffer.remote_id()), + buffer_row: Some(point.row), + ..Default::default() + }], + cx, + ) + .next() + }) + .flatten()?; + let renderer = cx.global::().0.clone(); + let repo = blame.read(cx).repository(cx)?; + let workspace = self.workspace()?.downgrade(); + renderer.open_blame_commit(blame_entry, repo, workspace, window, cx); + None + } + + pub fn git_blame_inline_enabled(&self) -> bool { + self.git_blame_inline_enabled + } + + pub fn toggle_selection_menu( + &mut self, + _: &ToggleSelectionMenu, + _: &mut Window, + cx: &mut Context, + ) { + self.show_selection_menu = self + .show_selection_menu + .map(|show_selections_menu| !show_selections_menu) + .or_else(|| Some(!EditorSettings::get_global(cx).toolbar.selections_menu)); + + cx.notify(); + } + + pub fn selection_menu_enabled(&self, cx: &App) -> bool { + self.show_selection_menu + .unwrap_or_else(|| EditorSettings::get_global(cx).toolbar.selections_menu) + } + + fn start_git_blame( + &mut self, + user_triggered: bool, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(project) = self.project.as_ref() { + let Some(buffer) = self.buffer().read(cx).as_singleton() else { + return; + }; + + if buffer.read(cx).file().is_none() { + return; + } + + let focused = self.focus_handle(cx).contains_focused(window, cx); + + let project = project.clone(); + let blame = cx.new(|cx| GitBlame::new(buffer, project, user_triggered, focused, cx)); + self.blame_subscription = + Some(cx.observe_in(&blame, window, |_, _, _, cx| cx.notify())); + self.blame = Some(blame); + } + } + + fn toggle_git_blame_inline_internal( + &mut self, + user_triggered: bool, + window: &mut Window, + cx: &mut Context, + ) { + if self.git_blame_inline_enabled { + self.git_blame_inline_enabled = false; + self.show_git_blame_inline = false; + self.show_git_blame_inline_delay_task.take(); + } else { + self.git_blame_inline_enabled = true; + self.start_git_blame_inline(user_triggered, window, cx); + } + + cx.notify(); + } + + fn start_git_blame_inline( + &mut self, + user_triggered: bool, + window: &mut Window, + cx: &mut Context, + ) { + self.start_git_blame(user_triggered, window, cx); + + if ProjectSettings::get_global(cx) + .git + .inline_blame_delay() + .is_some() + { + self.start_inline_blame_timer(window, cx); + } else { + self.show_git_blame_inline = true + } + } + + pub fn blame(&self) -> Option<&Entity> { + self.blame.as_ref() + } + + pub fn show_git_blame_gutter(&self) -> bool { + self.show_git_blame_gutter + } + + pub fn render_git_blame_gutter(&self, cx: &App) -> bool { + self.show_git_blame_gutter && self.has_blame_entries(cx) + } + + pub fn render_git_blame_inline(&self, window: &Window, cx: &App) -> bool { + self.show_git_blame_inline + && (self.focus_handle.is_focused(window) || self.inline_blame_popover.is_some()) + && !self.newest_selection_head_on_empty_line(cx) + && self.has_blame_entries(cx) + } + + fn has_blame_entries(&self, cx: &App) -> bool { + self.blame() + .map_or(false, |blame| blame.read(cx).has_generated_entries()) + } + + fn newest_selection_head_on_empty_line(&self, cx: &App) -> bool { + let cursor_anchor = self.selections.newest_anchor().head(); + + let snapshot = self.buffer.read(cx).snapshot(cx); + let buffer_row = MultiBufferRow(cursor_anchor.to_point(&snapshot).row); + + snapshot.line_len(buffer_row) == 0 + } + + fn get_permalink_to_line(&self, cx: &mut Context) -> Task> { + let buffer_and_selection = maybe!({ + let selection = self.selections.newest::(cx); + let selection_range = selection.range(); + + let multi_buffer = self.buffer().read(cx); + let multi_buffer_snapshot = multi_buffer.snapshot(cx); + let buffer_ranges = multi_buffer_snapshot.range_to_buffer_ranges(selection_range); + + let (buffer, range, _) = if selection.reversed { + buffer_ranges.first() + } else { + 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().clone(), + selection, + )) + }); + + let Some((buffer, selection)) = buffer_and_selection else { + return Task::ready(Err(anyhow!("failed to determine buffer and selection"))); + }; + + let Some(project) = self.project.as_ref() else { + return Task::ready(Err(anyhow!("editor does not have project"))); + }; + + project.update(cx, |project, cx| { + project.get_permalink_to_line(&buffer, selection, cx) + }) + } + + pub fn copy_permalink_to_line( + &mut self, + _: &CopyPermalinkToLine, + window: &mut Window, + cx: &mut Context, + ) { + let permalink_task = self.get_permalink_to_line(cx); + let workspace = self.workspace(); + + cx.spawn_in(window, async move |_, cx| match permalink_task.await { + Ok(permalink) => { + cx.update(|_, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(permalink.to_string())); + }) + .ok(); + } + Err(err) => { + let message = format!("Failed to copy permalink: {err}"); + + anyhow::Result::<()>::Err(err).log_err(); + + if let Some(workspace) = workspace { + workspace + .update_in(cx, |workspace, _, cx| { + struct CopyPermalinkToLine; + + workspace.show_toast( + Toast::new( + NotificationId::unique::(), + message, + ), + cx, + ) + }) + .ok(); + } + } + }) + .detach(); + } + + pub fn copy_file_location( + &mut self, + _: &CopyFileLocation, + _: &mut Window, + cx: &mut Context, + ) { + let selection = self.selections.newest::(cx).start.row + 1; + if let Some(file) = self.target_file(cx) { + if let Some(path) = file.path().to_str() { + cx.write_to_clipboard(ClipboardItem::new_string(format!("{path}:{selection}"))); + } + } + } + + pub fn open_permalink_to_line( + &mut self, + _: &OpenPermalinkToLine, + window: &mut Window, + cx: &mut Context, + ) { + let permalink_task = self.get_permalink_to_line(cx); + let workspace = self.workspace(); + + cx.spawn_in(window, async move |_, cx| match permalink_task.await { + Ok(permalink) => { + cx.update(|_, cx| { + cx.open_url(permalink.as_ref()); + }) + .ok(); + } + Err(err) => { + let message = format!("Failed to open permalink: {err}"); + + anyhow::Result::<()>::Err(err).log_err(); + + if let Some(workspace) = workspace { + workspace + .update(cx, |workspace, cx| { + struct OpenPermalinkToLine; + + workspace.show_toast( + Toast::new( + NotificationId::unique::(), + message, + ), + cx, + ) + }) + .ok(); + } + } + }) + .detach(); + } + + pub fn insert_uuid_v4( + &mut self, + _: &InsertUuidV4, + window: &mut Window, + cx: &mut Context, + ) { + self.insert_uuid(UuidVersion::V4, window, cx); + } + + pub fn insert_uuid_v7( + &mut self, + _: &InsertUuidV7, + window: &mut Window, + cx: &mut Context, + ) { + self.insert_uuid(UuidVersion::V7, window, cx); + } + + fn insert_uuid(&mut self, version: UuidVersion, window: &mut Window, cx: &mut Context) { + self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); + self.transact(window, cx, |this, window, cx| { + let edits = this + .selections + .all::(cx) + .into_iter() + .map(|selection| { + let uuid = match version { + UuidVersion::V4 => uuid::Uuid::new_v4(), + UuidVersion::V7 => uuid::Uuid::now_v7(), + }; + + (selection.range(), uuid.to_string()) + }); + this.edit(edits, cx); + this.refresh_inline_completion(true, false, window, cx); + }); + } + + pub fn open_selections_in_multibuffer( + &mut self, + _: &OpenSelectionsInMultibuffer, + window: &mut Window, + cx: &mut Context, + ) { + let multibuffer = self.buffer.read(cx); + + let Some(buffer) = multibuffer.as_singleton() else { + return; + }; + + let Some(workspace) = self.workspace() else { + return; + }; + + let locations = self + .selections + .disjoint_anchors() + .iter() + .map(|range| Location { + buffer: buffer.clone(), + range: range.start.text_anchor..range.end.text_anchor, + }) + .collect::>(); + + let title = multibuffer.title(cx).to_string(); + + cx.spawn_in(window, async move |_, cx| { + workspace.update_in(cx, |workspace, window, cx| { + Self::open_locations_in_multibuffer( + workspace, + locations, + format!("Selections for '{title}'"), + false, + MultibufferSelectionMode::All, + window, + cx, + ); + }) + }) + .detach(); + } + + /// Adds a row highlight for the given range. If a row has multiple highlights, the + /// last highlight added will be used. + /// + /// If the range ends at the beginning of a line, then that line will not be highlighted. + pub fn highlight_rows( + &mut self, + range: Range, + color: Hsla, + options: RowHighlightOptions, + cx: &mut Context, + ) { + let snapshot = self.buffer().read(cx).snapshot(cx); + let row_highlights = self.highlighted_rows.entry(TypeId::of::()).or_default(); + let ix = row_highlights.binary_search_by(|highlight| { + Ordering::Equal + .then_with(|| highlight.range.start.cmp(&range.start, &snapshot)) + .then_with(|| highlight.range.end.cmp(&range.end, &snapshot)) + }); + + if let Err(mut ix) = ix { + let index = post_inc(&mut self.highlight_order); + + // If this range intersects with the preceding highlight, then merge it with + // the preceding highlight. Otherwise insert a new highlight. + let mut merged = false; + if ix > 0 { + let prev_highlight = &mut row_highlights[ix - 1]; + if prev_highlight + .range + .end + .cmp(&range.start, &snapshot) + .is_ge() + { + ix -= 1; + if prev_highlight.range.end.cmp(&range.end, &snapshot).is_lt() { + prev_highlight.range.end = range.end; + } + merged = true; + prev_highlight.index = index; + prev_highlight.color = color; + prev_highlight.options = options; + } + } + + if !merged { + row_highlights.insert( + ix, + RowHighlight { + range: range.clone(), + index, + color, + options, + type_id: TypeId::of::(), + }, + ); + } + + // If any of the following highlights intersect with this one, merge them. + while let Some(next_highlight) = row_highlights.get(ix + 1) { + let highlight = &row_highlights[ix]; + if next_highlight + .range + .start + .cmp(&highlight.range.end, &snapshot) + .is_le() + { + if next_highlight + .range + .end + .cmp(&highlight.range.end, &snapshot) + .is_gt() + { + row_highlights[ix].range.end = next_highlight.range.end; + } + row_highlights.remove(ix + 1); + } else { + break; + } + } + } + } + + /// Remove any highlighted row ranges of the given type that intersect the + /// given ranges. + pub fn remove_highlighted_rows( + &mut self, + ranges_to_remove: Vec>, + cx: &mut Context, + ) { + let snapshot = self.buffer().read(cx).snapshot(cx); + let row_highlights = self.highlighted_rows.entry(TypeId::of::()).or_default(); + let mut ranges_to_remove = ranges_to_remove.iter().peekable(); + row_highlights.retain(|highlight| { + while let Some(range_to_remove) = ranges_to_remove.peek() { + match range_to_remove.end.cmp(&highlight.range.start, &snapshot) { + Ordering::Less | Ordering::Equal => { + ranges_to_remove.next(); + } + Ordering::Greater => { + match range_to_remove.start.cmp(&highlight.range.end, &snapshot) { + Ordering::Less | Ordering::Equal => { + return false; + } + Ordering::Greater => break, + } + } + } + } + + true + }) + } + + /// Clear all anchor ranges for a certain highlight context type, so no corresponding rows will be highlighted. + pub fn clear_row_highlights(&mut self) { + self.highlighted_rows.remove(&TypeId::of::()); + } + + /// For a highlight given context type, gets all anchor ranges that will be used for row highlighting. + pub fn highlighted_rows(&self) -> impl '_ + Iterator, Hsla)> { + self.highlighted_rows + .get(&TypeId::of::()) + .map_or(&[] as &[_], |vec| vec.as_slice()) + .iter() + .map(|highlight| (highlight.range.clone(), highlight.color)) + } + + /// Merges all anchor ranges for all context types ever set, picking the last highlight added in case of a row conflict. + /// Returns a map of display rows that are highlighted and their corresponding highlight color. + /// Allows to ignore certain kinds of highlights. + pub fn highlighted_display_rows( + &self, + window: &mut Window, + cx: &mut App, + ) -> BTreeMap { + let snapshot = self.snapshot(window, cx); + let mut used_highlight_orders = HashMap::default(); + self.highlighted_rows + .iter() + .flat_map(|(_, highlighted_rows)| highlighted_rows.iter()) + .fold( + BTreeMap::::new(), + |mut unique_rows, highlight| { + let start = highlight.range.start.to_display_point(&snapshot); + let end = highlight.range.end.to_display_point(&snapshot); + let start_row = start.row().0; + let end_row = if highlight.range.end.text_anchor != text::Anchor::MAX + && end.column() == 0 + { + end.row().0.saturating_sub(1) + } else { + end.row().0 + }; + for row in start_row..=end_row { + let used_index = + used_highlight_orders.entry(row).or_insert(highlight.index); + if highlight.index >= *used_index { + *used_index = highlight.index; + unique_rows.insert( + DisplayRow(row), + LineHighlight { + include_gutter: highlight.options.include_gutter, + border: None, + background: highlight.color.into(), + type_id: Some(highlight.type_id), + }, + ); + } + } + unique_rows + }, + ) + } + + pub fn highlighted_display_row_for_autoscroll( + &self, + snapshot: &DisplaySnapshot, + ) -> Option { + self.highlighted_rows + .values() + .flat_map(|highlighted_rows| highlighted_rows.iter()) + .filter_map(|highlight| { + if highlight.options.autoscroll { + Some(highlight.range.start.to_display_point(snapshot).row()) + } else { + None + } + }) + .min() + } + + pub fn set_search_within_ranges(&mut self, ranges: &[Range], cx: &mut Context) { + self.highlight_background::( + ranges, + |colors| colors.editor_document_highlight_read_background, + cx, + ) + } + + pub fn set_breadcrumb_header(&mut self, new_header: String) { + self.breadcrumb_header = Some(new_header); + } + + pub fn clear_search_within_ranges(&mut self, cx: &mut Context) { + self.clear_background_highlights::(cx); + } + + pub fn highlight_background( + &mut self, + ranges: &[Range], + color_fetcher: fn(&ThemeColors) -> Hsla, + cx: &mut Context, + ) { + self.background_highlights + .insert(TypeId::of::(), (color_fetcher, Arc::from(ranges))); + self.scrollbar_marker_state.dirty = true; + cx.notify(); + } + + pub fn clear_background_highlights( + &mut self, + cx: &mut Context, + ) -> Option { + let text_highlights = self.background_highlights.remove(&TypeId::of::())?; + if !text_highlights.1.is_empty() { + self.scrollbar_marker_state.dirty = true; + cx.notify(); + } + Some(text_highlights) + } + + pub fn highlight_gutter( + &mut self, + ranges: &[Range], + color_fetcher: fn(&App) -> Hsla, + cx: &mut Context, + ) { + self.gutter_highlights + .insert(TypeId::of::(), (color_fetcher, Arc::from(ranges))); + cx.notify(); + } + + pub fn clear_gutter_highlights( + &mut self, + cx: &mut Context, + ) -> Option { + cx.notify(); + self.gutter_highlights.remove(&TypeId::of::()) + } + + #[cfg(feature = "test-support")] + pub fn all_text_background_highlights( + &self, + window: &mut Window, + cx: &mut Context, + ) -> Vec<(Range, Hsla)> { + let snapshot = self.snapshot(window, cx); + let buffer = &snapshot.buffer_snapshot; + let start = buffer.anchor_before(0); + let end = buffer.anchor_after(buffer.len()); + let theme = cx.theme().colors(); + self.background_highlights_in_range(start..end, &snapshot, theme) + } + + #[cfg(feature = "test-support")] + pub fn search_background_highlights(&mut self, cx: &mut Context) -> Vec> { + let snapshot = self.buffer().read(cx).snapshot(cx); + + let highlights = self + .background_highlights + .get(&TypeId::of::()); + + if let Some((_color, ranges)) = highlights { + ranges + .iter() + .map(|range| range.start.to_point(&snapshot)..range.end.to_point(&snapshot)) + .collect_vec() + } else { + vec![] + } + } + + fn document_highlights_for_position<'a>( + &'a self, + position: Anchor, + buffer: &'a MultiBufferSnapshot, + ) -> impl 'a + Iterator> { + let read_highlights = self + .background_highlights + .get(&TypeId::of::()) + .map(|h| &h.1); + let write_highlights = self + .background_highlights + .get(&TypeId::of::()) + .map(|h| &h.1); + let left_position = position.bias_left(buffer); + let right_position = position.bias_right(buffer); + read_highlights + .into_iter() + .chain(write_highlights) + .flat_map(move |ranges| { + let start_ix = match ranges.binary_search_by(|probe| { + let cmp = probe.end.cmp(&left_position, buffer); + if cmp.is_ge() { + Ordering::Greater + } else { + Ordering::Less + } + }) { + Ok(i) | Err(i) => i, + }; + + ranges[start_ix..] + .iter() + .take_while(move |range| range.start.cmp(&right_position, buffer).is_le()) + }) + } + + pub fn has_background_highlights(&self) -> bool { + self.background_highlights + .get(&TypeId::of::()) + .map_or(false, |(_, highlights)| !highlights.is_empty()) + } + + pub fn background_highlights_in_range( + &self, + search_range: Range, + display_snapshot: &DisplaySnapshot, + theme: &ThemeColors, + ) -> Vec<(Range, Hsla)> { + let mut results = Vec::new(); + for (color_fetcher, ranges) in self.background_highlights.values() { + let color = color_fetcher(theme); + let start_ix = match ranges.binary_search_by(|probe| { + let cmp = probe + .end + .cmp(&search_range.start, &display_snapshot.buffer_snapshot); + if cmp.is_gt() { + Ordering::Greater + } else { + Ordering::Less + } + }) { + Ok(i) | Err(i) => i, + }; + for range in &ranges[start_ix..] { + if range + .start + .cmp(&search_range.end, &display_snapshot.buffer_snapshot) + .is_ge() + { + break; + } + + let start = range.start.to_display_point(display_snapshot); + let end = range.end.to_display_point(display_snapshot); + results.push((start..end, color)) + } + } + results + } + + pub fn background_highlight_row_ranges( + &self, + search_range: Range, + display_snapshot: &DisplaySnapshot, + count: usize, + ) -> Vec> { + let mut results = Vec::new(); + let Some((_, ranges)) = self.background_highlights.get(&TypeId::of::()) else { + return vec![]; + }; + + let start_ix = match ranges.binary_search_by(|probe| { + let cmp = probe + .end + .cmp(&search_range.start, &display_snapshot.buffer_snapshot); + if cmp.is_gt() { + Ordering::Greater + } else { + Ordering::Less + } + }) { + Ok(i) | Err(i) => i, + }; + let mut push_region = |start: Option, end: Option| { + if let (Some(start_display), Some(end_display)) = (start, end) { + results.push( + start_display.to_display_point(display_snapshot) + ..=end_display.to_display_point(display_snapshot), + ); + } + }; + let mut start_row: Option = None; + let mut end_row: Option = None; + if ranges.len() > count { + return Vec::new(); + } + for range in &ranges[start_ix..] { + if range + .start + .cmp(&search_range.end, &display_snapshot.buffer_snapshot) + .is_ge() + { + break; + } + let end = range.end.to_point(&display_snapshot.buffer_snapshot); + if let Some(current_row) = &end_row { + if end.row == current_row.row { + continue; + } + } + let start = range.start.to_point(&display_snapshot.buffer_snapshot); + if start_row.is_none() { + assert_eq!(end_row, None); + start_row = Some(start); + end_row = Some(end); + continue; + } + if let Some(current_end) = end_row.as_mut() { + if start.row > current_end.row + 1 { + push_region(start_row, end_row); + start_row = Some(start); + end_row = Some(end); + } else { + // Merge two hunks. + *current_end = end; + } + } else { + unreachable!(); + } + } + // We might still have a hunk that was not rendered (if there was a search hit on the last line) + push_region(start_row, end_row); + results + } + + pub fn gutter_highlights_in_range( + &self, + search_range: Range, + display_snapshot: &DisplaySnapshot, + cx: &App, + ) -> Vec<(Range, Hsla)> { + let mut results = Vec::new(); + for (color_fetcher, ranges) in self.gutter_highlights.values() { + let color = color_fetcher(cx); + let start_ix = match ranges.binary_search_by(|probe| { + let cmp = probe + .end + .cmp(&search_range.start, &display_snapshot.buffer_snapshot); + if cmp.is_gt() { + Ordering::Greater + } else { + Ordering::Less + } + }) { + Ok(i) | Err(i) => i, + }; + for range in &ranges[start_ix..] { + if range + .start + .cmp(&search_range.end, &display_snapshot.buffer_snapshot) + .is_ge() + { + break; + } + + let start = range.start.to_display_point(display_snapshot); + let end = range.end.to_display_point(display_snapshot); + results.push((start..end, color)) + } + } + results + } + + /// Get the text ranges corresponding to the redaction query + pub fn redacted_ranges( + &self, + search_range: Range, + display_snapshot: &DisplaySnapshot, + cx: &App, + ) -> Vec> { + display_snapshot + .buffer_snapshot + .redacted_ranges(search_range, |file| { + if let Some(file) = file { + file.is_private() + && EditorSettings::get( + Some(SettingsLocation { + worktree_id: file.worktree_id(cx), + path: file.path().as_ref(), + }), + cx, + ) + .redact_private_values + } else { + false + } + }) + .map(|range| { + range.start.to_display_point(display_snapshot) + ..range.end.to_display_point(display_snapshot) + }) + .collect() + } + + pub fn highlight_text( + &mut self, + ranges: Vec>, + style: HighlightStyle, + cx: &mut Context, + ) { + self.display_map.update(cx, |map, _| { + map.highlight_text(TypeId::of::(), ranges, style) + }); + cx.notify(); + } + + pub(crate) fn highlight_inlays( + &mut self, + highlights: Vec, + style: HighlightStyle, + cx: &mut Context, + ) { + self.display_map.update(cx, |map, _| { + map.highlight_inlays(TypeId::of::(), highlights, style) + }); + cx.notify(); + } + + pub fn text_highlights<'a, T: 'static>( + &'a self, + cx: &'a App, + ) -> Option<(HighlightStyle, &'a [Range])> { + self.display_map.read(cx).text_highlights(TypeId::of::()) + } + + pub fn clear_highlights(&mut self, cx: &mut Context) { + let cleared = self + .display_map + .update(cx, |map, _| map.clear_highlights(TypeId::of::())); + if cleared { + cx.notify(); + } + } + + pub fn show_local_cursors(&self, window: &mut Window, cx: &mut App) -> bool { + (self.read_only(cx) || self.blink_manager.read(cx).visible()) + && self.focus_handle.is_focused(window) + } + + pub fn set_show_cursor_when_unfocused(&mut self, is_enabled: bool, cx: &mut Context) { + self.show_cursor_when_unfocused = is_enabled; + cx.notify(); + } + + fn on_buffer_changed(&mut self, _: Entity, cx: &mut Context) { + cx.notify(); + } + + fn on_debug_session_event( + &mut self, + _session: Entity, + event: &SessionEvent, + cx: &mut Context, + ) { + match event { + SessionEvent::InvalidateInlineValue => { + self.refresh_inline_values(cx); + } + _ => {} + } + } + + fn refresh_inline_values(&mut self, cx: &mut Context) { + let Some(project) = self.project.clone() else { + return; + }; + let Some(buffer) = self.buffer.read(cx).as_singleton() else { + return; + }; + if !self.inline_value_cache.enabled { + let inlays = std::mem::take(&mut self.inline_value_cache.inlays); + self.splice_inlays(&inlays, Vec::new(), cx); + return; + } + + let current_execution_position = self + .highlighted_rows + .get(&TypeId::of::()) + .and_then(|lines| lines.last().map(|line| line.range.start)); + + self.inline_value_cache.refresh_task = cx.spawn(async move |editor, cx| { + let snapshot = editor + .update(cx, |editor, cx| editor.buffer().read(cx).snapshot(cx)) + .ok()?; + + let inline_values = editor + .update(cx, |_, cx| { + let Some(current_execution_position) = current_execution_position else { + return Some(Task::ready(Ok(Vec::new()))); + }; + + // todo(debugger) when introducing multi buffer inline values check execution position's buffer id to make sure the text + // anchor is in the same buffer + let range = + buffer.read(cx).anchor_before(0)..current_execution_position.text_anchor; + project.inline_values(buffer, range, cx) + }) + .ok() + .flatten()? + .await + .context("refreshing debugger inlays") + .log_err()?; + + let (excerpt_id, buffer_id) = snapshot + .excerpts() + .next() + .map(|excerpt| (excerpt.0, excerpt.1.remote_id()))?; + editor + .update(cx, |editor, cx| { + let new_inlays = inline_values + .into_iter() + .map(|debugger_value| { + Inlay::debugger_hint( + post_inc(&mut editor.next_inlay_id), + Anchor::in_buffer(excerpt_id, buffer_id, debugger_value.position), + debugger_value.text(), + ) + }) + .collect::>(); + let mut inlay_ids = new_inlays.iter().map(|inlay| inlay.id).collect(); + std::mem::swap(&mut editor.inline_value_cache.inlays, &mut inlay_ids); + + editor.splice_inlays(&inlay_ids, new_inlays, cx); + }) + .ok()?; + Some(()) + }); + } + + fn on_buffer_event( + &mut self, + multibuffer: &Entity, + event: &multi_buffer::Event, + window: &mut Window, + cx: &mut Context, + ) { + match event { + multi_buffer::Event::Edited { + singleton_buffer_edited, + edited_buffer: buffer_edited, + } => { + self.scrollbar_marker_state.dirty = true; + self.active_indent_guides_state.dirty = true; + self.refresh_active_diagnostics(cx); + self.refresh_code_actions(window, cx); + self.refresh_selected_text_highlights(true, window, cx); + refresh_matching_bracket_highlights(self, window, cx); + if self.has_active_inline_completion() { + self.update_visible_inline_completion(window, cx); + } + if let Some(buffer) = buffer_edited { + let buffer_id = buffer.read(cx).remote_id(); + if !self.registered_buffers.contains_key(&buffer_id) { + if let Some(project) = self.project.as_ref() { + project.update(cx, |project, cx| { + self.registered_buffers.insert( + buffer_id, + project.register_buffer_with_language_servers(&buffer, cx), + ); + }) + } + } + } + cx.emit(EditorEvent::BufferEdited); + cx.emit(SearchEvent::MatchesInvalidated); + if *singleton_buffer_edited { + if let Some(project) = &self.project { + #[allow(clippy::mutable_key_type)] + let languages_affected = multibuffer.update(cx, |multibuffer, cx| { + multibuffer + .all_buffers() + .into_iter() + .filter_map(|buffer| { + buffer.update(cx, |buffer, cx| { + let language = buffer.language()?; + let should_discard = project.update(cx, |project, cx| { + project.is_local() + && !project.has_language_servers_for(buffer, cx) + }); + should_discard.not().then_some(language.clone()) + }) + }) + .collect::>() + }); + if !languages_affected.is_empty() { + self.refresh_inlay_hints( + InlayHintRefreshReason::BufferEdited(languages_affected), + cx, + ); + } + } + } + + let Some(project) = &self.project else { return }; + let (telemetry, is_via_ssh) = { + let project = project.read(cx); + let telemetry = project.client().telemetry().clone(); + let is_via_ssh = project.is_via_ssh(); + (telemetry, is_via_ssh) + }; + refresh_linked_ranges(self, window, cx); + telemetry.log_edit_event("editor", is_via_ssh); + } + multi_buffer::Event::ExcerptsAdded { + buffer, + predecessor, + excerpts, + } => { + self.tasks_update_task = Some(self.refresh_runnables(window, cx)); + let buffer_id = buffer.read(cx).remote_id(); + if self.buffer.read(cx).diff_for(buffer_id).is_none() { + if let Some(project) = &self.project { + get_uncommitted_diff_for_buffer( + project, + [buffer.clone()], + self.buffer.clone(), + cx, + ) + .detach(); + } + } + cx.emit(EditorEvent::ExcerptsAdded { + buffer: buffer.clone(), + predecessor: *predecessor, + excerpts: excerpts.clone(), + }); + self.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx); + } + multi_buffer::Event::ExcerptsRemoved { + ids, + removed_buffer_ids, + } => { + self.refresh_inlay_hints(InlayHintRefreshReason::ExcerptsRemoved(ids.clone()), cx); + let buffer = self.buffer.read(cx); + self.registered_buffers + .retain(|buffer_id, _| buffer.buffer(*buffer_id).is_some()); + jsx_tag_auto_close::refresh_enabled_in_any_buffer(self, multibuffer, cx); + cx.emit(EditorEvent::ExcerptsRemoved { + ids: ids.clone(), + removed_buffer_ids: removed_buffer_ids.clone(), + }) + } + multi_buffer::Event::ExcerptsEdited { + excerpt_ids, + buffer_ids, + } => { + self.display_map.update(cx, |map, cx| { + map.unfold_buffers(buffer_ids.iter().copied(), cx) + }); + cx.emit(EditorEvent::ExcerptsEdited { + ids: excerpt_ids.clone(), + }) + } + multi_buffer::Event::ExcerptsExpanded { ids } => { + self.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx); + cx.emit(EditorEvent::ExcerptsExpanded { ids: ids.clone() }) + } + multi_buffer::Event::Reparsed(buffer_id) => { + self.tasks_update_task = Some(self.refresh_runnables(window, cx)); + jsx_tag_auto_close::refresh_enabled_in_any_buffer(self, multibuffer, cx); + + cx.emit(EditorEvent::Reparsed(*buffer_id)); + } + multi_buffer::Event::DiffHunksToggled => { + self.tasks_update_task = Some(self.refresh_runnables(window, cx)); + } + multi_buffer::Event::LanguageChanged(buffer_id) => { + linked_editing_ranges::refresh_linked_ranges(self, window, cx); + jsx_tag_auto_close::refresh_enabled_in_any_buffer(self, multibuffer, cx); + cx.emit(EditorEvent::Reparsed(*buffer_id)); + cx.notify(); + } + multi_buffer::Event::DirtyChanged => cx.emit(EditorEvent::DirtyChanged), + multi_buffer::Event::Saved => cx.emit(EditorEvent::Saved), + multi_buffer::Event::FileHandleChanged + | multi_buffer::Event::Reloaded + | multi_buffer::Event::BufferDiffChanged => cx.emit(EditorEvent::TitleChanged), + multi_buffer::Event::Closed => cx.emit(EditorEvent::Closed), + multi_buffer::Event::DiagnosticsUpdated => { + self.refresh_active_diagnostics(cx); + self.refresh_inline_diagnostics(true, window, cx); + self.scrollbar_marker_state.dirty = true; + cx.notify(); + } + _ => {} + }; + } + + fn on_display_map_changed( + &mut self, + _: Entity, + _: &mut Window, + cx: &mut Context, + ) { + cx.notify(); + } + + fn settings_changed(&mut self, window: &mut Window, cx: &mut Context) { + self.tasks_update_task = Some(self.refresh_runnables(window, cx)); + self.update_edit_prediction_settings(cx); + self.refresh_inline_completion(true, false, window, cx); + self.refresh_inlay_hints( + InlayHintRefreshReason::SettingsChange(inlay_hint_settings( + self.selections.newest_anchor().head(), + &self.buffer.read(cx).snapshot(cx), + cx, + )), + cx, + ); + + let old_cursor_shape = self.cursor_shape; + + { + let editor_settings = EditorSettings::get_global(cx); + self.scroll_manager.vertical_scroll_margin = editor_settings.vertical_scroll_margin; + self.show_breadcrumbs = editor_settings.toolbar.breadcrumbs; + self.cursor_shape = editor_settings.cursor_shape.unwrap_or_default(); + self.hide_mouse_mode = editor_settings.hide_mouse.unwrap_or_default(); + } + + if old_cursor_shape != self.cursor_shape { + cx.emit(EditorEvent::CursorShapeChanged); + } + + let project_settings = ProjectSettings::get_global(cx); + self.serialize_dirty_buffers = project_settings.session.restore_unsaved_buffers; + + if self.mode.is_full() { + let show_inline_diagnostics = project_settings.diagnostics.inline.enabled; + let inline_blame_enabled = project_settings.git.inline_blame_enabled(); + if self.show_inline_diagnostics != show_inline_diagnostics { + self.show_inline_diagnostics = show_inline_diagnostics; + self.refresh_inline_diagnostics(false, window, cx); + } + + if self.git_blame_inline_enabled != inline_blame_enabled { + self.toggle_git_blame_inline_internal(false, window, cx); + } + } + + cx.notify(); + } + + pub fn set_searchable(&mut self, searchable: bool) { + self.searchable = searchable; + } + + pub fn searchable(&self) -> bool { + self.searchable + } + + fn open_proposed_changes_editor( + &mut self, + _: &OpenProposedChangesEditor, + window: &mut Window, + cx: &mut Context, + ) { + let Some(workspace) = self.workspace() else { + cx.propagate(); + return; + }; + + let selections = self.selections.all::(cx); + let multi_buffer = self.buffer.read(cx); + let multi_buffer_snapshot = multi_buffer.snapshot(cx); + let mut new_selections_by_buffer = HashMap::default(); + for selection in selections { + for (buffer, range, _) in + multi_buffer_snapshot.range_to_buffer_ranges(selection.start..selection.end) + { + let mut range = range.to_point(buffer); + range.start.column = 0; + range.end.column = buffer.line_len(range.end.row); + new_selections_by_buffer + .entry(multi_buffer.buffer(buffer.remote_id()).unwrap()) + .or_insert(Vec::new()) + .push(range) + } + } + + let proposed_changes_buffers = new_selections_by_buffer + .into_iter() + .map(|(buffer, ranges)| ProposedChangeLocation { buffer, ranges }) + .collect::>(); + let proposed_changes_editor = cx.new(|cx| { + ProposedChangesEditor::new( + "Proposed changes", + proposed_changes_buffers, + self.project.clone(), + window, + cx, + ) + }); + + window.defer(cx, move |window, cx| { + workspace.update(cx, |workspace, cx| { + workspace.active_pane().update(cx, |pane, cx| { + pane.add_item( + Box::new(proposed_changes_editor), + true, + true, + None, + window, + cx, + ); + }); + }); + }); + } + + pub fn open_excerpts_in_split( + &mut self, + _: &OpenExcerptsSplit, + window: &mut Window, + cx: &mut Context, + ) { + self.open_excerpts_common(None, true, window, cx) + } + + pub fn open_excerpts(&mut self, _: &OpenExcerpts, window: &mut Window, cx: &mut Context) { + self.open_excerpts_common(None, false, window, cx) + } + + fn open_excerpts_common( + &mut self, + jump_data: Option, + split: bool, + window: &mut Window, + cx: &mut Context, + ) { + let Some(workspace) = self.workspace() else { + cx.propagate(); + return; + }; + + if self.buffer.read(cx).is_singleton() { + cx.propagate(); + return; + } + + let mut new_selections_by_buffer = HashMap::default(); + match &jump_data { + Some(JumpData::MultiBufferPoint { + excerpt_id, + position, + anchor, + line_offset_from_top, + }) => { + let multi_buffer_snapshot = self.buffer.read(cx).snapshot(cx); + if let Some(buffer) = multi_buffer_snapshot + .buffer_id_for_excerpt(*excerpt_id) + .and_then(|buffer_id| self.buffer.read(cx).buffer(buffer_id)) + { + let buffer_snapshot = buffer.read(cx).snapshot(); + let jump_to_point = if buffer_snapshot.can_resolve(anchor) { + language::ToPoint::to_point(anchor, &buffer_snapshot) + } else { + buffer_snapshot.clip_point(*position, Bias::Left) + }; + let jump_to_offset = buffer_snapshot.point_to_offset(jump_to_point); + new_selections_by_buffer.insert( + buffer, + ( + vec![jump_to_offset..jump_to_offset], + Some(*line_offset_from_top), + ), + ); + } + } + Some(JumpData::MultiBufferRow { + row, + line_offset_from_top, + }) => { + let point = MultiBufferPoint::new(row.0, 0); + if let Some((buffer, buffer_point, _)) = + self.buffer.read(cx).point_to_buffer_point(point, cx) + { + let buffer_offset = buffer.read(cx).point_to_offset(buffer_point); + new_selections_by_buffer + .entry(buffer) + .or_insert((Vec::new(), Some(*line_offset_from_top))) + .0 + .push(buffer_offset..buffer_offset) + } + } + None => { + let selections = self.selections.all::(cx); + let multi_buffer = self.buffer.read(cx); + for selection in selections { + for (snapshot, range, _, anchor) in multi_buffer + .snapshot(cx) + .range_to_buffer_ranges_with_deleted_hunks(selection.range()) + { + if let Some(anchor) = anchor { + // selection is in a deleted hunk + let Some(buffer_id) = anchor.buffer_id else { + continue; + }; + let Some(buffer_handle) = multi_buffer.buffer(buffer_id) else { + continue; + }; + let offset = text::ToOffset::to_offset( + &anchor.text_anchor, + &buffer_handle.read(cx).snapshot(), + ); + let range = offset..offset; + new_selections_by_buffer + .entry(buffer_handle) + .or_insert((Vec::new(), None)) + .0 + .push(range) + } else { + let Some(buffer_handle) = multi_buffer.buffer(snapshot.remote_id()) + else { + continue; + }; + new_selections_by_buffer + .entry(buffer_handle) + .or_insert((Vec::new(), None)) + .0 + .push(range) + } + } + } + } + } + + new_selections_by_buffer + .retain(|buffer, _| Self::can_open_excerpts_in_file(buffer.read(cx).file())); + + if new_selections_by_buffer.is_empty() { + return; + } + + // We defer the pane interaction because we ourselves are a workspace item + // and activating a new item causes the pane to call a method on us reentrantly, + // which panics if we're on the stack. + window.defer(cx, move |window, cx| { + workspace.update(cx, |workspace, cx| { + let pane = if split { + workspace.adjacent_pane(window, cx) + } else { + workspace.active_pane().clone() + }; + + for (buffer, (ranges, scroll_offset)) in new_selections_by_buffer { + let editor = buffer + .read(cx) + .file() + .is_none() + .then(|| { + // Handle file-less buffers separately: those are not really the project items, so won't have a project path or entity id, + // so `workspace.open_project_item` will never find them, always opening a new editor. + // Instead, we try to activate the existing editor in the pane first. + let (editor, pane_item_index) = + pane.read(cx).items().enumerate().find_map(|(i, item)| { + let editor = item.downcast::()?; + let singleton_buffer = + editor.read(cx).buffer().read(cx).as_singleton()?; + if singleton_buffer == buffer { + Some((editor, i)) + } else { + None + } + })?; + pane.update(cx, |pane, cx| { + pane.activate_item(pane_item_index, true, true, window, cx) + }); + Some(editor) + }) + .flatten() + .unwrap_or_else(|| { + workspace.open_project_item::( + pane.clone(), + buffer, + true, + true, + window, + cx, + ) + }); + + editor.update(cx, |editor, cx| { + let autoscroll = match scroll_offset { + Some(scroll_offset) => Autoscroll::top_relative(scroll_offset as usize), + None => Autoscroll::newest(), + }; + let nav_history = editor.nav_history.take(); + editor.change_selections(Some(autoscroll), window, cx, |s| { + s.select_ranges(ranges); + }); + editor.nav_history = nav_history; + }); + } + }) + }); + } + + // For now, don't allow opening excerpts in buffers that aren't backed by + // regular project files. + fn can_open_excerpts_in_file(file: Option<&Arc>) -> bool { + file.map_or(true, |file| project::File::from_dyn(Some(file)).is_some()) + } + + fn marked_text_ranges(&self, cx: &App) -> Option>> { + let snapshot = self.buffer.read(cx).read(cx); + let (_, ranges) = self.text_highlights::(cx)?; + Some( + ranges + .iter() + .map(move |range| { + range.start.to_offset_utf16(&snapshot)..range.end.to_offset_utf16(&snapshot) + }) + .collect(), + ) + } + + fn selection_replacement_ranges( + &self, + range: Range, + cx: &mut App, + ) -> Vec> { + let selections = self.selections.all::(cx); + let newest_selection = selections + .iter() + .max_by_key(|selection| selection.id) + .unwrap(); + let start_delta = range.start.0 as isize - newest_selection.start.0 as isize; + let end_delta = range.end.0 as isize - newest_selection.end.0 as isize; + let snapshot = self.buffer.read(cx).read(cx); + selections + .into_iter() + .map(|mut selection| { + selection.start.0 = + (selection.start.0 as isize).saturating_add(start_delta) as usize; + selection.end.0 = (selection.end.0 as isize).saturating_add(end_delta) as usize; + snapshot.clip_offset_utf16(selection.start, Bias::Left) + ..snapshot.clip_offset_utf16(selection.end, Bias::Right) + }) + .collect() + } + + fn report_editor_event( + &self, + event_type: &'static str, + file_extension: Option, + cx: &App, + ) { + if cfg!(any(test, feature = "test-support")) { + return; + } + + let Some(project) = &self.project else { return }; + + // If None, we are in a file without an extension + let file = self + .buffer + .read(cx) + .as_singleton() + .and_then(|b| b.read(cx).file()); + let file_extension = file_extension.or(file + .as_ref() + .and_then(|file| Path::new(file.file_name(cx)).extension()) + .and_then(|e| e.to_str()) + .map(|a| a.to_string())); + + let vim_mode = vim_enabled(cx); + + let edit_predictions_provider = all_language_settings(file, cx).edit_predictions.provider; + let copilot_enabled = edit_predictions_provider + == language::language_settings::EditPredictionProvider::Copilot; + let copilot_enabled_for_language = self + .buffer + .read(cx) + .language_settings(cx) + .show_edit_predictions; + + let project = project.read(cx); + telemetry::event!( + event_type, + file_extension, + vim_mode, + copilot_enabled, + copilot_enabled_for_language, + edit_predictions_provider, + is_via_ssh = project.is_via_ssh(), + ); + } + + /// Copy the highlighted chunks to the clipboard as JSON. The format is an array of lines, + /// with each line being an array of {text, highlight} objects. + fn copy_highlight_json( + &mut self, + _: &CopyHighlightJson, + window: &mut Window, + cx: &mut Context, + ) { + #[derive(Serialize)] + struct Chunk<'a> { + text: String, + highlight: Option<&'a str>, + } + + let snapshot = self.buffer.read(cx).snapshot(cx); + let range = self + .selected_text_range(false, window, cx) + .and_then(|selection| { + if selection.range.is_empty() { + None + } else { + Some(selection.range) + } + }) + .unwrap_or_else(|| 0..snapshot.len()); + + let chunks = snapshot.chunks(range, true); + let mut lines = Vec::new(); + let mut line: VecDeque = VecDeque::new(); + + let Some(style) = self.style.as_ref() else { + return; + }; + + for chunk in chunks { + let highlight = chunk + .syntax_highlight_id + .and_then(|id| id.name(&style.syntax)); + let mut chunk_lines = chunk.text.split('\n').peekable(); + while let Some(text) = chunk_lines.next() { + let mut merged_with_last_token = false; + if let Some(last_token) = line.back_mut() { + if last_token.highlight == highlight { + last_token.text.push_str(text); + merged_with_last_token = true; + } + } + + if !merged_with_last_token { + line.push_back(Chunk { + text: text.into(), + highlight, + }); + } + + if chunk_lines.peek().is_some() { + if line.len() > 1 && line.front().unwrap().text.is_empty() { + line.pop_front(); + } + if line.len() > 1 && line.back().unwrap().text.is_empty() { + line.pop_back(); + } + + lines.push(mem::take(&mut line)); + } + } + } + + let Some(lines) = serde_json::to_string_pretty(&lines).log_err() else { + return; + }; + cx.write_to_clipboard(ClipboardItem::new_string(lines)); + } + + pub fn open_context_menu( + &mut self, + _: &OpenContextMenu, + window: &mut Window, + cx: &mut Context, + ) { + self.request_autoscroll(Autoscroll::newest(), cx); + let position = self.selections.newest_display(cx).start; + mouse_context_menu::deploy_context_menu(self, None, position, window, cx); + } + + pub fn inlay_hint_cache(&self) -> &InlayHintCache { + &self.inlay_hint_cache + } + + pub fn replay_insert_event( + &mut self, + text: &str, + relative_utf16_range: Option>, + window: &mut Window, + cx: &mut Context, + ) { + if !self.input_enabled { + cx.emit(EditorEvent::InputIgnored { text: text.into() }); + return; + } + if let Some(relative_utf16_range) = relative_utf16_range { + let selections = self.selections.all::(cx); + self.change_selections(None, window, cx, |s| { + let new_ranges = selections.into_iter().map(|range| { + let start = OffsetUtf16( + range + .head() + .0 + .saturating_add_signed(relative_utf16_range.start), + ); + let end = OffsetUtf16( + range + .head() + .0 + .saturating_add_signed(relative_utf16_range.end), + ); + start..end + }); + s.select_ranges(new_ranges); + }); + } + + self.handle_input(text, window, cx); + } + + pub fn supports_inlay_hints(&self, cx: &mut App) -> bool { + let Some(provider) = self.semantics_provider.as_ref() else { + return false; + }; + + let mut supports = false; + self.buffer().update(cx, |this, cx| { + this.for_each_buffer(|buffer| { + supports |= provider.supports_inlay_hints(buffer, cx); + }); + }); + + supports + } + + pub fn is_focused(&self, window: &Window) -> bool { + self.focus_handle.is_focused(window) + } + + fn handle_focus(&mut self, window: &mut Window, cx: &mut Context) { + cx.emit(EditorEvent::Focused); + + if let Some(descendant) = self + .last_focused_descendant + .take() + .and_then(|descendant| descendant.upgrade()) + { + window.focus(&descendant); + } else { + if let Some(blame) = self.blame.as_ref() { + blame.update(cx, GitBlame::focus) + } + + self.blink_manager.update(cx, |blink_manager, cx| { + blink_manager.enable(cx); + }); + self.show_cursor_names(window, cx); + self.buffer.update(cx, |buffer, cx| { + buffer.finalize_last_transaction(cx); + if self.leader_peer_id.is_none() { + buffer.set_active_selections( + &self.selections.disjoint_anchors(), + self.selections.line_mode, + self.cursor_shape, + cx, + ); + } + }); + } + } + + fn handle_focus_in(&mut self, _: &mut Window, cx: &mut Context) { + cx.emit(EditorEvent::FocusedIn) + } + + fn handle_focus_out( + &mut self, + event: FocusOutEvent, + _window: &mut Window, + cx: &mut Context, + ) { + if event.blurred != self.focus_handle { + self.last_focused_descendant = Some(event.blurred); + } + self.refresh_inlay_hints(InlayHintRefreshReason::ModifiersChanged(false), cx); + } + + pub fn handle_blur(&mut self, window: &mut Window, cx: &mut Context) { + self.blink_manager.update(cx, BlinkManager::disable); + self.buffer + .update(cx, |buffer, cx| buffer.remove_active_selections(cx)); + + if let Some(blame) = self.blame.as_ref() { + blame.update(cx, GitBlame::blur) + } + if !self.hover_state.focused(window, cx) { + hide_hover(self, cx); + } + if !self + .context_menu + .borrow() + .as_ref() + .is_some_and(|context_menu| context_menu.focused(window, cx)) + { + self.hide_context_menu(window, cx); + } + self.discard_inline_completion(false, cx); + cx.emit(EditorEvent::Blurred); + cx.notify(); + } + + pub fn register_action( + &mut self, + listener: impl Fn(&A, &mut Window, &mut App) + 'static, + ) -> Subscription { + let id = self.next_editor_action_id.post_inc(); + let listener = Arc::new(listener); + self.editor_actions.borrow_mut().insert( + id, + Box::new(move |window, _| { + let listener = listener.clone(); + window.on_action(TypeId::of::(), move |action, phase, window, cx| { + let action = action.downcast_ref().unwrap(); + if phase == DispatchPhase::Bubble { + listener(action, window, cx) + } + }) + }), + ); + + let editor_actions = self.editor_actions.clone(); + Subscription::new(move || { + editor_actions.borrow_mut().remove(&id); + }) + } + + pub fn file_header_size(&self) -> u32 { + FILE_HEADER_HEIGHT + } + + pub fn restore( + &mut self, + revert_changes: HashMap, Rope)>>, + window: &mut Window, + cx: &mut Context, + ) { + let workspace = self.workspace(); + let project = self.project.as_ref(); + let save_tasks = self.buffer().update(cx, |multi_buffer, cx| { + let mut tasks = Vec::new(); + for (buffer_id, changes) in revert_changes { + if let Some(buffer) = multi_buffer.buffer(buffer_id) { + buffer.update(cx, |buffer, cx| { + buffer.edit( + changes + .into_iter() + .map(|(range, text)| (range, text.to_string())), + None, + 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(None, window, cx, |selections| selections.refresh()); + } + + pub fn to_pixel_point( + &self, + source: multi_buffer::Anchor, + editor_snapshot: &EditorSnapshot, + window: &mut Window, + ) -> Option> { + let source_point = source.to_display_point(editor_snapshot); + self.display_to_pixel_point(source_point, editor_snapshot, window) + } + + pub fn display_to_pixel_point( + &self, + source: DisplayPoint, + editor_snapshot: &EditorSnapshot, + window: &mut Window, + ) -> Option> { + let line_height = self.style()?.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 + .scroll_position(editor_snapshot) + .y; + + if source.row().as_f32() < scroll_top.floor() { + return None; + } + let source_x = editor_snapshot.x_for_display_point(source, &text_layout_details); + let source_y = line_height * (source.row().as_f32() - scroll_top); + Some(gpui::Point::new(source_x, source_y)) + } + + pub fn has_visible_completions_menu(&self) -> bool { + !self.edit_prediction_preview_is_active() + && self.context_menu.borrow().as_ref().map_or(false, |menu| { + menu.visible() && matches!(menu, CodeContextMenu::Completions(_)) + }) + } + + pub fn register_addon(&mut self, instance: T) { + self.addons + .insert(std::any::TypeId::of::(), Box::new(instance)); + } + + pub fn unregister_addon(&mut self) { + self.addons.remove(&std::any::TypeId::of::()); + } + + pub fn addon(&self) -> Option<&T> { + let type_id = std::any::TypeId::of::(); + self.addons + .get(&type_id) + .and_then(|item| item.to_any().downcast_ref::()) + } + + pub fn addon_mut(&mut self) -> Option<&mut T> { + let type_id = std::any::TypeId::of::(); + self.addons + .get_mut(&type_id) + .and_then(|item| item.to_any_mut()?.downcast_mut::()) + } + + fn character_size(&self, window: &mut Window) -> gpui::Size { + let text_layout_details = self.text_layout_details(window); + let style = &text_layout_details.editor_style; + let font_id = window.text_system().resolve_font(&style.text.font()); + let font_size = style.text.font_size.to_pixels(window.rem_size()); + let line_height = style.text.line_height_in_pixels(window.rem_size()); + let em_width = window.text_system().em_width(font_id, font_size).unwrap(); + + gpui::Size::new(em_width, line_height) + } + + pub fn wait_for_diff_to_load(&self) -> Option>> { + self.load_diff_task.clone() + } + + fn read_metadata_from_db( + &mut self, + item_id: u64, + workspace_id: WorkspaceId, + window: &mut Window, + cx: &mut Context, + ) { + if self.is_singleton(cx) + && WorkspaceSettings::get(None, cx).restore_on_startup != RestoreOnStartupBehavior::None + { + let buffer_snapshot = OnceCell::new(); + + if let Some(folds) = DB.get_editor_folds(item_id, workspace_id).log_err() { + if !folds.is_empty() { + let snapshot = + buffer_snapshot.get_or_init(|| self.buffer.read(cx).snapshot(cx)); + self.fold_ranges( + folds + .into_iter() + .map(|(start, end)| { + snapshot.clip_offset(start, Bias::Left) + ..snapshot.clip_offset(end, Bias::Right) + }) + .collect(), + false, + window, + cx, + ); + } + } + + if let Some(selections) = DB.get_editor_selections(item_id, workspace_id).log_err() { + if !selections.is_empty() { + let snapshot = + buffer_snapshot.get_or_init(|| self.buffer.read(cx).snapshot(cx)); + self.change_selections(None, window, cx, |s| { + s.select_ranges(selections.into_iter().map(|(start, end)| { + snapshot.clip_offset(start, Bias::Left) + ..snapshot.clip_offset(end, Bias::Right) + })); + }); + } + }; + } + + self.read_scroll_position_from_db(item_id, workspace_id, window, cx); + } +} + +fn vim_enabled(cx: &App) -> bool { + cx.global::() + .raw_user_settings() + .get("vim_mode") + == Some(&serde_json::Value::Bool(true)) +} + +// Consider user intent and default settings +fn choose_completion_range( + completion: &Completion, + intent: CompletionIntent, + buffer: &Entity, + cx: &mut Context, +) -> Range { + fn should_replace( + completion: &Completion, + insert_range: &Range, + intent: CompletionIntent, + completion_mode_setting: LspInsertMode, + buffer: &Buffer, + ) -> bool { + // specific actions take precedence over settings + match intent { + CompletionIntent::CompleteWithInsert => return false, + CompletionIntent::CompleteWithReplace => return true, + CompletionIntent::Complete | CompletionIntent::Compose => {} + } + + match completion_mode_setting { + LspInsertMode::Insert => false, + LspInsertMode::Replace => true, + LspInsertMode::ReplaceSubsequence => { + let mut text_to_replace = buffer.chars_for_range( + buffer.anchor_before(completion.replace_range.start) + ..buffer.anchor_after(completion.replace_range.end), + ); + let mut completion_text = completion.new_text.chars(); + + // is `text_to_replace` a subsequence of `completion_text` + text_to_replace + .all(|needle_ch| completion_text.any(|haystack_ch| haystack_ch == needle_ch)) + } + LspInsertMode::ReplaceSuffix => { + let range_after_cursor = insert_range.end..completion.replace_range.end; + + let text_after_cursor = buffer + .text_for_range( + buffer.anchor_before(range_after_cursor.start) + ..buffer.anchor_after(range_after_cursor.end), + ) + .collect::(); + completion.new_text.ends_with(&text_after_cursor) + } + } + } + + let buffer = buffer.read(cx); + + if let CompletionSource::Lsp { + insert_range: Some(insert_range), + .. + } = &completion.source + { + let completion_mode_setting = + language_settings(buffer.language().map(|l| l.name()), buffer.file(), cx) + .completions + .lsp_insert_mode; + + if !should_replace( + completion, + &insert_range, + intent, + completion_mode_setting, + buffer, + ) { + return insert_range.to_offset(buffer); + } + } + + completion.replace_range.to_offset(buffer) +} + +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(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.clone()) + .filter(move |pair| { + pair.open_range.start <= range.start && pair.close_range.end >= range.end + }) + { + 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) + .chain(buffer.chars_for_range(range.end..pair.close_range.start)) + .all(|c| c.is_whitespace() && c != '\n') +} + +fn get_uncommitted_diff_for_buffer( + project: &Entity, + buffers: impl IntoIterator>, + buffer: Entity, + cx: &mut App, +) -> Task<()> { + let mut tasks = Vec::new(); + project.update(cx, |project, cx| { + for buffer in buffers { + if project::File::from_dyn(buffer.read(cx).file()).is_some() { + tasks.push(project.open_uncommitted_diff(buffer.clone(), cx)) + } + } + }); + cx.spawn(async move |cx| { + let diffs = future::join_all(tasks).await; + buffer + .update(cx, |buffer, cx| { + for diff in diffs.into_iter().flatten() { + buffer.add_diff(diff, cx); + } + }) + .ok(); + }) +} + +fn char_len_with_expanded_tabs(offset: usize, text: &str, tab_size: NonZeroU32) -> usize { + let tab_size = tab_size.get() as usize; + let mut width = offset; + + for ch in text.chars() { + width += if ch == '\t' { + tab_size - (width % tab_size) + } else { + 1 + }; + } + + width - offset +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_string_size_with_expanded_tabs() { + let nz = |val| NonZeroU32::new(val).unwrap(); + assert_eq!(char_len_with_expanded_tabs(0, "", nz(4)), 0); + assert_eq!(char_len_with_expanded_tabs(0, "hello", nz(4)), 5); + assert_eq!(char_len_with_expanded_tabs(0, "\thello", nz(4)), 9); + assert_eq!(char_len_with_expanded_tabs(0, "abc\tab", nz(4)), 6); + assert_eq!(char_len_with_expanded_tabs(0, "hello\t", nz(4)), 8); + assert_eq!(char_len_with_expanded_tabs(0, "\t\t", nz(8)), 16); + assert_eq!(char_len_with_expanded_tabs(0, "x\t", nz(8)), 8); + assert_eq!(char_len_with_expanded_tabs(7, "x\t", nz(8)), 9); + } +} + +/// Tokenizes a string into runs of text that should stick together, or that is whitespace. +struct WordBreakingTokenizer<'a> { + input: &'a str, +} + +impl<'a> WordBreakingTokenizer<'a> { + fn new(input: &'a str) -> Self { + Self { input } + } +} + +fn is_char_ideographic(ch: char) -> bool { + use unicode_script::Script::*; + use unicode_script::UnicodeScript; + matches!(ch.script(), Han | Tangut | Yi) +} + +fn is_grapheme_ideographic(text: &str) -> bool { + text.chars().any(is_char_ideographic) +} + +fn is_grapheme_whitespace(text: &str) -> bool { + text.chars().any(|x| x.is_whitespace()) +} + +fn should_stay_with_preceding_ideograph(text: &str) -> bool { + text.chars().next().map_or(false, |ch| { + matches!(ch, '。' | '、' | ',' | '?' | '!' | ':' | ';' | '…') + }) +} + +#[derive(PartialEq, Eq, Debug, Clone, Copy)] +enum WordBreakToken<'a> { + Word { token: &'a str, grapheme_len: usize }, + InlineWhitespace { token: &'a str, grapheme_len: usize }, + Newline, +} + +impl<'a> Iterator for WordBreakingTokenizer<'a> { + /// Yields a span, the count of graphemes in the token, and whether it was + /// whitespace. Note that it also breaks at word boundaries. + type Item = WordBreakToken<'a>; + + fn next(&mut self) -> Option { + use unicode_segmentation::UnicodeSegmentation; + if self.input.is_empty() { + return None; + } + + let mut iter = self.input.graphemes(true).peekable(); + let mut offset = 0; + let mut grapheme_len = 0; + if let Some(first_grapheme) = iter.next() { + let is_newline = first_grapheme == "\n"; + let is_whitespace = is_grapheme_whitespace(first_grapheme); + offset += first_grapheme.len(); + grapheme_len += 1; + if is_grapheme_ideographic(first_grapheme) && !is_whitespace { + if let Some(grapheme) = iter.peek().copied() { + if should_stay_with_preceding_ideograph(grapheme) { + offset += grapheme.len(); + grapheme_len += 1; + } + } + } else { + let mut words = self.input[offset..].split_word_bound_indices().peekable(); + let mut next_word_bound = words.peek().copied(); + if next_word_bound.map_or(false, |(i, _)| i == 0) { + next_word_bound = words.next(); + } + while let Some(grapheme) = iter.peek().copied() { + if next_word_bound.map_or(false, |(i, _)| i == offset) { + break; + }; + if is_grapheme_whitespace(grapheme) != is_whitespace + || (grapheme == "\n") != is_newline + { + break; + }; + offset += grapheme.len(); + grapheme_len += 1; + iter.next(); + } + } + let token = &self.input[..offset]; + self.input = &self.input[offset..]; + if token == "\n" { + Some(WordBreakToken::Newline) + } else if is_whitespace { + Some(WordBreakToken::InlineWhitespace { + token, + grapheme_len, + }) + } else { + Some(WordBreakToken::Word { + token, + grapheme_len, + }) + } + } else { + None + } + } +} + +#[test] +fn test_word_breaking_tokenizer() { + let tests: &[(&str, &[WordBreakToken<'static>])] = &[ + ("", &[]), + (" ", &[whitespace(" ", 2)]), + ("Ʒ", &[word("Ʒ", 1)]), + ("Ǽ", &[word("Ǽ", 1)]), + ("⋑", &[word("⋑", 1)]), + ("⋑⋑", &[word("⋑⋑", 2)]), + ( + "原理,进而", + &[word("原", 1), word("理,", 2), word("进", 1), word("而", 1)], + ), + ( + "hello world", + &[word("hello", 5), whitespace(" ", 1), word("world", 5)], + ), + ( + "hello, world", + &[word("hello,", 6), whitespace(" ", 1), word("world", 5)], + ), + ( + " hello world", + &[ + whitespace(" ", 2), + word("hello", 5), + whitespace(" ", 1), + word("world", 5), + ], + ), + ( + "这是什么 \n 钢笔", + &[ + word("这", 1), + word("是", 1), + word("什", 1), + word("么", 1), + whitespace(" ", 1), + newline(), + whitespace(" ", 1), + word("钢", 1), + word("笔", 1), + ], + ), + (" mutton", &[whitespace(" ", 1), word("mutton", 6)]), + ]; + + fn word(token: &'static str, grapheme_len: usize) -> WordBreakToken<'static> { + WordBreakToken::Word { + token, + grapheme_len, + } + } + + fn whitespace(token: &'static str, grapheme_len: usize) -> WordBreakToken<'static> { + WordBreakToken::InlineWhitespace { + token, + grapheme_len, + } + } + + fn newline() -> WordBreakToken<'static> { + WordBreakToken::Newline + } + + for (input, result) in tests { + assert_eq!( + WordBreakingTokenizer::new(input) + .collect::>() + .as_slice(), + *result, + ); + } +} + +fn wrap_with_prefix( + line_prefix: String, + unwrapped_text: String, + wrap_column: usize, + tab_size: NonZeroU32, + preserve_existing_whitespace: bool, +) -> String { + let line_prefix_len = char_len_with_expanded_tabs(0, &line_prefix, tab_size); + let mut wrapped_text = String::new(); + let mut current_line = line_prefix.clone(); + + let tokenizer = WordBreakingTokenizer::new(&unwrapped_text); + let mut current_line_len = line_prefix_len; + let mut in_whitespace = false; + for token in tokenizer { + let have_preceding_whitespace = in_whitespace; + match token { + WordBreakToken::Word { + token, + grapheme_len, + } => { + in_whitespace = false; + if current_line_len + grapheme_len > wrap_column + && current_line_len != line_prefix_len + { + wrapped_text.push_str(current_line.trim_end()); + wrapped_text.push('\n'); + current_line.truncate(line_prefix.len()); + current_line_len = line_prefix_len; + } + current_line.push_str(token); + current_line_len += grapheme_len; + } + WordBreakToken::InlineWhitespace { + mut token, + mut grapheme_len, + } => { + in_whitespace = true; + if have_preceding_whitespace && !preserve_existing_whitespace { + continue; + } + if !preserve_existing_whitespace { + token = " "; + grapheme_len = 1; + } + if current_line_len + grapheme_len > wrap_column { + wrapped_text.push_str(current_line.trim_end()); + wrapped_text.push('\n'); + current_line.truncate(line_prefix.len()); + current_line_len = line_prefix_len; + } else if current_line_len != line_prefix_len || preserve_existing_whitespace { + current_line.push_str(token); + current_line_len += grapheme_len; + } + } + WordBreakToken::Newline => { + in_whitespace = true; + if preserve_existing_whitespace { + wrapped_text.push_str(current_line.trim_end()); + wrapped_text.push('\n'); + current_line.truncate(line_prefix.len()); + current_line_len = line_prefix_len; + } else if have_preceding_whitespace { + continue; + } else if current_line_len + 1 > wrap_column && current_line_len != line_prefix_len + { + wrapped_text.push_str(current_line.trim_end()); + wrapped_text.push('\n'); + current_line.truncate(line_prefix.len()); + current_line_len = line_prefix_len; + } else if current_line_len != line_prefix_len { + current_line.push(' '); + current_line_len += 1; + } + } + } + } + + if !current_line.is_empty() { + wrapped_text.push_str(¤t_line); + } + wrapped_text +} + +#[test] +fn test_wrap_with_prefix() { + assert_eq!( + wrap_with_prefix( + "# ".to_string(), + "abcdefg".to_string(), + 4, + NonZeroU32::new(4).unwrap(), + false, + ), + "# abcdefg" + ); + assert_eq!( + wrap_with_prefix( + "".to_string(), + "\thello world".to_string(), + 8, + NonZeroU32::new(4).unwrap(), + false, + ), + "hello\nworld" + ); + assert_eq!( + wrap_with_prefix( + "// ".to_string(), + "xx \nyy zz aa bb cc".to_string(), + 12, + NonZeroU32::new(4).unwrap(), + false, + ), + "// xx yy zz\n// aa bb cc" + ); + assert_eq!( + wrap_with_prefix( + String::new(), + "这是什么 \n 钢笔".to_string(), + 3, + NonZeroU32::new(4).unwrap(), + false, + ), + "这是什\n么 钢\n笔" + ); +} + +pub trait CollaborationHub { + fn collaborators<'a>(&self, cx: &'a App) -> &'a HashMap; + fn user_participant_indices<'a>(&self, cx: &'a App) -> &'a HashMap; + fn user_names(&self, cx: &App) -> HashMap; +} + +impl CollaborationHub for Entity { + fn collaborators<'a>(&self, cx: &'a App) -> &'a HashMap { + self.read(cx).collaborators() + } + + fn user_participant_indices<'a>(&self, cx: &'a App) -> &'a HashMap { + self.read(cx).user_store().read(cx).participant_indices() + } + + fn user_names(&self, cx: &App) -> HashMap { + let this = self.read(cx); + let user_ids = this.collaborators().values().map(|c| c.user_id); + this.user_store().read_with(cx, |user_store, cx| { + user_store.participant_names(user_ids, cx) + }) + } +} + +pub trait SemanticsProvider { + fn hover( + &self, + buffer: &Entity, + position: text::Anchor, + cx: &mut App, + ) -> Option>>; + + fn inline_values( + &self, + buffer_handle: Entity, + range: Range, + cx: &mut App, + ) -> Option>>>; + + fn inlay_hints( + &self, + buffer_handle: Entity, + range: Range, + cx: &mut App, + ) -> Option>>>; + + fn resolve_inlay_hint( + &self, + hint: InlayHint, + buffer_handle: Entity, + server_id: LanguageServerId, + cx: &mut App, + ) -> Option>>; + + fn supports_inlay_hints(&self, buffer: &Entity, cx: &mut App) -> bool; + + fn document_highlights( + &self, + buffer: &Entity, + position: text::Anchor, + cx: &mut App, + ) -> Option>>>; + + fn definitions( + &self, + buffer: &Entity, + position: text::Anchor, + kind: GotoDefinitionKind, + cx: &mut App, + ) -> Option>>>; + + fn range_for_rename( + &self, + buffer: &Entity, + position: text::Anchor, + cx: &mut App, + ) -> Option>>>>; + + fn perform_rename( + &self, + buffer: &Entity, + position: text::Anchor, + new_name: String, + cx: &mut App, + ) -> Option>>; +} + +pub trait CompletionProvider { + fn completions( + &self, + excerpt_id: ExcerptId, + buffer: &Entity, + buffer_position: text::Anchor, + trigger: CompletionContext, + window: &mut Window, + cx: &mut Context, + ) -> Task>>>; + + fn resolve_completions( + &self, + buffer: Entity, + completion_indices: Vec, + completions: Rc>>, + cx: &mut Context, + ) -> Task>; + + fn apply_additional_edits_for_completion( + &self, + _buffer: Entity, + _completions: Rc>>, + _completion_index: usize, + _push_to_history: bool, + _cx: &mut Context, + ) -> Task>> { + Task::ready(Ok(None)) + } + + fn is_completion_trigger( + &self, + buffer: &Entity, + position: language::Anchor, + text: &str, + trigger_in_words: bool, + cx: &mut Context, + ) -> bool; + + fn sort_completions(&self) -> bool { + true + } + + fn filter_completions(&self) -> bool { + true + } +} + +pub trait CodeActionProvider { + fn id(&self) -> Arc; + + fn code_actions( + &self, + buffer: &Entity, + range: Range, + window: &mut Window, + cx: &mut App, + ) -> Task>>; + + fn apply_code_action( + &self, + buffer_handle: Entity, + action: CodeAction, + excerpt_id: ExcerptId, + push_to_history: bool, + window: &mut Window, + cx: &mut App, + ) -> Task>; +} + +impl CodeActionProvider for Entity { + fn id(&self) -> Arc { + "project".into() + } + + fn code_actions( + &self, + buffer: &Entity, + range: Range, + _window: &mut Window, + cx: &mut App, + ) -> Task>> { + self.update(cx, |project, cx| { + let code_lens = project.code_lens(buffer, range.clone(), cx); + let code_actions = project.code_actions(buffer, range, None, cx); + cx.background_spawn(async move { + let (code_lens, code_actions) = join(code_lens, code_actions).await; + Ok(code_lens + .context("code lens fetch")? + .into_iter() + .chain(code_actions.context("code action fetch")?) + .collect()) + }) + }) + } + + fn apply_code_action( + &self, + buffer_handle: Entity, + action: CodeAction, + _excerpt_id: ExcerptId, + push_to_history: bool, + _window: &mut Window, + cx: &mut App, + ) -> Task> { + self.update(cx, |project, cx| { + project.apply_code_action(buffer_handle, action, push_to_history, cx) + }) + } +} + +fn snippet_completions( + project: &Project, + buffer: &Entity, + buffer_position: text::Anchor, + cx: &mut App, +) -> Task>> { + let languages = buffer.read(cx).languages_at(buffer_position); + let snippet_store = project.snippets().read(cx); + + let scopes: Vec<_> = languages + .iter() + .filter_map(|language| { + let language_name = language.lsp_id(); + let snippets = snippet_store.snippets_for(Some(language_name), cx); + + if snippets.is_empty() { + None + } else { + Some((language.default_scope(), snippets)) + } + }) + .collect(); + + if scopes.is_empty() { + return Task::ready(Ok(vec![])); + } + + let snapshot = buffer.read(cx).text_snapshot(); + let chars: String = snapshot + .reversed_chars_for_range(text::Anchor::MIN..buffer_position) + .collect(); + let executor = cx.background_executor().clone(); + + cx.background_spawn(async move { + let mut all_results: Vec = Vec::new(); + for (scope, snippets) in scopes.into_iter() { + let classifier = CharClassifier::new(Some(scope)).for_completion(true); + let mut last_word = chars + .chars() + .take_while(|c| classifier.is_word(*c)) + .collect::(); + last_word = last_word.chars().rev().collect(); + + if last_word.is_empty() { + return Ok(vec![]); + } + + let as_offset = text::ToOffset::to_offset(&buffer_position, &snapshot); + let to_lsp = |point: &text::Anchor| { + let end = text::ToPointUtf16::to_point_utf16(point, &snapshot); + point_to_lsp(end) + }; + let lsp_end = to_lsp(&buffer_position); + + let candidates = snippets + .iter() + .enumerate() + .flat_map(|(ix, snippet)| { + snippet + .prefix + .iter() + .map(move |prefix| StringMatchCandidate::new(ix, &prefix)) + }) + .collect::>(); + + let mut matches = fuzzy::match_strings( + &candidates, + &last_word, + last_word.chars().any(|c| c.is_uppercase()), + 100, + &Default::default(), + executor.clone(), + ) + .await; + + // Remove all candidates where the query's start does not match the start of any word in the candidate + if let Some(query_start) = last_word.chars().next() { + matches.retain(|string_match| { + split_words(&string_match.string).any(|word| { + // Check that the first codepoint of the word as lowercase matches the first + // codepoint of the query as lowercase + word.chars() + .flat_map(|codepoint| codepoint.to_lowercase()) + .zip(query_start.to_lowercase()) + .all(|(word_cp, query_cp)| word_cp == query_cp) + }) + }); + } + + let matched_strings = matches + .into_iter() + .map(|m| m.string) + .collect::>(); + + let mut result: Vec = snippets + .iter() + .filter_map(|snippet| { + let matching_prefix = snippet + .prefix + .iter() + .find(|prefix| matched_strings.contains(*prefix))?; + let start = as_offset - last_word.len(); + let start = snapshot.anchor_before(start); + let range = start..buffer_position; + let lsp_start = to_lsp(&start); + let lsp_range = lsp::Range { + start: lsp_start, + end: lsp_end, + }; + Some(Completion { + replace_range: range, + new_text: snippet.body.clone(), + source: CompletionSource::Lsp { + insert_range: None, + server_id: LanguageServerId(usize::MAX), + resolved: true, + lsp_completion: Box::new(lsp::CompletionItem { + label: snippet.prefix.first().unwrap().clone(), + kind: Some(CompletionItemKind::SNIPPET), + label_details: snippet.description.as_ref().map(|description| { + lsp::CompletionItemLabelDetails { + detail: Some(description.clone()), + description: None, + } + }), + insert_text_format: Some(InsertTextFormat::SNIPPET), + text_edit: Some(lsp::CompletionTextEdit::InsertAndReplace( + lsp::InsertReplaceEdit { + new_text: snippet.body.clone(), + insert: lsp_range, + replace: lsp_range, + }, + )), + filter_text: Some(snippet.body.clone()), + sort_text: Some(char::MAX.to_string()), + ..lsp::CompletionItem::default() + }), + lsp_defaults: None, + }, + label: CodeLabel { + text: matching_prefix.clone(), + runs: Vec::new(), + filter_range: 0..matching_prefix.len(), + }, + icon_path: None, + documentation: snippet.description.clone().map(|description| { + CompletionDocumentation::SingleLine(description.into()) + }), + insert_text_mode: None, + confirm: None, + }) + }) + .collect(); + + all_results.append(&mut result); + } + + Ok(all_results) + }) +} + +impl CompletionProvider for Entity { + fn completions( + &self, + _excerpt_id: ExcerptId, + buffer: &Entity, + buffer_position: text::Anchor, + options: CompletionContext, + _window: &mut Window, + cx: &mut Context, + ) -> Task>>> { + self.update(cx, |project, cx| { + let snippets = snippet_completions(project, buffer, buffer_position, cx); + let project_completions = project.completions(buffer, buffer_position, options, cx); + cx.background_spawn(async move { + let snippets_completions = snippets.await?; + match project_completions.await? { + Some(mut completions) => { + completions.extend(snippets_completions); + Ok(Some(completions)) + } + None => { + if snippets_completions.is_empty() { + Ok(None) + } else { + Ok(Some(snippets_completions)) + } + } + } + }) + }) + } + + fn resolve_completions( + &self, + buffer: Entity, + completion_indices: Vec, + completions: Rc>>, + cx: &mut Context, + ) -> Task> { + self.update(cx, |project, cx| { + project.lsp_store().update(cx, |lsp_store, cx| { + lsp_store.resolve_completions(buffer, completion_indices, completions, cx) + }) + }) + } + + fn apply_additional_edits_for_completion( + &self, + buffer: Entity, + completions: Rc>>, + completion_index: usize, + push_to_history: bool, + cx: &mut Context, + ) -> Task>> { + self.update(cx, |project, cx| { + project.lsp_store().update(cx, |lsp_store, cx| { + lsp_store.apply_additional_edits_for_completion( + buffer, + completions, + completion_index, + push_to_history, + cx, + ) + }) + }) + } + + fn is_completion_trigger( + &self, + buffer: &Entity, + position: language::Anchor, + text: &str, + trigger_in_words: bool, + cx: &mut Context, + ) -> bool { + let mut chars = text.chars(); + let char = if let Some(char) = chars.next() { + char + } else { + return false; + }; + if chars.next().is_some() { + return false; + } + + let buffer = buffer.read(cx); + let snapshot = buffer.snapshot(); + if !snapshot.settings_at(position, cx).show_completions_on_input { + return false; + } + let classifier = snapshot.char_classifier_at(position).for_completion(true); + if trigger_in_words && classifier.is_word(char) { + return true; + } + + buffer.completion_triggers().contains(text) + } +} + +impl SemanticsProvider for Entity { + fn hover( + &self, + buffer: &Entity, + position: text::Anchor, + cx: &mut App, + ) -> Option>> { + Some(self.update(cx, |project, cx| project.hover(buffer, position, cx))) + } + + fn document_highlights( + &self, + buffer: &Entity, + position: text::Anchor, + cx: &mut App, + ) -> Option>>> { + Some(self.update(cx, |project, cx| { + project.document_highlights(buffer, position, cx) + })) + } + + fn definitions( + &self, + buffer: &Entity, + position: text::Anchor, + kind: GotoDefinitionKind, + cx: &mut App, + ) -> Option>>> { + Some(self.update(cx, |project, cx| match kind { + GotoDefinitionKind::Symbol => project.definition(&buffer, position, cx), + GotoDefinitionKind::Declaration => project.declaration(&buffer, position, cx), + GotoDefinitionKind::Type => project.type_definition(&buffer, position, cx), + GotoDefinitionKind::Implementation => project.implementation(&buffer, position, cx), + })) + } + + fn supports_inlay_hints(&self, buffer: &Entity, cx: &mut App) -> bool { + // TODO: make this work for remote projects + self.update(cx, |project, cx| { + if project + .active_debug_session(cx) + .is_some_and(|(session, _)| session.read(cx).any_stopped_thread()) + { + return true; + } + + buffer.update(cx, |buffer, cx| { + project.any_language_server_supports_inlay_hints(buffer, cx) + }) + }) + } + + fn inline_values( + &self, + buffer_handle: Entity, + range: Range, + cx: &mut App, + ) -> Option>>> { + self.update(cx, |project, cx| { + let (session, active_stack_frame) = project.active_debug_session(cx)?; + + Some(project.inline_values(session, active_stack_frame, buffer_handle, range, cx)) + }) + } + + fn inlay_hints( + &self, + buffer_handle: Entity, + range: Range, + cx: &mut App, + ) -> Option>>> { + Some(self.update(cx, |project, cx| { + project.inlay_hints(buffer_handle, range, cx) + })) + } + + fn resolve_inlay_hint( + &self, + hint: InlayHint, + buffer_handle: Entity, + server_id: LanguageServerId, + cx: &mut App, + ) -> Option>> { + Some(self.update(cx, |project, cx| { + project.resolve_inlay_hint(hint, buffer_handle, server_id, cx) + })) + } + + fn range_for_rename( + &self, + buffer: &Entity, + position: text::Anchor, + cx: &mut App, + ) -> Option>>>> { + Some(self.update(cx, |project, cx| { + let buffer = buffer.clone(); + let task = project.prepare_rename(buffer.clone(), position, cx); + cx.spawn(async move |_, cx| { + Ok(match task.await? { + PrepareRenameResponse::Success(range) => Some(range), + PrepareRenameResponse::InvalidPosition => None, + PrepareRenameResponse::OnlyUnpreparedRenameSupported => { + // Fallback on using TreeSitter info to determine identifier range + buffer.update(cx, |buffer, _| { + let snapshot = buffer.snapshot(); + let (range, kind) = snapshot.surrounding_word(position); + if kind != Some(CharKind::Word) { + return None; + } + Some( + snapshot.anchor_before(range.start) + ..snapshot.anchor_after(range.end), + ) + })? + } + }) + }) + })) + } + + fn perform_rename( + &self, + buffer: &Entity, + position: text::Anchor, + new_name: String, + cx: &mut App, + ) -> Option>> { + Some(self.update(cx, |project, cx| { + project.perform_rename(buffer.clone(), position, new_name, cx) + })) + } +} + +fn inlay_hint_settings( + location: Anchor, + snapshot: &MultiBufferSnapshot, + cx: &mut Context, +) -> InlayHintSettings { + let file = snapshot.file_at(location); + let language = snapshot.language_at(location).map(|l| l.name()); + language_settings(language, file, cx).inlay_hints +} + +fn consume_contiguous_rows( + contiguous_row_selections: &mut Vec>, + selection: &Selection, + display_map: &DisplaySnapshot, + selections: &mut Peekable>>, +) -> (MultiBufferRow, MultiBufferRow) { + contiguous_row_selections.push(selection.clone()); + let start_row = MultiBufferRow(selection.start.row); + let mut end_row = ending_row(selection, display_map); + + while let Some(next_selection) = selections.peek() { + if next_selection.start.row <= end_row.0 { + end_row = ending_row(next_selection, display_map); + contiguous_row_selections.push(selections.next().unwrap().clone()); + } else { + break; + } + } + (start_row, end_row) +} + +fn ending_row(next_selection: &Selection, display_map: &DisplaySnapshot) -> MultiBufferRow { + if next_selection.end.column > 0 || next_selection.is_empty() { + MultiBufferRow(display_map.next_line_boundary(next_selection.end).0.row + 1) + } else { + MultiBufferRow(next_selection.end.row) + } +} + +impl EditorSnapshot { + pub fn remote_selections_in_range<'a>( + &'a self, + range: &'a Range, + collaboration_hub: &dyn CollaborationHub, + cx: &'a App, + ) -> impl 'a + Iterator { + let participant_names = collaboration_hub.user_names(cx); + let participant_indices = collaboration_hub.user_participant_indices(cx); + let collaborators_by_peer_id = collaboration_hub.collaborators(cx); + let collaborators_by_replica_id = collaborators_by_peer_id + .iter() + .map(|(_, collaborator)| (collaborator.replica_id, collaborator)) + .collect::>(); + self.buffer_snapshot + .selections_in_range(range, false) + .filter_map(move |(replica_id, line_mode, cursor_shape, selection)| { + let collaborator = collaborators_by_replica_id.get(&replica_id)?; + let participant_index = participant_indices.get(&collaborator.user_id).copied(); + let user_name = participant_names.get(&collaborator.user_id).cloned(); + Some(RemoteSelection { + replica_id, + selection, + cursor_shape, + line_mode, + participant_index, + peer_id: collaborator.peer_id, + user_name, + }) + }) + } + + pub fn hunks_for_ranges( + &self, + ranges: impl IntoIterator>, + ) -> Vec { + let mut hunks = Vec::new(); + let mut processed_buffer_rows: HashMap>> = + HashMap::default(); + for query_range in ranges { + let query_rows = + MultiBufferRow(query_range.start.row)..MultiBufferRow(query_range.end.row + 1); + for hunk in self.buffer_snapshot.diff_hunks_in_range( + Point::new(query_rows.start.0, 0)..Point::new(query_rows.end.0, 0), + ) { + // Include deleted hunks that are adjacent to the query range, because + // otherwise they would be missed. + let mut intersects_range = hunk.row_range.overlaps(&query_rows); + if hunk.status().is_deleted() { + intersects_range |= hunk.row_range.start == query_rows.end; + intersects_range |= hunk.row_range.end == query_rows.start; + } + if intersects_range { + if !processed_buffer_rows + .entry(hunk.buffer_id) + .or_default() + .insert(hunk.buffer_range.start..hunk.buffer_range.end) + { + continue; + } + hunks.push(hunk); + } + } + } + + hunks + } + + fn display_diff_hunks_for_rows<'a>( + &'a self, + display_rows: Range, + folded_buffers: &'a HashSet, + ) -> impl 'a + Iterator { + let buffer_start = DisplayPoint::new(display_rows.start, 0).to_point(self); + let buffer_end = DisplayPoint::new(display_rows.end, 0).to_point(self); + + self.buffer_snapshot + .diff_hunks_in_range(buffer_start..buffer_end) + .filter_map(|hunk| { + if folded_buffers.contains(&hunk.buffer_id) { + return None; + } + + let hunk_start_point = Point::new(hunk.row_range.start.0, 0); + let hunk_end_point = Point::new(hunk.row_range.end.0, 0); + + let hunk_display_start = self.point_to_display_point(hunk_start_point, Bias::Left); + let hunk_display_end = self.point_to_display_point(hunk_end_point, Bias::Right); + + let display_hunk = if hunk_display_start.column() != 0 { + DisplayDiffHunk::Folded { + display_row: hunk_display_start.row(), + } + } else { + let mut end_row = hunk_display_end.row(); + if hunk_display_end.column() > 0 { + end_row.0 += 1; + } + let is_created_file = hunk.is_created_file(); + DisplayDiffHunk::Unfolded { + status: hunk.status(), + diff_base_byte_range: hunk.diff_base_byte_range, + display_row_range: hunk_display_start.row()..end_row, + multi_buffer_range: Anchor::range_in_buffer( + hunk.excerpt_id, + hunk.buffer_id, + hunk.buffer_range, + ), + is_created_file, + } + }; + + Some(display_hunk) + }) + } + + pub fn language_at(&self, position: T) -> Option<&Arc> { + self.display_snapshot.buffer_snapshot.language_at(position) + } + + pub fn is_focused(&self) -> bool { + self.is_focused + } + + pub fn placeholder_text(&self) -> Option<&Arc> { + self.placeholder_text.as_ref() + } + + pub fn scroll_position(&self) -> gpui::Point { + self.scroll_anchor.scroll_position(&self.display_snapshot) + } + + fn gutter_dimensions( + &self, + font_id: FontId, + font_size: Pixels, + max_line_number_width: Pixels, + cx: &App, + ) -> Option { + if !self.show_gutter { + return None; + } + + let descent = cx.text_system().descent(font_id, font_size); + let em_width = cx.text_system().em_width(font_id, font_size).log_err()?; + let em_advance = cx.text_system().em_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, + Some(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 and only resize the gutter on files with N*10^5 lines. + let min_width_for_number_on_gutter = em_advance * MIN_LINE_NUMBER_DIGITS as f32; + max_line_number_width.max(min_width_for_number_on_gutter) + } else { + 0.0.into() + }; + + let show_code_actions = self + .show_code_actions + .unwrap_or(gutter_settings.code_actions); + + 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"; + + /// 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; + + em_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 { + em_width * 4.0 + } else if show_code_actions || show_runnables || show_breakpoints { + em_width * 3.0 + } else if show_git_gutter && show_line_numbers { + em_width * 2.0 + } else if show_git_gutter || show_line_numbers { + em_width + } else { + px(0.) + }; + + let shows_folds = is_singleton && gutter_settings.folds; + + let right_padding = if shows_folds && show_line_numbers { + em_width * 4.0 + } else if shows_folds || (!is_singleton && show_line_numbers) { + em_width * 3.0 + } else if show_line_numbers { + em_width + } else { + px(0.) + }; + + Some(GutterDimensions { + left_padding, + right_padding, + width: line_gutter_width + left_padding + right_padding, + margin: -descent, + git_blame_entries_width, + }) + } + + pub fn render_crease_toggle( + &self, + buffer_row: MultiBufferRow, + row_contains_cursor: bool, + editor: Entity, + window: &mut Window, + cx: &mut App, + ) -> Option { + let folded = self.is_line_folded(buffer_row); + let mut is_foldable = false; + + if let Some(crease) = self + .crease_snapshot + .query_row(buffer_row, &self.buffer_snapshot) + { + is_foldable = true; + match crease { + Crease::Inline { render_toggle, .. } | Crease::Block { render_toggle, .. } => { + if let Some(render_toggle) = render_toggle { + let toggle_callback = + Arc::new(move |folded, window: &mut Window, cx: &mut App| { + if folded { + editor.update(cx, |editor, cx| { + editor.fold_at(buffer_row, window, cx) + }); + } else { + editor.update(cx, |editor, cx| { + editor.unfold_at(buffer_row, window, cx) + }); + } + }); + return Some((render_toggle)( + buffer_row, + folded, + toggle_callback, + window, + cx, + )); + } + } + } + } + + is_foldable |= self.starts_indent(buffer_row); + + if folded || (is_foldable && (row_contains_cursor || self.gutter_hovered)) { + Some( + Disclosure::new(("gutter_crease", buffer_row.0), !folded) + .toggle_state(folded) + .on_click(window.listener_for(&editor, move |this, _e, window, cx| { + if folded { + this.unfold_at(buffer_row, window, cx); + } else { + this.fold_at(buffer_row, window, cx); + } + })) + .into_any_element(), + ) + } else { + None + } + } + + pub fn render_crease_trailer( + &self, + buffer_row: MultiBufferRow, + window: &mut Window, + cx: &mut App, + ) -> Option { + let folded = self.is_line_folded(buffer_row); + if let Crease::Inline { render_trailer, .. } = self + .crease_snapshot + .query_row(buffer_row, &self.buffer_snapshot)? + { + let render_trailer = render_trailer.as_ref()?; + Some(render_trailer(buffer_row, folded, window, cx)) + } else { + None + } + } +} + +impl Deref for EditorSnapshot { + type Target = DisplaySnapshot; + + fn deref(&self) -> &Self::Target { + &self.display_snapshot + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum EditorEvent { + InputIgnored { + text: Arc, + }, + InputHandled { + utf16_range_to_replace: Option>, + text: Arc, + }, + ExcerptsAdded { + buffer: Entity, + predecessor: ExcerptId, + excerpts: Vec<(ExcerptId, ExcerptRange)>, + }, + ExcerptsRemoved { + ids: Vec, + removed_buffer_ids: Vec, + }, + BufferFoldToggled { + ids: Vec, + folded: bool, + }, + ExcerptsEdited { + ids: Vec, + }, + ExcerptsExpanded { + ids: Vec, + }, + BufferEdited, + Edited { + transaction_id: clock::Lamport, + }, + Reparsed(BufferId), + Focused, + FocusedIn, + Blurred, + DirtyChanged, + Saved, + TitleChanged, + DiffBaseChanged, + SelectionsChanged { + local: bool, + }, + ScrollPositionChanged { + local: bool, + autoscroll: bool, + }, + Closed, + TransactionUndone { + transaction_id: clock::Lamport, + }, + TransactionBegun { + transaction_id: clock::Lamport, + }, + Reloaded, + CursorShapeChanged, + PushedToNavHistory { + anchor: Anchor, + is_deactivate: bool, + }, +} + +impl EventEmitter for Editor {} + +impl Focusable for Editor { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +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 { .. } => 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 { max_lines: _ } => cx.theme().system().transparent, + EditorMode::Full { .. } => cx.theme().colors().editor_background, + }; + + EditorElement::new( + &cx.entity(), + EditorStyle { + background, + 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), + inline_completion_styles: make_suggestion_styles(cx), + unnecessary_code_fade: ThemeSettings::get_global(cx).unnecessary_code_fade, + }, + ) + } +} + +impl EntityInputHandler for Editor { + fn text_for_range( + &mut self, + range_utf16: Range, + adjusted_range: &mut Option>, + _: &mut Window, + cx: &mut Context, + ) -> Option { + let snapshot = self.buffer.read(cx).read(cx); + let start = snapshot.clip_offset_utf16(OffsetUtf16(range_utf16.start), Bias::Left); + let end = snapshot.clip_offset_utf16(OffsetUtf16(range_utf16.end), Bias::Right); + if (start.0..end.0) != range_utf16 { + adjusted_range.replace(start.0..end.0); + } + Some(snapshot.text_for_range(start..end).collect()) + } + + fn selected_text_range( + &mut self, + ignore_disabled_input: bool, + _: &mut Window, + cx: &mut Context, + ) -> Option { + // Prevent the IME menu from appearing when holding down an alphabetic key + // while input is disabled. + if !ignore_disabled_input && !self.input_enabled { + return None; + } + + let selection = self.selections.newest::(cx); + let range = selection.range(); + + Some(UTF16Selection { + range: range.start.0..range.end.0, + reversed: selection.reversed, + }) + } + + fn marked_text_range(&self, _: &mut Window, cx: &mut Context) -> Option> { + let snapshot = self.buffer.read(cx).read(cx); + let range = self.text_highlights::(cx)?.1.first()?; + Some(range.start.to_offset_utf16(&snapshot).0..range.end.to_offset_utf16(&snapshot).0) + } + + fn unmark_text(&mut self, _: &mut Window, cx: &mut Context) { + self.clear_highlights::(cx); + self.ime_transaction.take(); + } + + fn replace_text_in_range( + &mut self, + range_utf16: Option>, + text: &str, + window: &mut Window, + cx: &mut Context, + ) { + if !self.input_enabled { + cx.emit(EditorEvent::InputIgnored { text: text.into() }); + return; + } + + self.transact(window, cx, |this, window, cx| { + let new_selected_ranges = if let Some(range_utf16) = range_utf16 { + let range_utf16 = OffsetUtf16(range_utf16.start)..OffsetUtf16(range_utf16.end); + Some(this.selection_replacement_ranges(range_utf16, cx)) + } else { + this.marked_text_ranges(cx) + }; + + let range_to_replace = new_selected_ranges.as_ref().and_then(|ranges_to_replace| { + let newest_selection_id = this.selections.newest_anchor().id; + this.selections + .all::(cx) + .iter() + .zip(ranges_to_replace.iter()) + .find_map(|(selection, range)| { + if selection.id == newest_selection_id { + Some( + (range.start.0 as isize - selection.head().0 as isize) + ..(range.end.0 as isize - selection.head().0 as isize), + ) + } else { + None + } + }) + }); + + cx.emit(EditorEvent::InputHandled { + utf16_range_to_replace: range_to_replace, + text: text.into(), + }); + + if let Some(new_selected_ranges) = new_selected_ranges { + this.change_selections(None, window, cx, |selections| { + selections.select_ranges(new_selected_ranges) + }); + this.backspace(&Default::default(), window, cx); + } + + this.handle_input(text, window, cx); + }); + + if let Some(transaction) = self.ime_transaction { + self.buffer.update(cx, |buffer, cx| { + buffer.group_until_transaction(transaction, cx); + }); + } + + self.unmark_text(window, cx); + } + + fn replace_and_mark_text_in_range( + &mut self, + range_utf16: Option>, + text: &str, + new_selected_range_utf16: Option>, + window: &mut Window, + cx: &mut Context, + ) { + if !self.input_enabled { + return; + } + + let transaction = self.transact(window, cx, |this, window, cx| { + let ranges_to_replace = if let Some(mut marked_ranges) = this.marked_text_ranges(cx) { + let snapshot = this.buffer.read(cx).read(cx); + if let Some(relative_range_utf16) = range_utf16.as_ref() { + for marked_range in &mut marked_ranges { + marked_range.end.0 = marked_range.start.0 + relative_range_utf16.end; + marked_range.start.0 += relative_range_utf16.start; + marked_range.start = + snapshot.clip_offset_utf16(marked_range.start, Bias::Left); + marked_range.end = + snapshot.clip_offset_utf16(marked_range.end, Bias::Right); + } + } + Some(marked_ranges) + } else if let Some(range_utf16) = range_utf16 { + let range_utf16 = OffsetUtf16(range_utf16.start)..OffsetUtf16(range_utf16.end); + Some(this.selection_replacement_ranges(range_utf16, cx)) + } else { + None + }; + + let range_to_replace = ranges_to_replace.as_ref().and_then(|ranges_to_replace| { + let newest_selection_id = this.selections.newest_anchor().id; + this.selections + .all::(cx) + .iter() + .zip(ranges_to_replace.iter()) + .find_map(|(selection, range)| { + if selection.id == newest_selection_id { + Some( + (range.start.0 as isize - selection.head().0 as isize) + ..(range.end.0 as isize - selection.head().0 as isize), + ) + } else { + None + } + }) + }); + + cx.emit(EditorEvent::InputHandled { + utf16_range_to_replace: range_to_replace, + text: text.into(), + }); + + if let Some(ranges) = ranges_to_replace { + this.change_selections(None, window, cx, |s| s.select_ranges(ranges)); + } + + let marked_ranges = { + let snapshot = this.buffer.read(cx).read(cx); + this.selections + .disjoint_anchors() + .iter() + .map(|selection| { + selection.start.bias_left(&snapshot)..selection.end.bias_right(&snapshot) + }) + .collect::>() + }; + + if text.is_empty() { + this.unmark_text(window, cx); + } else { + this.highlight_text::( + marked_ranges.clone(), + HighlightStyle { + underline: Some(UnderlineStyle { + thickness: px(1.), + color: None, + wavy: false, + }), + ..Default::default() + }, + cx, + ); + } + + // Disable auto-closing when composing text (i.e. typing a `"` on a Brazilian keyboard) + let use_autoclose = this.use_autoclose; + let use_auto_surround = this.use_auto_surround; + this.set_use_autoclose(false); + this.set_use_auto_surround(false); + this.handle_input(text, window, cx); + this.set_use_autoclose(use_autoclose); + this.set_use_auto_surround(use_auto_surround); + + if let Some(new_selected_range) = new_selected_range_utf16 { + let snapshot = this.buffer.read(cx).read(cx); + let new_selected_ranges = marked_ranges + .into_iter() + .map(|marked_range| { + let insertion_start = marked_range.start.to_offset_utf16(&snapshot).0; + let new_start = OffsetUtf16(new_selected_range.start + insertion_start); + let new_end = OffsetUtf16(new_selected_range.end + insertion_start); + snapshot.clip_offset_utf16(new_start, Bias::Left) + ..snapshot.clip_offset_utf16(new_end, Bias::Right) + }) + .collect::>(); + + drop(snapshot); + this.change_selections(None, window, cx, |selections| { + selections.select_ranges(new_selected_ranges) + }); + } + }); + + self.ime_transaction = self.ime_transaction.or(transaction); + if let Some(transaction) = self.ime_transaction { + self.buffer.update(cx, |buffer, cx| { + buffer.group_until_transaction(transaction, cx); + }); + } + + if self.text_highlights::(cx).is_none() { + self.ime_transaction.take(); + } + } + + fn bounds_for_range( + &mut self, + range_utf16: Range, + element_bounds: gpui::Bounds, + window: &mut Window, + cx: &mut Context, + ) -> Option> { + let text_layout_details = self.text_layout_details(window); + let gpui::Size { + width: em_width, + height: line_height, + } = self.character_size(window); + + let snapshot = self.snapshot(window, cx); + let scroll_position = snapshot.scroll_position(); + let scroll_left = scroll_position.x * em_width; + + let start = OffsetUtf16(range_utf16.start).to_display_point(&snapshot); + let x = snapshot.x_for_display_point(start, &text_layout_details) - scroll_left + + self.gutter_dimensions.width + + self.gutter_dimensions.margin; + let y = line_height * (start.row().as_f32() - scroll_position.y); + + Some(Bounds { + origin: element_bounds.origin + point(x, y), + size: size(em_width, line_height), + }) + } + + fn character_index_for_point( + &mut self, + point: gpui::Point, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + let position_map = self.last_position_map.as_ref()?; + if !position_map.text_hitbox.contains(&point) { + return None; + } + let display_point = position_map.point_for_position(point).previous_valid; + let anchor = position_map + .snapshot + .display_point_to_anchor(display_point, Bias::Left); + let utf16_offset = anchor.to_offset_utf16(&position_map.snapshot.buffer_snapshot); + Some(utf16_offset.0) + } +} + +trait SelectionExt { + fn display_range(&self, map: &DisplaySnapshot) -> Range; + fn spanned_rows( + &self, + include_end_if_at_line_start: bool, + map: &DisplaySnapshot, + ) -> Range; +} + +impl SelectionExt for Selection { + fn display_range(&self, map: &DisplaySnapshot) -> Range { + let start = self + .start + .to_point(&map.buffer_snapshot) + .to_display_point(map); + let end = self + .end + .to_point(&map.buffer_snapshot) + .to_display_point(map); + if self.reversed { + end..start + } else { + start..end + } + } + + fn spanned_rows( + &self, + include_end_if_at_line_start: bool, + map: &DisplaySnapshot, + ) -> Range { + let start = self.start.to_point(&map.buffer_snapshot); + let mut end = self.end.to_point(&map.buffer_snapshot); + if !include_end_if_at_line_start && start.row != end.row && end.column == 0 { + end.row -= 1; + } + + let buffer_start = map.prev_line_boundary(start).0; + let buffer_end = map.next_line_boundary(end).0; + MultiBufferRow(buffer_start.row)..MultiBufferRow(buffer_end.row + 1) + } +} + +impl InvalidationStack { + fn invalidate(&mut self, selections: &[Selection], buffer: &MultiBufferSnapshot) + where + S: Clone + ToOffset, + { + while let Some(region) = self.last() { + let all_selections_inside_invalidation_ranges = + if selections.len() == region.ranges().len() { + selections + .iter() + .zip(region.ranges().iter().map(|r| r.to_offset(buffer))) + .all(|(selection, invalidation_range)| { + let head = selection.head().to_offset(buffer); + invalidation_range.start <= head && invalidation_range.end >= head + }) + } else { + false + }; + + if all_selections_inside_invalidation_ranges { + break; + } else { + self.pop(); + } + } + } +} + +impl Default for InvalidationStack { + fn default() -> Self { + Self(Default::default()) + } +} + +impl Deref for InvalidationStack { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for InvalidationStack { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl InvalidationRegion for SnippetState { + fn ranges(&self) -> &[Range] { + &self.ranges[self.active_index] + } +} + +fn inline_completion_edit_text( + current_snapshot: &BufferSnapshot, + edits: &[(Range, String)], + edit_preview: &EditPreview, + include_deletions: bool, + cx: &App, +) -> HighlightedText { + let edits = edits + .iter() + .map(|(anchor, text)| { + ( + anchor.start.text_anchor..anchor.end.text_anchor, + text.clone(), + ) + }) + .collect::>(); + + edit_preview.highlight_edits(current_snapshot, &edits, include_deletions, cx) +} + +pub fn diagnostic_style(severity: DiagnosticSeverity, colors: &StatusColors) -> Hsla { + match severity { + DiagnosticSeverity::ERROR => colors.error, + DiagnosticSeverity::WARNING => colors.warning, + DiagnosticSeverity::INFORMATION => colors.info, + DiagnosticSeverity::HINT => colors.info, + _ => colors.ignored, + } +} + +pub fn styled_runs_for_code_label<'a>( + label: &'a CodeLabel, + syntax_theme: &'a theme::SyntaxTheme, +) -> impl 'a + Iterator, HighlightStyle)> { + let fade_out = HighlightStyle { + fade_out: Some(0.35), + ..Default::default() + }; + + let mut prev_end = label.filter_range.end; + label + .runs + .iter() + .enumerate() + .flat_map(move |(ix, (range, highlight_id))| { + let style = if let Some(style) = highlight_id.style(syntax_theme) { + style + } else { + return Default::default(); + }; + let mut muted_style = style; + muted_style.highlight(fade_out); + + let mut runs = SmallVec::<[(Range, HighlightStyle); 3]>::new(); + if range.start >= label.filter_range.end { + if range.start > prev_end { + runs.push((prev_end..range.start, fade_out)); + } + runs.push((range.clone(), muted_style)); + } else if range.end <= label.filter_range.end { + runs.push((range.clone(), style)); + } else { + runs.push((range.start..label.filter_range.end, style)); + runs.push((label.filter_range.end..range.end, muted_style)); + } + prev_end = cmp::max(prev_end, range.end); + + if ix + 1 == label.runs.len() && label.text.len() > prev_end { + runs.push((prev_end..label.text.len(), fade_out)); + } + + runs + }) +} + +pub(crate) fn split_words(text: &str) -> impl std::iter::Iterator + '_ { + let mut prev_index = 0; + let mut prev_codepoint: Option = None; + text.char_indices() + .chain([(text.len(), '\0')]) + .filter_map(move |(index, codepoint)| { + let prev_codepoint = prev_codepoint.replace(codepoint)?; + let is_boundary = index == text.len() + || !prev_codepoint.is_uppercase() && codepoint.is_uppercase() + || !prev_codepoint.is_alphanumeric() && codepoint.is_alphanumeric(); + if is_boundary { + let chunk = &text[prev_index..index]; + prev_index = index; + Some(chunk) + } else { + None + } + }) +} + +pub trait RangeToAnchorExt: Sized { + fn to_anchors(self, snapshot: &MultiBufferSnapshot) -> Range; + + fn to_display_points(self, snapshot: &EditorSnapshot) -> Range { + let anchor_range = self.to_anchors(&snapshot.buffer_snapshot); + anchor_range.start.to_display_point(snapshot)..anchor_range.end.to_display_point(snapshot) + } +} + +impl RangeToAnchorExt for Range { + fn to_anchors(self, snapshot: &MultiBufferSnapshot) -> Range { + let start_offset = self.start.to_offset(snapshot); + let end_offset = self.end.to_offset(snapshot); + if start_offset == end_offset { + snapshot.anchor_before(start_offset)..snapshot.anchor_before(end_offset) + } else { + snapshot.anchor_after(self.start)..snapshot.anchor_before(self.end) + } + } +} + +pub trait RowExt { + fn as_f32(&self) -> f32; + + fn next_row(&self) -> Self; + + fn previous_row(&self) -> Self; + + fn minus(&self, other: Self) -> u32; +} + +impl RowExt for DisplayRow { + fn as_f32(&self) -> f32 { + self.0 as f32 + } + + fn next_row(&self) -> Self { + Self(self.0 + 1) + } + + fn previous_row(&self) -> Self { + Self(self.0.saturating_sub(1)) + } + + fn minus(&self, other: Self) -> u32 { + self.0 - other.0 + } +} + +impl RowExt for MultiBufferRow { + fn as_f32(&self) -> f32 { + self.0 as f32 + } + + fn next_row(&self) -> Self { + Self(self.0 + 1) + } + + fn previous_row(&self) -> Self { + Self(self.0.saturating_sub(1)) + } + + fn minus(&self, other: Self) -> u32 { + self.0 - other.0 + } +} + +trait RowRangeExt { + type Row; + + fn len(&self) -> usize; + + fn iter_rows(&self) -> impl DoubleEndedIterator; +} + +impl RowRangeExt for Range { + type Row = MultiBufferRow; + + fn len(&self) -> usize { + (self.end.0 - self.start.0) as usize + } + + fn iter_rows(&self) -> impl DoubleEndedIterator { + (self.start.0..self.end.0).map(MultiBufferRow) + } +} + +impl RowRangeExt for Range { + type Row = DisplayRow; + + fn len(&self) -> usize { + (self.end.0 - self.start.0) as usize + } + + fn iter_rows(&self) -> impl DoubleEndedIterator { + (self.start.0..self.end.0).map(DisplayRow) + } +} + +/// If select range has more than one line, we +/// just point the cursor to range.start. +fn collapse_multiline_range(range: Range) -> Range { + if range.start.row == range.end.row { + range + } else { + range.start..range.start + } +} +pub struct KillRing(ClipboardItem); +impl Global for KillRing {} + +const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50); + +enum BreakpointPromptEditAction { + Log, + Condition, + HitCondition, +} + +struct BreakpointPromptEditor { + pub(crate) prompt: Entity, + editor: WeakEntity, + breakpoint_anchor: Anchor, + breakpoint: Breakpoint, + edit_action: BreakpointPromptEditAction, + block_ids: HashSet, + gutter_dimensions: Arc>, + _subscriptions: Vec, +} + +impl BreakpointPromptEditor { + const MAX_LINES: u8 = 4; + + fn new( + editor: WeakEntity, + breakpoint_anchor: Anchor, + breakpoint: Breakpoint, + edit_action: BreakpointPromptEditAction, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let base_text = match edit_action { + BreakpointPromptEditAction::Log => breakpoint.message.as_ref(), + BreakpointPromptEditAction::Condition => breakpoint.condition.as_ref(), + BreakpointPromptEditAction::HitCondition => breakpoint.hit_condition.as_ref(), + } + .map(|msg| msg.to_string()) + .unwrap_or_default(); + + let buffer = cx.new(|cx| Buffer::local(base_text, cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + + let prompt = cx.new(|cx| { + let mut prompt = Editor::new( + EditorMode::AutoHeight { + max_lines: Self::MAX_LINES as usize, + }, + buffer, + None, + window, + cx, + ); + prompt.set_soft_wrap_mode(language::language_settings::SoftWrap::EditorWidth, cx); + prompt.set_show_cursor_when_unfocused(false, cx); + prompt.set_placeholder_text( + match edit_action { + BreakpointPromptEditAction::Log => "Message to log when a breakpoint is hit. Expressions within {} are interpolated.", + BreakpointPromptEditAction::Condition => "Condition when a breakpoint is hit. Expressions within {} are interpolated.", + BreakpointPromptEditAction::HitCondition => "How many breakpoint hits to ignore", + }, + cx, + ); + + prompt + }); + + Self { + prompt, + editor, + breakpoint_anchor, + breakpoint, + edit_action, + gutter_dimensions: Arc::new(Mutex::new(GutterDimensions::default())), + block_ids: Default::default(), + _subscriptions: vec![], + } + } + + pub(crate) fn add_block_ids(&mut self, block_ids: Vec) { + self.block_ids.extend(block_ids) + } + + fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { + if let Some(editor) = self.editor.upgrade() { + let message = self + .prompt + .read(cx) + .buffer + .read(cx) + .as_singleton() + .expect("A multi buffer in breakpoint prompt isn't possible") + .read(cx) + .as_rope() + .to_string(); + + editor.update(cx, |editor, cx| { + editor.edit_breakpoint_at_anchor( + self.breakpoint_anchor, + self.breakpoint.clone(), + match self.edit_action { + BreakpointPromptEditAction::Log => { + BreakpointEditAction::EditLogMessage(message.into()) + } + BreakpointPromptEditAction::Condition => { + BreakpointEditAction::EditCondition(message.into()) + } + BreakpointPromptEditAction::HitCondition => { + BreakpointEditAction::EditHitCondition(message.into()) + } + }, + cx, + ); + + editor.remove_blocks(self.block_ids.clone(), None, cx); + cx.focus_self(window); + }); + } + } + + fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context) { + self.editor + .update(cx, |editor, cx| { + editor.remove_blocks(self.block_ids.clone(), None, cx); + window.focus(&editor.focus_handle); + }) + .log_err(); + } + + fn render_prompt_editor(&self, cx: &mut Context) -> impl IntoElement { + let settings = ThemeSettings::get_global(cx); + let text_style = TextStyle { + color: if self.prompt.read(cx).read_only(cx) { + cx.theme().colors().text_disabled + } else { + cx.theme().colors().text + }, + font_family: settings.buffer_font.family.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() + }; + EditorElement::new( + &self.prompt, + EditorStyle { + background: cx.theme().colors().editor_background, + local_player: cx.theme().players().local(), + text: text_style, + ..Default::default() + }, + ) + } +} + +impl Render for BreakpointPromptEditor { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let gutter_dimensions = *self.gutter_dimensions.lock(); + h_flex() + .key_context("Editor") + .bg(cx.theme().colors().editor_background) + .border_y_1() + .border_color(cx.theme().status().info_border) + .size_full() + .py(window.line_height() / 2.5) + .on_action(cx.listener(Self::confirm)) + .on_action(cx.listener(Self::cancel)) + .child(h_flex().w(gutter_dimensions.full_width() + (gutter_dimensions.margin / 2.0))) + .child(div().flex_1().child(self.render_prompt_editor(cx))) + } +} + +impl Focusable for BreakpointPromptEditor { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.prompt.focus_handle(cx) + } +} + +fn all_edits_insertions_or_deletions( + edits: &Vec<(Range, String)>, + snapshot: &MultiBufferSnapshot, +) -> bool { + let mut all_insertions = true; + let mut all_deletions = true; + + for (range, new_text) in edits.iter() { + let range_is_empty = range.to_offset(&snapshot).is_empty(); + let text_is_empty = new_text.is_empty(); + + if range_is_empty != text_is_empty { + if range_is_empty { + all_deletions = false; + } else { + all_insertions = false; + } + } else { + return false; + } + + if !all_insertions && !all_deletions { + return false; + } + } + all_insertions || all_deletions +} + +struct MissingEditPredictionKeybindingTooltip; + +impl Render for MissingEditPredictionKeybindingTooltip { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + ui::tooltip_container(window, cx, |container, _, cx| { + container + .flex_shrink_0() + .max_w_80() + .min_h(rems_from_px(124.)) + .justify_between() + .child( + v_flex() + .flex_1() + .text_ui_sm(cx) + .child(Label::new("Conflict with Accept Keybinding")) + .child("Your keymap currently overrides the default accept keybinding. To continue, assign one keybinding for the `editor::AcceptEditPrediction` action.") + ) + .child( + h_flex() + .pb_1() + .gap_1() + .items_end() + .w_full() + .child(Button::new("open-keymap", "Assign Keybinding").size(ButtonSize::Compact).on_click(|_ev, window, cx| { + window.dispatch_action(zed_actions::OpenKeymap.boxed_clone(), cx) + })) + .child(Button::new("see-docs", "See Docs").size(ButtonSize::Compact).on_click(|_ev, _window, cx| { + cx.open_url("https://zed.dev/docs/completions#edit-predictions-missing-keybinding"); + })), + ) + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct LineHighlight { + pub background: Background, + pub border: Option, + pub include_gutter: bool, + pub type_id: Option, +} + +fn render_diff_hunk_controls( + row: u32, + status: &DiffHunkStatus, + hunk_range: Range, + is_created_file: bool, + line_height: Pixels, + editor: &Entity, + _window: &mut Window, + cx: &mut App, +) -> AnyElement { + h_flex() + .h(line_height) + .mr_1() + .gap_1() + .px_0p5() + .pb_1() + .border_x_1() + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .rounded_b_lg() + .bg(cx.theme().colors().editor_background) + .gap_1() + .occlude() + .shadow_md() + .child(if status.has_secondary_hunk() { + Button::new(("stage", row as u64), "Stage") + .alpha(if status.is_pending() { 0.66 } else { 1.0 }) + .tooltip({ + let focus_handle = editor.focus_handle(cx); + move |window, cx| { + Tooltip::for_action_in( + "Stage Hunk", + &::git::ToggleStaged, + &focus_handle, + window, + cx, + ) + } + }) + .on_click({ + let editor = editor.clone(); + move |_event, _window, cx| { + editor.update(cx, |editor, cx| { + editor.stage_or_unstage_diff_hunks( + true, + vec![hunk_range.start..hunk_range.start], + cx, + ); + }); + } + }) + } else { + Button::new(("unstage", row as u64), "Unstage") + .alpha(if status.is_pending() { 0.66 } else { 1.0 }) + .tooltip({ + let focus_handle = editor.focus_handle(cx); + move |window, cx| { + Tooltip::for_action_in( + "Unstage Hunk", + &::git::ToggleStaged, + &focus_handle, + window, + cx, + ) + } + }) + .on_click({ + let editor = editor.clone(); + move |_event, _window, cx| { + editor.update(cx, |editor, cx| { + editor.stage_or_unstage_diff_hunks( + false, + vec![hunk_range.start..hunk_range.start], + cx, + ); + }); + } + }) + }) + .child( + Button::new(("restore", row as u64), "Restore") + .tooltip({ + let focus_handle = editor.focus_handle(cx); + move |window, cx| { + Tooltip::for_action_in( + "Restore Hunk", + &::git::Restore, + &focus_handle, + window, + cx, + ) + } + }) + .on_click({ + let editor = editor.clone(); + move |_event, window, cx| { + editor.update(cx, |editor, cx| { + let snapshot = editor.snapshot(window, cx); + let point = hunk_range.start.to_point(&snapshot.buffer_snapshot); + editor.restore_hunks_in_ranges(vec![point..point], window, cx); + }); + } + }) + .disabled(is_created_file), + ) + .when( + !editor.read(cx).buffer().read(cx).all_diff_hunks_expanded(), + |el| { + el.child( + IconButton::new(("next-hunk", row as u64), IconName::ArrowDown) + .shape(IconButtonShape::Square) + .icon_size(IconSize::Small) + // .disabled(!has_multiple_hunks) + .tooltip({ + let focus_handle = editor.focus_handle(cx); + move |window, cx| { + Tooltip::for_action_in( + "Next Hunk", + &GoToHunk, + &focus_handle, + window, + cx, + ) + } + }) + .on_click({ + let editor = editor.clone(); + move |_event, window, cx| { + editor.update(cx, |editor, cx| { + let snapshot = editor.snapshot(window, cx); + let position = + hunk_range.end.to_point(&snapshot.buffer_snapshot); + editor.go_to_hunk_before_or_after_position( + &snapshot, + position, + Direction::Next, + window, + cx, + ); + editor.expand_selected_diff_hunks(cx); + }); + } + }), + ) + .child( + IconButton::new(("prev-hunk", row as u64), IconName::ArrowUp) + .shape(IconButtonShape::Square) + .icon_size(IconSize::Small) + // .disabled(!has_multiple_hunks) + .tooltip({ + let focus_handle = editor.focus_handle(cx); + move |window, cx| { + Tooltip::for_action_in( + "Previous Hunk", + &GoToPreviousHunk, + &focus_handle, + window, + cx, + ) + } + }) + .on_click({ + let editor = editor.clone(); + move |_event, window, cx| { + editor.update(cx, |editor, cx| { + let snapshot = editor.snapshot(window, cx); + let point = + hunk_range.start.to_point(&snapshot.buffer_snapshot); + editor.go_to_hunk_before_or_after_position( + &snapshot, + point, + Direction::Prev, + window, + cx, + ); + editor.expand_selected_diff_hunks(cx); + }); + } + }), + ) + }, + ) + .into_any_element() +} diff --git a/crates/agent/src/tools/evals/fixtures/disable_cursor_blinking/possible-01.diff b/crates/agent/src/tools/evals/fixtures/disable_cursor_blinking/possible-01.diff new file mode 100644 index 0000000000000000000000000000000000000000..1a38a1967f94c974de491c712babb7882020d697 --- /dev/null +++ b/crates/agent/src/tools/evals/fixtures/disable_cursor_blinking/possible-01.diff @@ -0,0 +1,28 @@ +--- before.rs 2025-07-07 11:37:48.434629001 +0300 ++++ expected.rs 2025-07-14 10:33:53.346906775 +0300 +@@ -1780,11 +1780,11 @@ + cx.observe_window_activation(window, |editor, window, cx| { + let active = window.is_window_active(); + editor.blink_manager.update(cx, |blink_manager, cx| { +- if active { +- blink_manager.enable(cx); +- } else { +- blink_manager.disable(cx); +- } ++ // if active { ++ // blink_manager.enable(cx); ++ // } else { ++ // blink_manager.disable(cx); ++ // } + }); + }), + ], +@@ -18463,7 +18463,7 @@ + } + + self.blink_manager.update(cx, |blink_manager, cx| { +- blink_manager.enable(cx); ++ // blink_manager.enable(cx); + }); + self.show_cursor_names(window, cx); + self.buffer.update(cx, |buffer, cx| { diff --git a/crates/agent/src/tools/evals/fixtures/disable_cursor_blinking/possible-02.diff b/crates/agent/src/tools/evals/fixtures/disable_cursor_blinking/possible-02.diff new file mode 100644 index 0000000000000000000000000000000000000000..b484cce48f71b232ddaa947a73940b8bf11846c6 --- /dev/null +++ b/crates/agent/src/tools/evals/fixtures/disable_cursor_blinking/possible-02.diff @@ -0,0 +1,29 @@ +@@ -1778,13 +1778,13 @@ + cx.observe_global_in::(window, Self::settings_changed), + observe_buffer_font_size_adjustment(cx, |_, cx| cx.notify()), + cx.observe_window_activation(window, |editor, window, cx| { +- let active = window.is_window_active(); ++ // let active = window.is_window_active(); + editor.blink_manager.update(cx, |blink_manager, cx| { +- if active { +- blink_manager.enable(cx); +- } else { +- blink_manager.disable(cx); +- } ++ // if active { ++ // blink_manager.enable(cx); ++ // } else { ++ // blink_manager.disable(cx); ++ // } + }); + }), + ], +@@ -18463,7 +18463,7 @@ + } + + self.blink_manager.update(cx, |blink_manager, cx| { +- blink_manager.enable(cx); ++ // blink_manager.enable(cx); + }); + self.show_cursor_names(window, cx); + self.buffer.update(cx, |buffer, cx| { diff --git a/crates/agent/src/tools/evals/fixtures/disable_cursor_blinking/possible-03.diff b/crates/agent/src/tools/evals/fixtures/disable_cursor_blinking/possible-03.diff new file mode 100644 index 0000000000000000000000000000000000000000..431e34e48a250bff80efbd5a2cc20ecc25be1020 --- /dev/null +++ b/crates/agent/src/tools/evals/fixtures/disable_cursor_blinking/possible-03.diff @@ -0,0 +1,34 @@ +@@ -1774,17 +1774,17 @@ + cx.observe(&buffer, Self::on_buffer_changed), + cx.subscribe_in(&buffer, window, Self::on_buffer_event), + cx.observe_in(&display_map, window, Self::on_display_map_changed), +- cx.observe(&blink_manager, |_, _, cx| cx.notify()), ++ // cx.observe(&blink_manager, |_, _, cx| cx.notify()), + cx.observe_global_in::(window, Self::settings_changed), + observe_buffer_font_size_adjustment(cx, |_, cx| cx.notify()), + cx.observe_window_activation(window, |editor, window, cx| { +- let active = window.is_window_active(); ++ // let active = window.is_window_active(); + editor.blink_manager.update(cx, |blink_manager, cx| { +- if active { +- blink_manager.enable(cx); +- } else { +- blink_manager.disable(cx); +- } ++ // if active { ++ // blink_manager.enable(cx); ++ // } else { ++ // blink_manager.disable(cx); ++ // } + }); + }), + ], +@@ -18463,7 +18463,7 @@ + } + + self.blink_manager.update(cx, |blink_manager, cx| { +- blink_manager.enable(cx); ++ // blink_manager.enable(cx); + }); + self.show_cursor_names(window, cx); + self.buffer.update(cx, |buffer, cx| { diff --git a/crates/agent/src/tools/evals/fixtures/disable_cursor_blinking/possible-04.diff b/crates/agent/src/tools/evals/fixtures/disable_cursor_blinking/possible-04.diff new file mode 100644 index 0000000000000000000000000000000000000000..64a6b85dd3751407db65da74656b66ee1beaf58b --- /dev/null +++ b/crates/agent/src/tools/evals/fixtures/disable_cursor_blinking/possible-04.diff @@ -0,0 +1,33 @@ +@@ -1774,17 +1774,17 @@ + cx.observe(&buffer, Self::on_buffer_changed), + cx.subscribe_in(&buffer, window, Self::on_buffer_event), + cx.observe_in(&display_map, window, Self::on_display_map_changed), +- cx.observe(&blink_manager, |_, _, cx| cx.notify()), ++ // cx.observe(&blink_manager, |_, _, cx| cx.notify()), + cx.observe_global_in::(window, Self::settings_changed), + observe_buffer_font_size_adjustment(cx, |_, cx| cx.notify()), + cx.observe_window_activation(window, |editor, window, cx| { + let active = window.is_window_active(); + editor.blink_manager.update(cx, |blink_manager, cx| { +- if active { +- blink_manager.enable(cx); +- } else { +- blink_manager.disable(cx); +- } ++ // if active { ++ // blink_manager.enable(cx); ++ // } else { ++ // blink_manager.disable(cx); ++ // } + }); + }), + ], +@@ -18463,7 +18463,7 @@ + } + + self.blink_manager.update(cx, |blink_manager, cx| { +- blink_manager.enable(cx); ++ // blink_manager.enable(cx); + }); + self.show_cursor_names(window, cx); + self.buffer.update(cx, |buffer, cx| { diff --git a/crates/agent/src/tools/evals/fixtures/extract_handle_command_output/before.rs b/crates/agent/src/tools/evals/fixtures/extract_handle_command_output/before.rs new file mode 100644 index 0000000000000000000000000000000000000000..36fccb513271265ff7ae3d54b6f974beeb809737 --- /dev/null +++ b/crates/agent/src/tools/evals/fixtures/extract_handle_command_output/before.rs @@ -0,0 +1,371 @@ +use crate::commit::get_messages; +use crate::{GitRemote, Oid}; +use anyhow::{Context as _, Result, anyhow}; +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 time::OffsetDateTime; +use time::UtcOffset; +use time::macros::format_description; + +pub use git2 as libgit; + +#[derive(Debug, Clone, Default)] +pub struct Blame { + pub entries: Vec, + pub messages: HashMap, + pub remote_url: Option, +} + +#[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: &Path, + content: &Rope, + remote_url: Option, + ) -> Result { + let output = run_git_blame(git_binary, working_directory, path, content).await?; + let mut entries = parse_git_blame(&output)?; + entries.sort_unstable_by(|a, b| a.range.start.cmp(&b.range.start)); + + let mut unique_shas = HashSet::default(); + + for entry in entries.iter_mut() { + unique_shas.insert(entry.sha); + } + + let shas = unique_shas.into_iter().collect::>(); + let messages = get_messages(working_directory, &shas) + .await + .context("failed to get commit messages")?; + + Ok(Self { + entries, + messages, + remote_url, + }) + } +} + +const GIT_BLAME_NO_COMMIT_ERROR: &str = "fatal: no such ref: HEAD"; +const GIT_BLAME_NO_PATH: &str = "fatal: no such path"; + +async fn run_git_blame( + git_binary: &Path, + working_directory: &Path, + path: &Path, + contents: &Rope, +) -> Result { + let mut child = util::command::new_smol_command(git_binary) + .current_dir(working_directory) + .arg("blame") + .arg("--incremental") + .arg("--contents") + .arg("-") + .arg(path.as_os_str()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("starting git blame process")?; + + let stdin = child + .stdin + .as_mut() + .context("failed to get pipe to stdin of git blame command")?; + + for chunk in contents.chunks() { + stdin.write_all(chunk.as_bytes()).await?; + } + stdin.flush().await?; + + let output = child.output().await.context("reading git blame output")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let trimmed = stderr.trim(); + if trimmed == GIT_BLAME_NO_COMMIT_ERROR || trimmed.contains(GIT_BLAME_NO_PATH) { + return Ok(String::new()); + } + anyhow::bail!("git blame process failed: {stderr}"); + } + + Ok(String::from_utf8(output.stdout)?) +} + +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] +pub struct BlameEntry { + pub sha: Oid, + + pub range: Range, + + pub original_line_number: u32, + + pub author: Option, + pub author_mail: Option, + pub author_time: Option, + pub author_tz: Option, + + pub committer_name: Option, + pub committer_email: Option, + pub committer_time: Option, + pub committer_tz: Option, + + pub summary: Option, + + pub previous: Option, + pub filename: String, +} + +impl BlameEntry { + // Returns a BlameEntry by parsing the first line of a `git blame --incremental` + // entry. The line MUST have this format: + // + // <40-byte-hex-sha1> + fn new_from_blame_line(line: &str) -> Result { + let mut parts = line.split_whitespace(); + + let sha = parts + .next() + .and_then(|line| line.parse::().ok()) + .with_context(|| format!("parsing sha from {line}"))?; + + let original_line_number = parts + .next() + .and_then(|line| line.parse::().ok()) + .with_context(|| format!("parsing original line number from {line}"))?; + let final_line_number = parts + .next() + .and_then(|line| line.parse::().ok()) + .with_context(|| format!("parsing final line number from {line}"))?; + + let line_count = parts + .next() + .and_then(|line| line.parse::().ok()) + .with_context(|| format!("parsing line count from {line}"))?; + + let start_line = final_line_number.saturating_sub(1); + let end_line = start_line + line_count; + let range = start_line..end_line; + + Ok(Self { + sha, + range, + original_line_number, + ..Default::default() + }) + } + + pub fn author_offset_date_time(&self) -> Result { + if let (Some(author_time), Some(author_tz)) = (self.author_time, &self.author_tz) { + let format = format_description!("[offset_hour][offset_minute]"); + let offset = UtcOffset::parse(author_tz, &format)?; + let date_time_utc = OffsetDateTime::from_unix_timestamp(author_time)?; + + Ok(date_time_utc.to_offset(offset)) + } else { + // Directly return current time in UTC if there's no committer time or timezone + Ok(time::OffsetDateTime::now_utc()) + } + } +} + +// parse_git_blame parses the output of `git blame --incremental`, which returns +// all the blame-entries for a given path incrementally, as it finds them. +// +// Each entry *always* starts with: +// +// <40-byte-hex-sha1> +// +// Each entry *always* ends with: +// +// filename +// +// Line numbers are 1-indexed. +// +// A `git blame --incremental` entry looks like this: +// +// 6ad46b5257ba16d12c5ca9f0d4900320959df7f4 2 2 1 +// author Joe Schmoe +// author-mail +// author-time 1709741400 +// author-tz +0100 +// committer Joe Schmoe +// committer-mail +// committer-time 1709741400 +// committer-tz +0100 +// summary Joe's cool commit +// previous 486c2409237a2c627230589e567024a96751d475 index.js +// filename index.js +// +// If the entry has the same SHA as an entry that was already printed then no +// signature information is printed: +// +// 6ad46b5257ba16d12c5ca9f0d4900320959df7f4 3 4 1 +// previous 486c2409237a2c627230589e567024a96751d475 index.js +// filename index.js +// +// More about `--incremental` output: https://mirrors.edge.kernel.org/pub/software/scm/git/docs/git-blame.html +fn parse_git_blame(output: &str) -> Result> { + let mut entries: Vec = Vec::new(); + let mut index: HashMap = HashMap::default(); + + let mut current_entry: Option = None; + + for line in output.lines() { + let mut done = false; + + match &mut current_entry { + None => { + let mut new_entry = BlameEntry::new_from_blame_line(line)?; + + if let Some(existing_entry) = index + .get(&new_entry.sha) + .and_then(|slot| entries.get(*slot)) + { + new_entry.author.clone_from(&existing_entry.author); + new_entry + .author_mail + .clone_from(&existing_entry.author_mail); + new_entry.author_time = existing_entry.author_time; + new_entry.author_tz.clone_from(&existing_entry.author_tz); + new_entry + .committer_name + .clone_from(&existing_entry.committer_name); + new_entry + .committer_email + .clone_from(&existing_entry.committer_email); + new_entry.committer_time = existing_entry.committer_time; + new_entry + .committer_tz + .clone_from(&existing_entry.committer_tz); + new_entry.summary.clone_from(&existing_entry.summary); + } + + current_entry.replace(new_entry); + } + Some(entry) => { + let Some((key, value)) = line.split_once(' ') else { + continue; + }; + let is_committed = !entry.sha.is_zero(); + match key { + "filename" => { + entry.filename = value.into(); + done = true; + } + "previous" => entry.previous = Some(value.into()), + + "summary" if is_committed => entry.summary = Some(value.into()), + "author" if is_committed => entry.author = Some(value.into()), + "author-mail" if is_committed => entry.author_mail = Some(value.into()), + "author-time" if is_committed => { + entry.author_time = Some(value.parse::()?) + } + "author-tz" if is_committed => entry.author_tz = Some(value.into()), + + "committer" if is_committed => entry.committer_name = Some(value.into()), + "committer-mail" if is_committed => entry.committer_email = Some(value.into()), + "committer-time" if is_committed => { + entry.committer_time = Some(value.parse::()?) + } + "committer-tz" if is_committed => entry.committer_tz = Some(value.into()), + _ => {} + } + } + }; + + if done { + if let Some(entry) = current_entry.take() { + index.insert(entry.sha, entries.len()); + + // We only want annotations that have a commit. + if !entry.sha.is_zero() { + entries.push(entry); + } + } + } + } + + Ok(entries) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::BlameEntry; + use super::parse_git_blame; + + fn read_test_data(filename: &str) -> String { + let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + path.push("test_data"); + path.push(filename); + + std::fs::read_to_string(&path) + .unwrap_or_else(|_| panic!("Could not read test data at {:?}. Is it generated?", path)) + } + + fn assert_eq_golden(entries: &Vec, golden_filename: &str) { + let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + path.push("test_data"); + path.push("golden"); + path.push(format!("{}.json", golden_filename)); + + let mut have_json = + serde_json::to_string_pretty(&entries).expect("could not serialize entries to JSON"); + // We always want to save with a trailing newline. + have_json.push('\n'); + + let update = std::env::var("UPDATE_GOLDEN") + .map(|val| val.eq_ignore_ascii_case("true")) + .unwrap_or(false); + + if update { + std::fs::create_dir_all(path.parent().unwrap()) + .expect("could not create golden test data directory"); + std::fs::write(&path, have_json).expect("could not write out golden data"); + } else { + let want_json = + std::fs::read_to_string(&path).unwrap_or_else(|_| { + panic!("could not read golden test data file at {:?}. Did you run the test with UPDATE_GOLDEN=true before?", path); + }).replace("\r\n", "\n"); + + pretty_assertions::assert_eq!(have_json, want_json, "wrong blame entries"); + } + } + + #[test] + fn test_parse_git_blame_not_committed() { + let output = read_test_data("blame_incremental_not_committed"); + let entries = parse_git_blame(&output).unwrap(); + assert_eq_golden(&entries, "blame_incremental_not_committed"); + } + + #[test] + fn test_parse_git_blame_simple() { + let output = read_test_data("blame_incremental_simple"); + let entries = parse_git_blame(&output).unwrap(); + assert_eq_golden(&entries, "blame_incremental_simple"); + } + + #[test] + fn test_parse_git_blame_complex() { + let output = read_test_data("blame_incremental_complex"); + let entries = parse_git_blame(&output).unwrap(); + assert_eq_golden(&entries, "blame_incremental_complex"); + } +} diff --git a/crates/agent/src/tools/evals/fixtures/extract_handle_command_output/possible-01.diff b/crates/agent/src/tools/evals/fixtures/extract_handle_command_output/possible-01.diff new file mode 100644 index 0000000000000000000000000000000000000000..c13a223c63f4226ac0f1bf5e7221551e586827f5 --- /dev/null +++ b/crates/agent/src/tools/evals/fixtures/extract_handle_command_output/possible-01.diff @@ -0,0 +1,11 @@ +@@ -94,6 +94,10 @@ + + let output = child.output().await.context("reading git blame output")?; + ++ handle_command_output(output) ++} ++ ++fn handle_command_output(output: std::process::Output) -> Result { + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let trimmed = stderr.trim(); diff --git a/crates/agent/src/tools/evals/fixtures/extract_handle_command_output/possible-02.diff b/crates/agent/src/tools/evals/fixtures/extract_handle_command_output/possible-02.diff new file mode 100644 index 0000000000000000000000000000000000000000..aa36a9241e9706a3413277f07c7a2a0364df24b7 --- /dev/null +++ b/crates/agent/src/tools/evals/fixtures/extract_handle_command_output/possible-02.diff @@ -0,0 +1,26 @@ +@@ -95,15 +95,19 @@ + let output = child.output().await.context("reading git blame output")?; + + if !output.status.success() { +- let stderr = String::from_utf8_lossy(&output.stderr); +- let trimmed = stderr.trim(); +- if trimmed == GIT_BLAME_NO_COMMIT_ERROR || trimmed.contains(GIT_BLAME_NO_PATH) { +- return Ok(String::new()); +- } +- anyhow::bail!("git blame process failed: {stderr}"); ++ return handle_command_output(output); + } + + Ok(String::from_utf8(output.stdout)?) ++} ++ ++fn handle_command_output(output: std::process::Output) -> Result { ++ let stderr = String::from_utf8_lossy(&output.stderr); ++ let trimmed = stderr.trim(); ++ if trimmed == GIT_BLAME_NO_COMMIT_ERROR || trimmed.contains(GIT_BLAME_NO_PATH) { ++ return Ok(String::new()); ++ } ++ anyhow::bail!("git blame process failed: {stderr}"); + } + + #[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] diff --git a/crates/agent/src/tools/evals/fixtures/extract_handle_command_output/possible-03.diff b/crates/agent/src/tools/evals/fixtures/extract_handle_command_output/possible-03.diff new file mode 100644 index 0000000000000000000000000000000000000000..d3c19b43803941ca9c17ace5d72fe72d6c3361df --- /dev/null +++ b/crates/agent/src/tools/evals/fixtures/extract_handle_command_output/possible-03.diff @@ -0,0 +1,11 @@ +@@ -93,7 +93,10 @@ + stdin.flush().await?; + + let output = child.output().await.context("reading git blame output")?; ++ handle_command_output(output) ++} + ++fn handle_command_output(output: std::process::Output) -> Result { + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let trimmed = stderr.trim(); diff --git a/crates/agent/src/tools/evals/fixtures/extract_handle_command_output/possible-04.diff b/crates/agent/src/tools/evals/fixtures/extract_handle_command_output/possible-04.diff new file mode 100644 index 0000000000000000000000000000000000000000..1f87e4352c60ceb3df2fab57dd7b7e7e13dad95e --- /dev/null +++ b/crates/agent/src/tools/evals/fixtures/extract_handle_command_output/possible-04.diff @@ -0,0 +1,24 @@ +@@ -93,17 +93,20 @@ + stdin.flush().await?; + + let output = child.output().await.context("reading git blame output")?; ++ handle_command_output(&output)?; ++ Ok(String::from_utf8(output.stdout)?) ++} + ++fn handle_command_output(output: &std::process::Output) -> Result<()> { + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let trimmed = stderr.trim(); + if trimmed == GIT_BLAME_NO_COMMIT_ERROR || trimmed.contains(GIT_BLAME_NO_PATH) { +- return Ok(String::new()); ++ return Ok(()); + } + anyhow::bail!("git blame process failed: {stderr}"); + } +- +- Ok(String::from_utf8(output.stdout)?) ++ Ok(()) + } + + #[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] diff --git a/crates/agent/src/tools/evals/fixtures/extract_handle_command_output/possible-05.diff b/crates/agent/src/tools/evals/fixtures/extract_handle_command_output/possible-05.diff new file mode 100644 index 0000000000000000000000000000000000000000..8f4b745b9a1105a2ff6511c141ea7459edb47b77 --- /dev/null +++ b/crates/agent/src/tools/evals/fixtures/extract_handle_command_output/possible-05.diff @@ -0,0 +1,26 @@ +@@ -95,15 +95,19 @@ + let output = child.output().await.context("reading git blame output")?; + + if !output.status.success() { +- let stderr = String::from_utf8_lossy(&output.stderr); +- let trimmed = stderr.trim(); +- if trimmed == GIT_BLAME_NO_COMMIT_ERROR || trimmed.contains(GIT_BLAME_NO_PATH) { +- return Ok(String::new()); +- } +- anyhow::bail!("git blame process failed: {stderr}"); ++ return handle_command_output(&output); + } + + Ok(String::from_utf8(output.stdout)?) ++} ++ ++fn handle_command_output(output: &std::process::Output) -> Result { ++ let stderr = String::from_utf8_lossy(&output.stderr); ++ let trimmed = stderr.trim(); ++ if trimmed == GIT_BLAME_NO_COMMIT_ERROR || trimmed.contains(GIT_BLAME_NO_PATH) { ++ return Ok(String::new()); ++ } ++ anyhow::bail!("git blame process failed: {stderr}"); + } + + #[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] diff --git a/crates/agent/src/tools/evals/fixtures/extract_handle_command_output/possible-06.diff b/crates/agent/src/tools/evals/fixtures/extract_handle_command_output/possible-06.diff new file mode 100644 index 0000000000000000000000000000000000000000..3514d9c8e2969c7286398f41cd8e00e3172774a8 --- /dev/null +++ b/crates/agent/src/tools/evals/fixtures/extract_handle_command_output/possible-06.diff @@ -0,0 +1,23 @@ +@@ -93,7 +93,12 @@ + stdin.flush().await?; + + let output = child.output().await.context("reading git blame output")?; ++ handle_command_output(&output)?; + ++ Ok(String::from_utf8(output.stdout)?) ++} ++ ++fn handle_command_output(output: &std::process::Output) -> Result { + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let trimmed = stderr.trim(); +@@ -102,8 +107,7 @@ + } + anyhow::bail!("git blame process failed: {stderr}"); + } +- +- Ok(String::from_utf8(output.stdout)?) ++ Ok(String::from_utf8_lossy(&output.stdout).into_owned()) + } + + #[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] diff --git a/crates/agent/src/tools/evals/fixtures/extract_handle_command_output/possible-07.diff b/crates/agent/src/tools/evals/fixtures/extract_handle_command_output/possible-07.diff new file mode 100644 index 0000000000000000000000000000000000000000..9691479e2997ca654e1092499a880507c38b979c --- /dev/null +++ b/crates/agent/src/tools/evals/fixtures/extract_handle_command_output/possible-07.diff @@ -0,0 +1,26 @@ +@@ -95,15 +95,19 @@ + let output = child.output().await.context("reading git blame output")?; + + if !output.status.success() { +- let stderr = String::from_utf8_lossy(&output.stderr); +- let trimmed = stderr.trim(); +- if trimmed == GIT_BLAME_NO_COMMIT_ERROR || trimmed.contains(GIT_BLAME_NO_PATH) { +- return Ok(String::new()); +- } +- anyhow::bail!("git blame process failed: {stderr}"); ++ return handle_command_output(output); + } + + Ok(String::from_utf8(output.stdout)?) ++} ++ ++fn handle_command_output(output: std::process::Output) -> Result { ++ let stderr = String::from_utf8_lossy(&output.stderr); ++ let trimmed = stderr.trim(); ++ if trimmed == GIT_BLAME_NO_COMMIT_ERROR || trimmed.contains(GIT_BLAME_NO_PATH) { ++ return Ok(String::new()); ++ } ++ anyhow::bail!("git blame process failed: {stderr}"); + } + + #[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] diff --git a/crates/agent/src/tools/evals/fixtures/extract_handle_command_output/possible-08.diff b/crates/agent/src/tools/evals/fixtures/extract_handle_command_output/possible-08.diff new file mode 100644 index 0000000000000000000000000000000000000000..f5da859005aef07d1c39e516d7c4688c575c7e9d --- /dev/null +++ b/crates/agent/src/tools/evals/fixtures/extract_handle_command_output/possible-08.diff @@ -0,0 +1,26 @@ +@@ -95,15 +95,19 @@ + let output = child.output().await.context("reading git blame output")?; + + if !output.status.success() { +- let stderr = String::from_utf8_lossy(&output.stderr); +- let trimmed = stderr.trim(); +- if trimmed == GIT_BLAME_NO_COMMIT_ERROR || trimmed.contains(GIT_BLAME_NO_PATH) { +- return Ok(String::new()); +- } +- anyhow::bail!("git blame process failed: {stderr}"); ++ return handle_command_output(output); + } + + Ok(String::from_utf8(output.stdout)?) ++} ++ ++fn handle_command_output(output: std::process::Output) -> Result { ++ let stderr = String::from_utf8_lossy(&output.stderr); ++ let trimmed = stderr.trim(); ++ if trimmed == GIT_BLAME_NO_COMMIT_ERROR || trimmed.contains(GIT_BLAME_NO_PATH) { ++ return Ok(String::new()); ++ } ++ anyhow::bail!("git blame process failed: {stderr}") + } + + #[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] diff --git a/crates/agent/src/tools/evals/fixtures/extract_handle_command_output/possible-09.diff b/crates/agent/src/tools/evals/fixtures/extract_handle_command_output/possible-09.diff new file mode 100644 index 0000000000000000000000000000000000000000..6bc45657b3d6bf23b4542deb4f6016472a0e89b9 --- /dev/null +++ b/crates/agent/src/tools/evals/fixtures/extract_handle_command_output/possible-09.diff @@ -0,0 +1,20 @@ +@@ -5,7 +5,7 @@ + use futures::AsyncWriteExt; + use gpui::SharedString; + use serde::{Deserialize, Serialize}; +-use std::process::Stdio; ++use std::process::{Output, Stdio}; + use std::{ops::Range, path::Path}; + use text::Rope; + use time::OffsetDateTime; +@@ -94,6 +94,10 @@ + + let output = child.output().await.context("reading git blame output")?; + ++ handle_command_output(output) ++} ++ ++fn handle_command_output(output: Output) -> Result { + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let trimmed = stderr.trim(); diff --git a/crates/agent/src/tools/evals/fixtures/from_pixels_constructor/before.rs b/crates/agent/src/tools/evals/fixtures/from_pixels_constructor/before.rs new file mode 100644 index 0000000000000000000000000000000000000000..12590fe6e93dc61f5c319d650b637654c39707d3 --- /dev/null +++ b/crates/agent/src/tools/evals/fixtures/from_pixels_constructor/before.rs @@ -0,0 +1,339 @@ +// font-kit/src/canvas.rs +// +// Copyright © 2018 The Pathfinder Project Developers. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +//! An in-memory bitmap surface for glyph rasterization. + +use lazy_static::lazy_static; +use pathfinder_geometry::rect::RectI; +use pathfinder_geometry::vector::Vector2I; +use std::cmp; +use std::fmt; + +use crate::utils; + +lazy_static! { + static ref BITMAP_1BPP_TO_8BPP_LUT: [[u8; 8]; 256] = { + let mut lut = [[0; 8]; 256]; + for byte in 0..0x100 { + let mut value = [0; 8]; + for bit in 0..8 { + if (byte & (0x80 >> bit)) != 0 { + value[bit] = 0xff; + } + } + lut[byte] = value + } + lut + }; +} + +/// An in-memory bitmap surface for glyph rasterization. +pub struct Canvas { + /// The raw pixel data. + pub pixels: Vec, + /// The size of the buffer, in pixels. + pub size: Vector2I, + /// The number of *bytes* between successive rows. + pub stride: usize, + /// The image format of the canvas. + pub format: Format, +} + +impl Canvas { + /// Creates a new blank canvas with the given pixel size and format. + /// + /// Stride is automatically calculated from width. + /// + /// The canvas is initialized with transparent black (all values 0). + #[inline] + pub fn new(size: Vector2I, format: Format) -> Canvas { + Canvas::with_stride( + size, + size.x() as usize * format.bytes_per_pixel() as usize, + format, + ) + } + + /// Creates a new blank canvas with the given pixel size, stride (number of bytes between + /// successive rows), and format. + /// + /// The canvas is initialized with transparent black (all values 0). + pub fn with_stride(size: Vector2I, stride: usize, format: Format) -> Canvas { + Canvas { + pixels: vec![0; stride * size.y() as usize], + size, + stride, + format, + } + } + + #[allow(dead_code)] + pub(crate) fn blit_from_canvas(&mut self, src: &Canvas) { + self.blit_from( + Vector2I::default(), + &src.pixels, + src.size, + src.stride, + src.format, + ) + } + + /// Blits to a rectangle with origin at `dst_point` and size according to `src_size`. + /// If the target area overlaps the boundaries of the canvas, only the drawable region is blitted. + /// `dst_point` and `src_size` are specified in pixels. `src_stride` is specified in bytes. + /// `src_stride` must be equal or larger than the actual data length. + #[allow(dead_code)] + pub(crate) fn blit_from( + &mut self, + dst_point: Vector2I, + src_bytes: &[u8], + src_size: Vector2I, + src_stride: usize, + src_format: Format, + ) { + assert_eq!( + src_stride * src_size.y() as usize, + src_bytes.len(), + "Number of pixels in src_bytes does not match stride and size." + ); + assert!( + src_stride >= src_size.x() as usize * src_format.bytes_per_pixel() as usize, + "src_stride must be >= than src_size.x()" + ); + + let dst_rect = RectI::new(dst_point, src_size); + let dst_rect = dst_rect.intersection(RectI::new(Vector2I::default(), self.size)); + let dst_rect = match dst_rect { + Some(dst_rect) => dst_rect, + None => return, + }; + + match (self.format, src_format) { + (Format::A8, Format::A8) + | (Format::Rgb24, Format::Rgb24) + | (Format::Rgba32, Format::Rgba32) => { + self.blit_from_with::(dst_rect, src_bytes, src_stride, src_format) + } + (Format::A8, Format::Rgb24) => { + self.blit_from_with::(dst_rect, src_bytes, src_stride, src_format) + } + (Format::Rgb24, Format::A8) => { + self.blit_from_with::(dst_rect, src_bytes, src_stride, src_format) + } + (Format::Rgb24, Format::Rgba32) => self + .blit_from_with::(dst_rect, src_bytes, src_stride, src_format), + (Format::Rgba32, Format::Rgb24) => self + .blit_from_with::(dst_rect, src_bytes, src_stride, src_format), + (Format::Rgba32, Format::A8) | (Format::A8, Format::Rgba32) => unimplemented!(), + } + } + + #[allow(dead_code)] + pub(crate) fn blit_from_bitmap_1bpp( + &mut self, + dst_point: Vector2I, + src_bytes: &[u8], + src_size: Vector2I, + src_stride: usize, + ) { + if self.format != Format::A8 { + unimplemented!() + } + + let dst_rect = RectI::new(dst_point, src_size); + let dst_rect = dst_rect.intersection(RectI::new(Vector2I::default(), self.size)); + let dst_rect = match dst_rect { + Some(dst_rect) => dst_rect, + None => return, + }; + + let size = dst_rect.size(); + + let dest_bytes_per_pixel = self.format.bytes_per_pixel() as usize; + let dest_row_stride = size.x() as usize * dest_bytes_per_pixel; + let src_row_stride = utils::div_round_up(size.x() as usize, 8); + + for y in 0..size.y() { + let (dest_row_start, src_row_start) = ( + (y + dst_rect.origin_y()) as usize * self.stride + + dst_rect.origin_x() as usize * dest_bytes_per_pixel, + y as usize * src_stride, + ); + let dest_row_end = dest_row_start + dest_row_stride; + let src_row_end = src_row_start + src_row_stride; + let dest_row_pixels = &mut self.pixels[dest_row_start..dest_row_end]; + let src_row_pixels = &src_bytes[src_row_start..src_row_end]; + for x in 0..src_row_stride { + let pattern = &BITMAP_1BPP_TO_8BPP_LUT[src_row_pixels[x] as usize]; + let dest_start = x * 8; + let dest_end = cmp::min(dest_start + 8, dest_row_stride); + let src = &pattern[0..(dest_end - dest_start)]; + dest_row_pixels[dest_start..dest_end].clone_from_slice(src); + } + } + } + + /// Blits to area `rect` using the data given in the buffer `src_bytes`. + /// `src_stride` must be specified in bytes. + /// The dimensions of `rect` must be in pixels. + fn blit_from_with( + &mut self, + rect: RectI, + src_bytes: &[u8], + src_stride: usize, + src_format: Format, + ) { + let src_bytes_per_pixel = src_format.bytes_per_pixel() as usize; + let dest_bytes_per_pixel = self.format.bytes_per_pixel() as usize; + + for y in 0..rect.height() { + let (dest_row_start, src_row_start) = ( + (y + rect.origin_y()) as usize * self.stride + + rect.origin_x() as usize * dest_bytes_per_pixel, + y as usize * src_stride, + ); + let dest_row_end = dest_row_start + rect.width() as usize * dest_bytes_per_pixel; + let src_row_end = src_row_start + rect.width() as usize * src_bytes_per_pixel; + let dest_row_pixels = &mut self.pixels[dest_row_start..dest_row_end]; + let src_row_pixels = &src_bytes[src_row_start..src_row_end]; + B::blit(dest_row_pixels, src_row_pixels) + } + } +} + +impl fmt::Debug for Canvas { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.debug_struct("Canvas") + .field("pixels", &self.pixels.len()) // Do not dump a vector content. + .field("size", &self.size) + .field("stride", &self.stride) + .field("format", &self.format) + .finish() + } +} + +/// The image format for the canvas. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Format { + /// Premultiplied R8G8B8A8, little-endian. + Rgba32, + /// R8G8B8, little-endian. + Rgb24, + /// A8. + A8, +} + +impl Format { + /// Returns the number of bits per pixel that this image format corresponds to. + #[inline] + pub fn bits_per_pixel(self) -> u8 { + match self { + Format::Rgba32 => 32, + Format::Rgb24 => 24, + Format::A8 => 8, + } + } + + /// Returns the number of color channels per pixel that this image format corresponds to. + #[inline] + pub fn components_per_pixel(self) -> u8 { + match self { + Format::Rgba32 => 4, + Format::Rgb24 => 3, + Format::A8 => 1, + } + } + + /// Returns the number of bits per color channel that this image format contains. + #[inline] + pub fn bits_per_component(self) -> u8 { + self.bits_per_pixel() / self.components_per_pixel() + } + + /// Returns the number of bytes per pixel that this image format corresponds to. + #[inline] + pub fn bytes_per_pixel(self) -> u8 { + self.bits_per_pixel() / 8 + } +} + +/// The antialiasing strategy that should be used when rasterizing glyphs. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum RasterizationOptions { + /// "Black-and-white" rendering. Each pixel is either entirely on or off. + Bilevel, + /// Grayscale antialiasing. Only one channel is used. + GrayscaleAa, + /// Subpixel RGB antialiasing, for LCD screens. + SubpixelAa, +} + +trait Blit { + fn blit(dest: &mut [u8], src: &[u8]); +} + +struct BlitMemcpy; + +impl Blit for BlitMemcpy { + #[inline] + fn blit(dest: &mut [u8], src: &[u8]) { + dest.clone_from_slice(src) + } +} + +struct BlitRgb24ToA8; + +impl Blit for BlitRgb24ToA8 { + #[inline] + fn blit(dest: &mut [u8], src: &[u8]) { + // TODO(pcwalton): SIMD. + for (dest, src) in dest.iter_mut().zip(src.chunks(3)) { + *dest = src[1] + } + } +} + +struct BlitA8ToRgb24; + +impl Blit for BlitA8ToRgb24 { + #[inline] + fn blit(dest: &mut [u8], src: &[u8]) { + for (dest, src) in dest.chunks_mut(3).zip(src.iter()) { + dest[0] = *src; + dest[1] = *src; + dest[2] = *src; + } + } +} + +struct BlitRgba32ToRgb24; + +impl Blit for BlitRgba32ToRgb24 { + #[inline] + fn blit(dest: &mut [u8], src: &[u8]) { + // TODO(pcwalton): SIMD. + for (dest, src) in dest.chunks_mut(3).zip(src.chunks(4)) { + dest.copy_from_slice(&src[0..3]) + } + } +} + +struct BlitRgb24ToRgba32; + +impl Blit for BlitRgb24ToRgba32 { + fn blit(dest: &mut [u8], src: &[u8]) { + for (dest, src) in dest.chunks_mut(4).zip(src.chunks(3)) { + dest[0] = src[0]; + dest[1] = src[1]; + dest[2] = src[2]; + dest[3] = 255; + } + } +} diff --git a/crates/agent/src/tools/evals/fixtures/translate_doc_comments/before.rs b/crates/agent/src/tools/evals/fixtures/translate_doc_comments/before.rs new file mode 100644 index 0000000000000000000000000000000000000000..12590fe6e93dc61f5c319d650b637654c39707d3 --- /dev/null +++ b/crates/agent/src/tools/evals/fixtures/translate_doc_comments/before.rs @@ -0,0 +1,339 @@ +// font-kit/src/canvas.rs +// +// Copyright © 2018 The Pathfinder Project Developers. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +//! An in-memory bitmap surface for glyph rasterization. + +use lazy_static::lazy_static; +use pathfinder_geometry::rect::RectI; +use pathfinder_geometry::vector::Vector2I; +use std::cmp; +use std::fmt; + +use crate::utils; + +lazy_static! { + static ref BITMAP_1BPP_TO_8BPP_LUT: [[u8; 8]; 256] = { + let mut lut = [[0; 8]; 256]; + for byte in 0..0x100 { + let mut value = [0; 8]; + for bit in 0..8 { + if (byte & (0x80 >> bit)) != 0 { + value[bit] = 0xff; + } + } + lut[byte] = value + } + lut + }; +} + +/// An in-memory bitmap surface for glyph rasterization. +pub struct Canvas { + /// The raw pixel data. + pub pixels: Vec, + /// The size of the buffer, in pixels. + pub size: Vector2I, + /// The number of *bytes* between successive rows. + pub stride: usize, + /// The image format of the canvas. + pub format: Format, +} + +impl Canvas { + /// Creates a new blank canvas with the given pixel size and format. + /// + /// Stride is automatically calculated from width. + /// + /// The canvas is initialized with transparent black (all values 0). + #[inline] + pub fn new(size: Vector2I, format: Format) -> Canvas { + Canvas::with_stride( + size, + size.x() as usize * format.bytes_per_pixel() as usize, + format, + ) + } + + /// Creates a new blank canvas with the given pixel size, stride (number of bytes between + /// successive rows), and format. + /// + /// The canvas is initialized with transparent black (all values 0). + pub fn with_stride(size: Vector2I, stride: usize, format: Format) -> Canvas { + Canvas { + pixels: vec![0; stride * size.y() as usize], + size, + stride, + format, + } + } + + #[allow(dead_code)] + pub(crate) fn blit_from_canvas(&mut self, src: &Canvas) { + self.blit_from( + Vector2I::default(), + &src.pixels, + src.size, + src.stride, + src.format, + ) + } + + /// Blits to a rectangle with origin at `dst_point` and size according to `src_size`. + /// If the target area overlaps the boundaries of the canvas, only the drawable region is blitted. + /// `dst_point` and `src_size` are specified in pixels. `src_stride` is specified in bytes. + /// `src_stride` must be equal or larger than the actual data length. + #[allow(dead_code)] + pub(crate) fn blit_from( + &mut self, + dst_point: Vector2I, + src_bytes: &[u8], + src_size: Vector2I, + src_stride: usize, + src_format: Format, + ) { + assert_eq!( + src_stride * src_size.y() as usize, + src_bytes.len(), + "Number of pixels in src_bytes does not match stride and size." + ); + assert!( + src_stride >= src_size.x() as usize * src_format.bytes_per_pixel() as usize, + "src_stride must be >= than src_size.x()" + ); + + let dst_rect = RectI::new(dst_point, src_size); + let dst_rect = dst_rect.intersection(RectI::new(Vector2I::default(), self.size)); + let dst_rect = match dst_rect { + Some(dst_rect) => dst_rect, + None => return, + }; + + match (self.format, src_format) { + (Format::A8, Format::A8) + | (Format::Rgb24, Format::Rgb24) + | (Format::Rgba32, Format::Rgba32) => { + self.blit_from_with::(dst_rect, src_bytes, src_stride, src_format) + } + (Format::A8, Format::Rgb24) => { + self.blit_from_with::(dst_rect, src_bytes, src_stride, src_format) + } + (Format::Rgb24, Format::A8) => { + self.blit_from_with::(dst_rect, src_bytes, src_stride, src_format) + } + (Format::Rgb24, Format::Rgba32) => self + .blit_from_with::(dst_rect, src_bytes, src_stride, src_format), + (Format::Rgba32, Format::Rgb24) => self + .blit_from_with::(dst_rect, src_bytes, src_stride, src_format), + (Format::Rgba32, Format::A8) | (Format::A8, Format::Rgba32) => unimplemented!(), + } + } + + #[allow(dead_code)] + pub(crate) fn blit_from_bitmap_1bpp( + &mut self, + dst_point: Vector2I, + src_bytes: &[u8], + src_size: Vector2I, + src_stride: usize, + ) { + if self.format != Format::A8 { + unimplemented!() + } + + let dst_rect = RectI::new(dst_point, src_size); + let dst_rect = dst_rect.intersection(RectI::new(Vector2I::default(), self.size)); + let dst_rect = match dst_rect { + Some(dst_rect) => dst_rect, + None => return, + }; + + let size = dst_rect.size(); + + let dest_bytes_per_pixel = self.format.bytes_per_pixel() as usize; + let dest_row_stride = size.x() as usize * dest_bytes_per_pixel; + let src_row_stride = utils::div_round_up(size.x() as usize, 8); + + for y in 0..size.y() { + let (dest_row_start, src_row_start) = ( + (y + dst_rect.origin_y()) as usize * self.stride + + dst_rect.origin_x() as usize * dest_bytes_per_pixel, + y as usize * src_stride, + ); + let dest_row_end = dest_row_start + dest_row_stride; + let src_row_end = src_row_start + src_row_stride; + let dest_row_pixels = &mut self.pixels[dest_row_start..dest_row_end]; + let src_row_pixels = &src_bytes[src_row_start..src_row_end]; + for x in 0..src_row_stride { + let pattern = &BITMAP_1BPP_TO_8BPP_LUT[src_row_pixels[x] as usize]; + let dest_start = x * 8; + let dest_end = cmp::min(dest_start + 8, dest_row_stride); + let src = &pattern[0..(dest_end - dest_start)]; + dest_row_pixels[dest_start..dest_end].clone_from_slice(src); + } + } + } + + /// Blits to area `rect` using the data given in the buffer `src_bytes`. + /// `src_stride` must be specified in bytes. + /// The dimensions of `rect` must be in pixels. + fn blit_from_with( + &mut self, + rect: RectI, + src_bytes: &[u8], + src_stride: usize, + src_format: Format, + ) { + let src_bytes_per_pixel = src_format.bytes_per_pixel() as usize; + let dest_bytes_per_pixel = self.format.bytes_per_pixel() as usize; + + for y in 0..rect.height() { + let (dest_row_start, src_row_start) = ( + (y + rect.origin_y()) as usize * self.stride + + rect.origin_x() as usize * dest_bytes_per_pixel, + y as usize * src_stride, + ); + let dest_row_end = dest_row_start + rect.width() as usize * dest_bytes_per_pixel; + let src_row_end = src_row_start + rect.width() as usize * src_bytes_per_pixel; + let dest_row_pixels = &mut self.pixels[dest_row_start..dest_row_end]; + let src_row_pixels = &src_bytes[src_row_start..src_row_end]; + B::blit(dest_row_pixels, src_row_pixels) + } + } +} + +impl fmt::Debug for Canvas { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.debug_struct("Canvas") + .field("pixels", &self.pixels.len()) // Do not dump a vector content. + .field("size", &self.size) + .field("stride", &self.stride) + .field("format", &self.format) + .finish() + } +} + +/// The image format for the canvas. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Format { + /// Premultiplied R8G8B8A8, little-endian. + Rgba32, + /// R8G8B8, little-endian. + Rgb24, + /// A8. + A8, +} + +impl Format { + /// Returns the number of bits per pixel that this image format corresponds to. + #[inline] + pub fn bits_per_pixel(self) -> u8 { + match self { + Format::Rgba32 => 32, + Format::Rgb24 => 24, + Format::A8 => 8, + } + } + + /// Returns the number of color channels per pixel that this image format corresponds to. + #[inline] + pub fn components_per_pixel(self) -> u8 { + match self { + Format::Rgba32 => 4, + Format::Rgb24 => 3, + Format::A8 => 1, + } + } + + /// Returns the number of bits per color channel that this image format contains. + #[inline] + pub fn bits_per_component(self) -> u8 { + self.bits_per_pixel() / self.components_per_pixel() + } + + /// Returns the number of bytes per pixel that this image format corresponds to. + #[inline] + pub fn bytes_per_pixel(self) -> u8 { + self.bits_per_pixel() / 8 + } +} + +/// The antialiasing strategy that should be used when rasterizing glyphs. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum RasterizationOptions { + /// "Black-and-white" rendering. Each pixel is either entirely on or off. + Bilevel, + /// Grayscale antialiasing. Only one channel is used. + GrayscaleAa, + /// Subpixel RGB antialiasing, for LCD screens. + SubpixelAa, +} + +trait Blit { + fn blit(dest: &mut [u8], src: &[u8]); +} + +struct BlitMemcpy; + +impl Blit for BlitMemcpy { + #[inline] + fn blit(dest: &mut [u8], src: &[u8]) { + dest.clone_from_slice(src) + } +} + +struct BlitRgb24ToA8; + +impl Blit for BlitRgb24ToA8 { + #[inline] + fn blit(dest: &mut [u8], src: &[u8]) { + // TODO(pcwalton): SIMD. + for (dest, src) in dest.iter_mut().zip(src.chunks(3)) { + *dest = src[1] + } + } +} + +struct BlitA8ToRgb24; + +impl Blit for BlitA8ToRgb24 { + #[inline] + fn blit(dest: &mut [u8], src: &[u8]) { + for (dest, src) in dest.chunks_mut(3).zip(src.iter()) { + dest[0] = *src; + dest[1] = *src; + dest[2] = *src; + } + } +} + +struct BlitRgba32ToRgb24; + +impl Blit for BlitRgba32ToRgb24 { + #[inline] + fn blit(dest: &mut [u8], src: &[u8]) { + // TODO(pcwalton): SIMD. + for (dest, src) in dest.chunks_mut(3).zip(src.chunks(4)) { + dest.copy_from_slice(&src[0..3]) + } + } +} + +struct BlitRgb24ToRgba32; + +impl Blit for BlitRgb24ToRgba32 { + fn blit(dest: &mut [u8], src: &[u8]) { + for (dest, src) in dest.chunks_mut(4).zip(src.chunks(3)) { + dest[0] = src[0]; + dest[1] = src[1]; + dest[2] = src[2]; + dest[3] = 255; + } + } +} diff --git a/crates/agent/src/tools/evals/fixtures/use_wasi_sdk_in_compile_parser_to_wasm/before.rs b/crates/agent/src/tools/evals/fixtures/use_wasi_sdk_in_compile_parser_to_wasm/before.rs new file mode 100644 index 0000000000000000000000000000000000000000..cfa28fe1ad6091c9adda22f610e1cf13166f8dfb --- /dev/null +++ b/crates/agent/src/tools/evals/fixtures/use_wasi_sdk_in_compile_parser_to_wasm/before.rs @@ -0,0 +1,1629 @@ +#![doc = include_str!("../README.md")] +#![cfg_attr(docsrs, feature(doc_cfg))] + +#[cfg(any(feature = "tree-sitter-highlight", feature = "tree-sitter-tags"))] +use std::ops::Range; +#[cfg(feature = "tree-sitter-highlight")] +use std::sync::Mutex; +use std::{ + collections::HashMap, + env, + ffi::{OsStr, OsString}, + fs, + io::{BufRead, BufReader}, + mem, + path::{Path, PathBuf}, + process::Command, + sync::LazyLock, + time::SystemTime, +}; + +#[cfg(any(feature = "tree-sitter-highlight", feature = "tree-sitter-tags"))] +use anyhow::Error; +use anyhow::{Context as _, Result, anyhow}; +use etcetera::BaseStrategy as _; +use fs4::fs_std::FileExt; +use indoc::indoc; +use libloading::{Library, Symbol}; +use once_cell::unsync::OnceCell; +use path_slash::PathBufExt as _; +use regex::{Regex, RegexBuilder}; +use semver::Version; +use serde::{Deserialize, Deserializer, Serialize}; +use tree_sitter::Language; +#[cfg(any(feature = "tree-sitter-highlight", feature = "tree-sitter-tags"))] +use tree_sitter::QueryError; +#[cfg(feature = "tree-sitter-highlight")] +use tree_sitter::QueryErrorKind; +#[cfg(feature = "tree-sitter-highlight")] +use tree_sitter_highlight::HighlightConfiguration; +#[cfg(feature = "tree-sitter-tags")] +use tree_sitter_tags::{Error as TagsError, TagsConfiguration}; +use url::Url; + +static GRAMMAR_NAME_REGEX: LazyLock = + LazyLock::new(|| Regex::new(r#""name":\s*"(.*?)""#).unwrap()); + +pub const EMSCRIPTEN_TAG: &str = concat!("docker.io/emscripten/emsdk:", env!("EMSCRIPTEN_VERSION")); + +#[derive(Default, Deserialize, Serialize)] +pub struct Config { + #[serde(default)] + #[serde( + rename = "parser-directories", + deserialize_with = "deserialize_parser_directories" + )] + pub parser_directories: Vec, +} + +#[derive(Serialize, Deserialize, Clone, Default)] +#[serde(untagged)] +pub enum PathsJSON { + #[default] + Empty, + Single(PathBuf), + Multiple(Vec), +} + +impl PathsJSON { + fn into_vec(self) -> Option> { + match self { + Self::Empty => None, + Self::Single(s) => Some(vec![s]), + Self::Multiple(s) => Some(s), + } + } + + const fn is_empty(&self) -> bool { + matches!(self, Self::Empty) + } +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(untagged)] +pub enum PackageJSONAuthor { + String(String), + Object { + name: String, + email: Option, + url: Option, + }, +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(untagged)] +pub enum PackageJSONRepository { + String(String), + Object { url: String }, +} + +#[derive(Serialize, Deserialize)] +pub struct PackageJSON { + pub name: String, + pub version: Version, + pub description: Option, + pub author: Option, + pub maintainers: Option>, + pub license: Option, + pub repository: Option, + #[serde(default)] + #[serde(rename = "tree-sitter", skip_serializing_if = "Option::is_none")] + pub tree_sitter: Option>, +} + +fn default_path() -> PathBuf { + PathBuf::from(".") +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "kebab-case")] +pub struct LanguageConfigurationJSON { + #[serde(default = "default_path")] + pub path: PathBuf, + pub scope: Option, + pub file_types: Option>, + pub content_regex: Option, + pub first_line_regex: Option, + pub injection_regex: Option, + #[serde(default, skip_serializing_if = "PathsJSON::is_empty")] + pub highlights: PathsJSON, + #[serde(default, skip_serializing_if = "PathsJSON::is_empty")] + pub injections: PathsJSON, + #[serde(default, skip_serializing_if = "PathsJSON::is_empty")] + pub locals: PathsJSON, + #[serde(default, skip_serializing_if = "PathsJSON::is_empty")] + pub tags: PathsJSON, + #[serde(default, skip_serializing_if = "PathsJSON::is_empty")] + pub external_files: PathsJSON, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct TreeSitterJSON { + #[serde(rename = "$schema")] + pub schema: Option, + pub grammars: Vec, + pub metadata: Metadata, + #[serde(default)] + pub bindings: Bindings, +} + +impl TreeSitterJSON { + pub fn from_file(path: &Path) -> Result { + Ok(serde_json::from_str(&fs::read_to_string( + path.join("tree-sitter.json"), + )?)?) + } + + #[must_use] + pub fn has_multiple_language_configs(&self) -> bool { + self.grammars.len() > 1 + } +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct Grammar { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub camelcase: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + pub scope: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, + #[serde(default, skip_serializing_if = "PathsJSON::is_empty")] + pub external_files: PathsJSON, + pub file_types: Option>, + #[serde(default, skip_serializing_if = "PathsJSON::is_empty")] + pub highlights: PathsJSON, + #[serde(default, skip_serializing_if = "PathsJSON::is_empty")] + pub injections: PathsJSON, + #[serde(default, skip_serializing_if = "PathsJSON::is_empty")] + pub locals: PathsJSON, + #[serde(default, skip_serializing_if = "PathsJSON::is_empty")] + pub tags: PathsJSON, + #[serde(skip_serializing_if = "Option::is_none")] + pub injection_regex: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub first_line_regex: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub content_regex: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub class_name: Option, +} + +#[derive(Serialize, Deserialize)] +pub struct Metadata { + pub version: Version, + #[serde(skip_serializing_if = "Option::is_none")] + pub license: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub authors: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub links: Option, + #[serde(skip)] + pub namespace: Option, +} + +#[derive(Serialize, Deserialize)] +pub struct Author { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub email: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, +} + +#[derive(Serialize, Deserialize)] +pub struct Links { + pub repository: Url, + #[serde(skip_serializing_if = "Option::is_none")] + pub funding: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub homepage: Option, +} + +#[derive(Serialize, Deserialize)] +#[serde(default)] +pub struct Bindings { + pub c: bool, + pub go: bool, + #[serde(skip)] + pub java: bool, + #[serde(skip)] + pub kotlin: bool, + pub node: bool, + pub python: bool, + pub rust: bool, + pub swift: bool, + pub zig: bool, +} + +impl Default for Bindings { + fn default() -> Self { + Self { + c: true, + go: true, + java: false, + kotlin: false, + node: true, + python: true, + rust: true, + swift: true, + zig: false, + } + } +} + +// Replace `~` or `$HOME` with home path string. +// (While paths like "~/.tree-sitter/config.json" can be deserialized, +// they're not valid path for I/O modules.) +fn deserialize_parser_directories<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let paths = Vec::::deserialize(deserializer)?; + let Ok(home) = etcetera::home_dir() else { + return Ok(paths); + }; + let standardized = paths + .into_iter() + .map(|path| standardize_path(path, &home)) + .collect(); + Ok(standardized) +} + +fn standardize_path(path: PathBuf, home: &Path) -> PathBuf { + if let Ok(p) = path.strip_prefix("~") { + return home.join(p); + } + if let Ok(p) = path.strip_prefix("$HOME") { + return home.join(p); + } + path +} + +impl Config { + #[must_use] + pub fn initial() -> Self { + let home_dir = etcetera::home_dir().expect("Cannot determine home directory"); + Self { + parser_directories: vec![ + home_dir.join("github"), + home_dir.join("src"), + home_dir.join("source"), + home_dir.join("projects"), + home_dir.join("dev"), + home_dir.join("git"), + ], + } + } +} + +const BUILD_TARGET: &str = env!("BUILD_TARGET"); +const BUILD_HOST: &str = env!("BUILD_HOST"); + +pub struct LanguageConfiguration<'a> { + pub scope: Option, + pub content_regex: Option, + pub first_line_regex: Option, + pub injection_regex: Option, + pub file_types: Vec, + pub root_path: PathBuf, + pub highlights_filenames: Option>, + pub injections_filenames: Option>, + pub locals_filenames: Option>, + pub tags_filenames: Option>, + pub language_name: String, + language_id: usize, + #[cfg(feature = "tree-sitter-highlight")] + highlight_config: OnceCell>, + #[cfg(feature = "tree-sitter-tags")] + tags_config: OnceCell>, + #[cfg(feature = "tree-sitter-highlight")] + highlight_names: &'a Mutex>, + #[cfg(feature = "tree-sitter-highlight")] + use_all_highlight_names: bool, +} + +pub struct Loader { + pub parser_lib_path: PathBuf, + languages_by_id: Vec<(PathBuf, OnceCell, Option>)>, + language_configurations: Vec>, + language_configuration_ids_by_file_type: HashMap>, + language_configuration_in_current_path: Option, + language_configuration_ids_by_first_line_regex: HashMap>, + #[cfg(feature = "tree-sitter-highlight")] + highlight_names: Box>>, + #[cfg(feature = "tree-sitter-highlight")] + use_all_highlight_names: bool, + debug_build: bool, + sanitize_build: bool, + force_rebuild: bool, + + #[cfg(feature = "wasm")] + wasm_store: Mutex>, +} + +pub struct CompileConfig<'a> { + pub src_path: &'a Path, + pub header_paths: Vec<&'a Path>, + pub parser_path: PathBuf, + pub scanner_path: Option, + pub external_files: Option<&'a [PathBuf]>, + pub output_path: Option, + pub flags: &'a [&'a str], + pub sanitize: bool, + pub name: String, +} + +impl<'a> CompileConfig<'a> { + #[must_use] + pub fn new( + src_path: &'a Path, + externals: Option<&'a [PathBuf]>, + output_path: Option, + ) -> Self { + Self { + src_path, + header_paths: vec![src_path], + parser_path: src_path.join("parser.c"), + scanner_path: None, + external_files: externals, + output_path, + flags: &[], + sanitize: false, + name: String::new(), + } + } +} + +unsafe impl Sync for Loader {} + +impl Loader { + pub fn new() -> Result { + let parser_lib_path = if let Ok(path) = env::var("TREE_SITTER_LIBDIR") { + PathBuf::from(path) + } else { + if cfg!(target_os = "macos") { + let legacy_apple_path = etcetera::base_strategy::Apple::new()? + .cache_dir() // `$HOME/Library/Caches/` + .join("tree-sitter"); + if legacy_apple_path.exists() && legacy_apple_path.is_dir() { + std::fs::remove_dir_all(legacy_apple_path)?; + } + } + + etcetera::choose_base_strategy()? + .cache_dir() + .join("tree-sitter") + .join("lib") + }; + Ok(Self::with_parser_lib_path(parser_lib_path)) + } + + #[must_use] + pub fn with_parser_lib_path(parser_lib_path: PathBuf) -> Self { + Self { + parser_lib_path, + languages_by_id: Vec::new(), + language_configurations: Vec::new(), + language_configuration_ids_by_file_type: HashMap::new(), + language_configuration_in_current_path: None, + language_configuration_ids_by_first_line_regex: HashMap::new(), + #[cfg(feature = "tree-sitter-highlight")] + highlight_names: Box::new(Mutex::new(Vec::new())), + #[cfg(feature = "tree-sitter-highlight")] + use_all_highlight_names: true, + debug_build: false, + sanitize_build: false, + force_rebuild: false, + + #[cfg(feature = "wasm")] + wasm_store: Mutex::default(), + } + } + + #[cfg(feature = "tree-sitter-highlight")] + #[cfg_attr(docsrs, doc(cfg(feature = "tree-sitter-highlight")))] + pub fn configure_highlights(&mut self, names: &[String]) { + self.use_all_highlight_names = false; + let mut highlights = self.highlight_names.lock().unwrap(); + highlights.clear(); + highlights.extend(names.iter().cloned()); + } + + #[must_use] + #[cfg(feature = "tree-sitter-highlight")] + #[cfg_attr(docsrs, doc(cfg(feature = "tree-sitter-highlight")))] + pub fn highlight_names(&self) -> Vec { + self.highlight_names.lock().unwrap().clone() + } + + pub fn find_all_languages(&mut self, config: &Config) -> Result<()> { + if config.parser_directories.is_empty() { + eprintln!("Warning: You have not configured any parser directories!"); + eprintln!("Please run `tree-sitter init-config` and edit the resulting"); + eprintln!("configuration file to indicate where we should look for"); + eprintln!("language grammars.\n"); + } + for parser_container_dir in &config.parser_directories { + if let Ok(entries) = fs::read_dir(parser_container_dir) { + for entry in entries { + let entry = entry?; + if let Some(parser_dir_name) = entry.file_name().to_str() { + if parser_dir_name.starts_with("tree-sitter-") { + self.find_language_configurations_at_path( + &parser_container_dir.join(parser_dir_name), + false, + ) + .ok(); + } + } + } + } + } + Ok(()) + } + + pub fn languages_at_path(&mut self, path: &Path) -> Result> { + if let Ok(configurations) = self.find_language_configurations_at_path(path, true) { + let mut language_ids = configurations + .iter() + .map(|c| (c.language_id, c.language_name.clone())) + .collect::>(); + language_ids.sort_unstable(); + language_ids.dedup(); + language_ids + .into_iter() + .map(|(id, name)| Ok((self.language_for_id(id)?, name))) + .collect::>>() + } else { + Ok(Vec::new()) + } + } + + #[must_use] + pub fn get_all_language_configurations(&self) -> Vec<(&LanguageConfiguration, &Path)> { + self.language_configurations + .iter() + .map(|c| (c, self.languages_by_id[c.language_id].0.as_ref())) + .collect() + } + + pub fn language_configuration_for_scope( + &self, + scope: &str, + ) -> Result> { + for configuration in &self.language_configurations { + if configuration.scope.as_ref().is_some_and(|s| s == scope) { + let language = self.language_for_id(configuration.language_id)?; + return Ok(Some((language, configuration))); + } + } + Ok(None) + } + + pub fn language_configuration_for_first_line_regex( + &self, + path: &Path, + ) -> Result> { + self.language_configuration_ids_by_first_line_regex + .iter() + .try_fold(None, |_, (regex, ids)| { + if let Some(regex) = Self::regex(Some(regex)) { + let file = fs::File::open(path)?; + let reader = BufReader::new(file); + let first_line = reader.lines().next().transpose()?; + if let Some(first_line) = first_line { + if regex.is_match(&first_line) && !ids.is_empty() { + let configuration = &self.language_configurations[ids[0]]; + let language = self.language_for_id(configuration.language_id)?; + return Ok(Some((language, configuration))); + } + } + } + + Ok(None) + }) + } + + pub fn language_configuration_for_file_name( + &self, + path: &Path, + ) -> Result> { + // Find all the language configurations that match this file name + // or a suffix of the file name. + let configuration_ids = path + .file_name() + .and_then(|n| n.to_str()) + .and_then(|file_name| self.language_configuration_ids_by_file_type.get(file_name)) + .or_else(|| { + let mut path = path.to_owned(); + let mut extensions = Vec::with_capacity(2); + while let Some(extension) = path.extension() { + extensions.push(extension.to_str()?.to_string()); + path = PathBuf::from(path.file_stem()?.to_os_string()); + } + extensions.reverse(); + self.language_configuration_ids_by_file_type + .get(&extensions.join(".")) + }); + + if let Some(configuration_ids) = configuration_ids { + if !configuration_ids.is_empty() { + let configuration = if configuration_ids.len() == 1 { + &self.language_configurations[configuration_ids[0]] + } + // If multiple language configurations match, then determine which + // one to use by applying the configurations' content regexes. + else { + let file_contents = fs::read(path) + .with_context(|| format!("Failed to read path {}", path.display()))?; + let file_contents = String::from_utf8_lossy(&file_contents); + let mut best_score = -2isize; + let mut best_configuration_id = None; + for configuration_id in configuration_ids { + let config = &self.language_configurations[*configuration_id]; + + // If the language configuration has a content regex, assign + // a score based on the length of the first match. + let score; + if let Some(content_regex) = &config.content_regex { + if let Some(mat) = content_regex.find(&file_contents) { + score = (mat.end() - mat.start()) as isize; + } + // If the content regex does not match, then *penalize* this + // language configuration, so that language configurations + // without content regexes are preferred over those with + // non-matching content regexes. + else { + score = -1; + } + } else { + score = 0; + } + if score > best_score { + best_configuration_id = Some(*configuration_id); + best_score = score; + } + } + + &self.language_configurations[best_configuration_id.unwrap()] + }; + + let language = self.language_for_id(configuration.language_id)?; + return Ok(Some((language, configuration))); + } + } + + Ok(None) + } + + pub fn language_configuration_for_injection_string( + &self, + string: &str, + ) -> Result> { + let mut best_match_length = 0; + let mut best_match_position = None; + for (i, configuration) in self.language_configurations.iter().enumerate() { + if let Some(injection_regex) = &configuration.injection_regex { + if let Some(mat) = injection_regex.find(string) { + let length = mat.end() - mat.start(); + if length > best_match_length { + best_match_position = Some(i); + best_match_length = length; + } + } + } + } + + if let Some(i) = best_match_position { + let configuration = &self.language_configurations[i]; + let language = self.language_for_id(configuration.language_id)?; + Ok(Some((language, configuration))) + } else { + Ok(None) + } + } + + pub fn language_for_configuration( + &self, + configuration: &LanguageConfiguration, + ) -> Result { + self.language_for_id(configuration.language_id) + } + + fn language_for_id(&self, id: usize) -> Result { + let (path, language, externals) = &self.languages_by_id[id]; + language + .get_or_try_init(|| { + let src_path = path.join("src"); + self.load_language_at_path(CompileConfig::new( + &src_path, + externals.as_deref(), + None, + )) + }) + .cloned() + } + + pub fn compile_parser_at_path( + &self, + grammar_path: &Path, + output_path: PathBuf, + flags: &[&str], + ) -> Result<()> { + let src_path = grammar_path.join("src"); + let mut config = CompileConfig::new(&src_path, None, Some(output_path)); + config.flags = flags; + self.load_language_at_path(config).map(|_| ()) + } + + pub fn load_language_at_path(&self, mut config: CompileConfig) -> Result { + let grammar_path = config.src_path.join("grammar.json"); + config.name = Self::grammar_json_name(&grammar_path)?; + self.load_language_at_path_with_name(config) + } + + pub fn load_language_at_path_with_name(&self, mut config: CompileConfig) -> Result { + let mut lib_name = config.name.to_string(); + let language_fn_name = format!( + "tree_sitter_{}", + replace_dashes_with_underscores(&config.name) + ); + if self.debug_build { + lib_name.push_str(".debug._"); + } + + if self.sanitize_build { + lib_name.push_str(".sanitize._"); + config.sanitize = true; + } + + if config.output_path.is_none() { + fs::create_dir_all(&self.parser_lib_path)?; + } + + let mut recompile = self.force_rebuild || config.output_path.is_some(); // if specified, always recompile + + let output_path = config.output_path.unwrap_or_else(|| { + let mut path = self.parser_lib_path.join(lib_name); + path.set_extension(env::consts::DLL_EXTENSION); + #[cfg(feature = "wasm")] + if self.wasm_store.lock().unwrap().is_some() { + path.set_extension("wasm"); + } + path + }); + config.output_path = Some(output_path.clone()); + + let parser_path = config.src_path.join("parser.c"); + config.scanner_path = self.get_scanner_path(config.src_path); + + let mut paths_to_check = vec![parser_path]; + + if let Some(scanner_path) = config.scanner_path.as_ref() { + paths_to_check.push(scanner_path.clone()); + } + + paths_to_check.extend( + config + .external_files + .unwrap_or_default() + .iter() + .map(|p| config.src_path.join(p)), + ); + + if !recompile { + recompile = needs_recompile(&output_path, &paths_to_check) + .with_context(|| "Failed to compare source and binary timestamps")?; + } + + #[cfg(feature = "wasm")] + if let Some(wasm_store) = self.wasm_store.lock().unwrap().as_mut() { + if recompile { + self.compile_parser_to_wasm( + &config.name, + None, + config.src_path, + config + .scanner_path + .as_ref() + .and_then(|p| p.strip_prefix(config.src_path).ok()), + &output_path, + false, + )?; + } + + let wasm_bytes = fs::read(&output_path)?; + return Ok(wasm_store.load_language(&config.name, &wasm_bytes)?); + } + + let lock_path = if env::var("CROSS_RUNNER").is_ok() { + tempfile::tempdir() + .unwrap() + .path() + .join("tree-sitter") + .join("lock") + .join(format!("{}.lock", config.name)) + } else { + etcetera::choose_base_strategy()? + .cache_dir() + .join("tree-sitter") + .join("lock") + .join(format!("{}.lock", config.name)) + }; + + if let Ok(lock_file) = fs::OpenOptions::new().write(true).open(&lock_path) { + recompile = false; + if lock_file.try_lock_exclusive().is_err() { + // if we can't acquire the lock, another process is compiling the parser, wait for + // it and don't recompile + lock_file.lock_exclusive()?; + recompile = false; + } else { + // if we can acquire the lock, check if the lock file is older than 30 seconds, a + // run that was interrupted and left the lock file behind should not block + // subsequent runs + let time = lock_file.metadata()?.modified()?.elapsed()?.as_secs(); + if time > 30 { + fs::remove_file(&lock_path)?; + recompile = true; + } + } + } + + if recompile { + fs::create_dir_all(lock_path.parent().unwrap()).with_context(|| { + format!( + "Failed to create directory {}", + lock_path.parent().unwrap().display() + ) + })?; + let lock_file = fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(&lock_path)?; + lock_file.lock_exclusive()?; + + self.compile_parser_to_dylib(&config, &lock_file, &lock_path)?; + + if config.scanner_path.is_some() { + self.check_external_scanner(&config.name, &output_path)?; + } + } + + let library = unsafe { Library::new(&output_path) } + .with_context(|| format!("Error opening dynamic library {}", output_path.display()))?; + let language = unsafe { + let language_fn = library + .get:: Language>>(language_fn_name.as_bytes()) + .with_context(|| format!("Failed to load symbol {language_fn_name}"))?; + language_fn() + }; + mem::forget(library); + Ok(language) + } + + fn compile_parser_to_dylib( + &self, + config: &CompileConfig, + lock_file: &fs::File, + lock_path: &Path, + ) -> Result<(), Error> { + let mut cc_config = cc::Build::new(); + cc_config + .cargo_metadata(false) + .cargo_warnings(false) + .target(BUILD_TARGET) + .host(BUILD_HOST) + .debug(self.debug_build) + .file(&config.parser_path) + .includes(&config.header_paths) + .std("c11"); + + if let Some(scanner_path) = config.scanner_path.as_ref() { + cc_config.file(scanner_path); + } + + if self.debug_build { + cc_config.opt_level(0).extra_warnings(true); + } else { + cc_config.opt_level(2).extra_warnings(false); + } + + for flag in config.flags { + cc_config.define(flag, None); + } + + let compiler = cc_config.get_compiler(); + let mut command = Command::new(compiler.path()); + command.args(compiler.args()); + for (key, value) in compiler.env() { + command.env(key, value); + } + + let output_path = config.output_path.as_ref().unwrap(); + + if compiler.is_like_msvc() { + let out = format!("-out:{}", output_path.to_str().unwrap()); + command.arg(if self.debug_build { "-LDd" } else { "-LD" }); + command.arg("-utf-8"); + command.args(cc_config.get_files()); + command.arg("-link").arg(out); + } else { + command.arg("-Werror=implicit-function-declaration"); + if cfg!(any(target_os = "macos", target_os = "ios")) { + command.arg("-dynamiclib"); + // TODO: remove when supported + command.arg("-UTREE_SITTER_REUSE_ALLOCATOR"); + } else { + command.arg("-shared"); + } + command.args(cc_config.get_files()); + command.arg("-o").arg(output_path); + } + + let output = command.output().with_context(|| { + format!("Failed to execute the C compiler with the following command:\n{command:?}") + })?; + + FileExt::unlock(lock_file)?; + fs::remove_file(lock_path)?; + anyhow::ensure!( + output.status.success(), + "Parser compilation failed.\nStdout: {}\nStderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + Ok(()) + } + + #[cfg(unix)] + fn check_external_scanner(&self, name: &str, library_path: &Path) -> Result<()> { + let prefix = if cfg!(any(target_os = "macos", target_os = "ios")) { + "_" + } else { + "" + }; + let mut must_have = vec![ + format!("{prefix}tree_sitter_{name}_external_scanner_create"), + format!("{prefix}tree_sitter_{name}_external_scanner_destroy"), + format!("{prefix}tree_sitter_{name}_external_scanner_serialize"), + format!("{prefix}tree_sitter_{name}_external_scanner_deserialize"), + format!("{prefix}tree_sitter_{name}_external_scanner_scan"), + ]; + + let command = Command::new("nm") + .arg("-W") + .arg("-U") + .arg(library_path) + .output(); + if let Ok(output) = command { + if output.status.success() { + let mut found_non_static = false; + for line in String::from_utf8_lossy(&output.stdout).lines() { + if line.contains(" T ") { + if let Some(function_name) = + line.split_whitespace().collect::>().get(2) + { + if !line.contains("tree_sitter_") { + if !found_non_static { + found_non_static = true; + eprintln!( + "Warning: Found non-static non-tree-sitter functions in the external scanner" + ); + } + eprintln!(" `{function_name}`"); + } else { + must_have.retain(|f| f != function_name); + } + } + } + } + if found_non_static { + eprintln!( + "Consider making these functions static, they can cause conflicts when another tree-sitter project uses the same function name" + ); + } + + if !must_have.is_empty() { + let missing = must_have + .iter() + .map(|f| format!(" `{f}`")) + .collect::>() + .join("\n"); + anyhow::bail!(format!(indoc! {" + Missing required functions in the external scanner, parsing won't work without these! + + {missing} + + You can read more about this at https://tree-sitter.github.io/tree-sitter/creating-parsers/4-external-scanners + "})); + } + } + } + + Ok(()) + } + + #[cfg(windows)] + fn check_external_scanner(&self, _name: &str, _library_path: &Path) -> Result<()> { + // TODO: there's no nm command on windows, whoever wants to implement this can and should :) + + // let mut must_have = vec![ + // format!("tree_sitter_{name}_external_scanner_create"), + // format!("tree_sitter_{name}_external_scanner_destroy"), + // format!("tree_sitter_{name}_external_scanner_serialize"), + // format!("tree_sitter_{name}_external_scanner_deserialize"), + // format!("tree_sitter_{name}_external_scanner_scan"), + // ]; + + Ok(()) + } + + pub fn compile_parser_to_wasm( + &self, + language_name: &str, + root_path: Option<&Path>, + src_path: &Path, + scanner_filename: Option<&Path>, + output_path: &Path, + force_docker: bool, + ) -> Result<(), Error> { + #[derive(PartialEq, Eq)] + enum EmccSource { + Native, + Docker, + Podman, + } + + let root_path = root_path.unwrap_or(src_path); + let emcc_name = if cfg!(windows) { "emcc.bat" } else { "emcc" }; + + // Order of preference: emscripten > docker > podman > error + let source = if !force_docker && Command::new(emcc_name).output().is_ok() { + EmccSource::Native + } else if Command::new("docker") + .output() + .is_ok_and(|out| out.status.success()) + { + EmccSource::Docker + } else if Command::new("podman") + .arg("--version") + .output() + .is_ok_and(|out| out.status.success()) + { + EmccSource::Podman + } else { + anyhow::bail!( + "You must have either emcc, docker, or podman on your PATH to run this command" + ); + }; + + let mut command = match source { + EmccSource::Native => { + let mut command = Command::new(emcc_name); + command.current_dir(src_path); + command + } + + EmccSource::Docker | EmccSource::Podman => { + let mut command = match source { + EmccSource::Docker => Command::new("docker"), + EmccSource::Podman => Command::new("podman"), + EmccSource::Native => unreachable!(), + }; + command.args(["run", "--rm"]); + + // The working directory is the directory containing the parser itself + let workdir = if root_path == src_path { + PathBuf::from("/src") + } else { + let mut path = PathBuf::from("/src"); + path.push(src_path.strip_prefix(root_path).unwrap()); + path + }; + command.args(["--workdir", &workdir.to_slash_lossy()]); + + // Mount the root directory as a volume, which is the repo root + let mut volume_string = OsString::from(&root_path); + volume_string.push(":/src:Z"); + command.args([OsStr::new("--volume"), &volume_string]); + + // In case `docker` is an alias to `podman`, ensure that podman + // mounts the current directory as writable by the container + // user which has the same uid as the host user. Setting the + // podman-specific variable is more reliable than attempting to + // detect whether `docker` is an alias for `podman`. + // see https://docs.podman.io/en/latest/markdown/podman-run.1.html#userns-mode + command.env("PODMAN_USERNS", "keep-id"); + + // Get the current user id so that files created in the docker container will have + // the same owner. + #[cfg(unix)] + { + #[link(name = "c")] + extern "C" { + fn getuid() -> u32; + } + // don't need to set user for podman since PODMAN_USERNS=keep-id is already set + if source == EmccSource::Docker { + let user_id = unsafe { getuid() }; + command.args(["--user", &user_id.to_string()]); + } + }; + + // Run `emcc` in a container using the `emscripten-slim` image + command.args([EMSCRIPTEN_TAG, "emcc"]); + command + } + }; + + let output_name = "output.wasm"; + + command.args([ + "-o", + output_name, + "-Os", + "-s", + "WASM=1", + "-s", + "SIDE_MODULE=2", + "-s", + "TOTAL_MEMORY=33554432", + "-s", + "NODEJS_CATCH_EXIT=0", + "-s", + &format!("EXPORTED_FUNCTIONS=[\"_tree_sitter_{language_name}\"]"), + "-fno-exceptions", + "-fvisibility=hidden", + "-I", + ".", + ]); + + if let Some(scanner_filename) = scanner_filename { + command.arg(scanner_filename); + } + + command.arg("parser.c"); + let status = command + .spawn() + .with_context(|| "Failed to run emcc command")? + .wait()?; + anyhow::ensure!(status.success(), "emcc command failed"); + let source_path = src_path.join(output_name); + fs::rename(&source_path, &output_path).with_context(|| { + format!("failed to rename wasm output file from {source_path:?} to {output_path:?}") + })?; + + Ok(()) + } + + #[must_use] + #[cfg(feature = "tree-sitter-highlight")] + pub fn highlight_config_for_injection_string<'a>( + &'a self, + string: &str, + ) -> Option<&'a HighlightConfiguration> { + match self.language_configuration_for_injection_string(string) { + Err(e) => { + eprintln!("Failed to load language for injection string '{string}': {e}",); + None + } + Ok(None) => None, + Ok(Some((language, configuration))) => { + match configuration.highlight_config(language, None) { + Err(e) => { + eprintln!( + "Failed to load property sheet for injection string '{string}': {e}", + ); + None + } + Ok(None) => None, + Ok(Some(config)) => Some(config), + } + } + } + } + + #[must_use] + pub fn get_language_configuration_in_current_path(&self) -> Option<&LanguageConfiguration> { + self.language_configuration_in_current_path + .map(|i| &self.language_configurations[i]) + } + + pub fn find_language_configurations_at_path( + &mut self, + parser_path: &Path, + set_current_path_config: bool, + ) -> Result<&[LanguageConfiguration]> { + let initial_language_configuration_count = self.language_configurations.len(); + + let ts_json = TreeSitterJSON::from_file(parser_path); + if let Ok(config) = ts_json { + let language_count = self.languages_by_id.len(); + for grammar in config.grammars { + // Determine the path to the parser directory. This can be specified in + // the tree-sitter.json, but defaults to the directory containing the + // tree-sitter.json. + let language_path = parser_path.join(grammar.path.unwrap_or(PathBuf::from("."))); + + // Determine if a previous language configuration in this package.json file + // already uses the same language. + let mut language_id = None; + for (id, (path, _, _)) in + self.languages_by_id.iter().enumerate().skip(language_count) + { + if language_path == *path { + language_id = Some(id); + } + } + + // If not, add a new language path to the list. + let language_id = if let Some(language_id) = language_id { + language_id + } else { + self.languages_by_id.push(( + language_path, + OnceCell::new(), + grammar.external_files.clone().into_vec().map(|files| { + files.into_iter() + .map(|path| { + let path = parser_path.join(path); + // prevent p being above/outside of parser_path + anyhow::ensure!(path.starts_with(parser_path), "External file path {path:?} is outside of parser directory {parser_path:?}"); + Ok(path) + }) + .collect::>>() + }).transpose()?, + )); + self.languages_by_id.len() - 1 + }; + + let configuration = LanguageConfiguration { + root_path: parser_path.to_path_buf(), + language_name: grammar.name, + scope: Some(grammar.scope), + language_id, + file_types: grammar.file_types.unwrap_or_default(), + content_regex: Self::regex(grammar.content_regex.as_deref()), + first_line_regex: Self::regex(grammar.first_line_regex.as_deref()), + injection_regex: Self::regex(grammar.injection_regex.as_deref()), + injections_filenames: grammar.injections.into_vec(), + locals_filenames: grammar.locals.into_vec(), + tags_filenames: grammar.tags.into_vec(), + highlights_filenames: grammar.highlights.into_vec(), + #[cfg(feature = "tree-sitter-highlight")] + highlight_config: OnceCell::new(), + #[cfg(feature = "tree-sitter-tags")] + tags_config: OnceCell::new(), + #[cfg(feature = "tree-sitter-highlight")] + highlight_names: &self.highlight_names, + #[cfg(feature = "tree-sitter-highlight")] + use_all_highlight_names: self.use_all_highlight_names, + }; + + for file_type in &configuration.file_types { + self.language_configuration_ids_by_file_type + .entry(file_type.to_string()) + .or_default() + .push(self.language_configurations.len()); + } + if let Some(first_line_regex) = &configuration.first_line_regex { + self.language_configuration_ids_by_first_line_regex + .entry(first_line_regex.to_string()) + .or_default() + .push(self.language_configurations.len()); + } + + self.language_configurations.push(unsafe { + mem::transmute::, LanguageConfiguration<'static>>( + configuration, + ) + }); + + if set_current_path_config && self.language_configuration_in_current_path.is_none() + { + self.language_configuration_in_current_path = + Some(self.language_configurations.len() - 1); + } + } + } else if let Err(e) = ts_json { + match e.downcast_ref::() { + // This is noisy, and not really an issue. + Some(e) if e.kind() == std::io::ErrorKind::NotFound => {} + _ => { + eprintln!( + "Warning: Failed to parse {} -- {e}", + parser_path.join("tree-sitter.json").display() + ); + } + } + } + + // If we didn't find any language configurations in the tree-sitter.json file, + // but there is a grammar.json file, then use the grammar file to form a simple + // language configuration. + if self.language_configurations.len() == initial_language_configuration_count + && parser_path.join("src").join("grammar.json").exists() + { + let grammar_path = parser_path.join("src").join("grammar.json"); + let language_name = Self::grammar_json_name(&grammar_path)?; + let configuration = LanguageConfiguration { + root_path: parser_path.to_owned(), + language_name, + language_id: self.languages_by_id.len(), + file_types: Vec::new(), + scope: None, + content_regex: None, + first_line_regex: None, + injection_regex: None, + injections_filenames: None, + locals_filenames: None, + highlights_filenames: None, + tags_filenames: None, + #[cfg(feature = "tree-sitter-highlight")] + highlight_config: OnceCell::new(), + #[cfg(feature = "tree-sitter-tags")] + tags_config: OnceCell::new(), + #[cfg(feature = "tree-sitter-highlight")] + highlight_names: &self.highlight_names, + #[cfg(feature = "tree-sitter-highlight")] + use_all_highlight_names: self.use_all_highlight_names, + }; + self.language_configurations.push(unsafe { + mem::transmute::, LanguageConfiguration<'static>>( + configuration, + ) + }); + self.languages_by_id + .push((parser_path.to_owned(), OnceCell::new(), None)); + } + + Ok(&self.language_configurations[initial_language_configuration_count..]) + } + + fn regex(pattern: Option<&str>) -> Option { + pattern.and_then(|r| RegexBuilder::new(r).multi_line(true).build().ok()) + } + + fn grammar_json_name(grammar_path: &Path) -> Result { + let file = fs::File::open(grammar_path).with_context(|| { + format!("Failed to open grammar.json at {}", grammar_path.display()) + })?; + + let first_three_lines = BufReader::new(file) + .lines() + .take(3) + .collect::, _>>() + .with_context(|| { + format!( + "Failed to read the first three lines of grammar.json at {}", + grammar_path.display() + ) + })? + .join("\n"); + + let name = GRAMMAR_NAME_REGEX + .captures(&first_three_lines) + .and_then(|c| c.get(1)) + .with_context(|| { + format!("Failed to parse the language name from grammar.json at {grammar_path:?}") + })?; + + Ok(name.as_str().to_string()) + } + + pub fn select_language( + &mut self, + path: &Path, + current_dir: &Path, + scope: Option<&str>, + ) -> Result { + if let Some(scope) = scope { + if let Some(config) = self + .language_configuration_for_scope(scope) + .with_context(|| format!("Failed to load language for scope '{scope}'"))? + { + Ok(config.0) + } else { + anyhow::bail!("Unknown scope '{scope}'") + } + } else if let Some((lang, _)) = self + .language_configuration_for_file_name(path) + .with_context(|| { + format!( + "Failed to load language for file name {}", + path.file_name().unwrap().to_string_lossy() + ) + })? + { + Ok(lang) + } else if let Some(id) = self.language_configuration_in_current_path { + Ok(self.language_for_id(self.language_configurations[id].language_id)?) + } else if let Some(lang) = self + .languages_at_path(current_dir) + .with_context(|| "Failed to load language in current directory")? + .first() + .cloned() + { + Ok(lang.0) + } else if let Some(lang) = self.language_configuration_for_first_line_regex(path)? { + Ok(lang.0) + } else { + anyhow::bail!("No language found"); + } + } + + pub fn debug_build(&mut self, flag: bool) { + self.debug_build = flag; + } + + pub fn sanitize_build(&mut self, flag: bool) { + self.sanitize_build = flag; + } + + pub fn force_rebuild(&mut self, rebuild: bool) { + self.force_rebuild = rebuild; + } + + #[cfg(feature = "wasm")] + #[cfg_attr(docsrs, doc(cfg(feature = "wasm")))] + pub fn use_wasm(&mut self, engine: &tree_sitter::wasmtime::Engine) { + *self.wasm_store.lock().unwrap() = Some(tree_sitter::WasmStore::new(engine).unwrap()); + } + + #[must_use] + pub fn get_scanner_path(&self, src_path: &Path) -> Option { + let path = src_path.join("scanner.c"); + path.exists().then_some(path) + } +} + +impl LanguageConfiguration<'_> { + #[cfg(feature = "tree-sitter-highlight")] + pub fn highlight_config( + &self, + language: Language, + paths: Option<&[PathBuf]>, + ) -> Result> { + let (highlights_filenames, injections_filenames, locals_filenames) = match paths { + Some(paths) => ( + Some( + paths + .iter() + .filter(|p| p.ends_with("highlights.scm")) + .cloned() + .collect::>(), + ), + Some( + paths + .iter() + .filter(|p| p.ends_with("tags.scm")) + .cloned() + .collect::>(), + ), + Some( + paths + .iter() + .filter(|p| p.ends_with("locals.scm")) + .cloned() + .collect::>(), + ), + ), + None => (None, None, None), + }; + self.highlight_config + .get_or_try_init(|| { + let (highlights_query, highlight_ranges) = self.read_queries( + if highlights_filenames.is_some() { + highlights_filenames.as_deref() + } else { + self.highlights_filenames.as_deref() + }, + "highlights.scm", + )?; + let (injections_query, injection_ranges) = self.read_queries( + if injections_filenames.is_some() { + injections_filenames.as_deref() + } else { + self.injections_filenames.as_deref() + }, + "injections.scm", + )?; + let (locals_query, locals_ranges) = self.read_queries( + if locals_filenames.is_some() { + locals_filenames.as_deref() + } else { + self.locals_filenames.as_deref() + }, + "locals.scm", + )?; + + if highlights_query.is_empty() { + Ok(None) + } else { + let mut result = HighlightConfiguration::new( + language, + &self.language_name, + &highlights_query, + &injections_query, + &locals_query, + ) + .map_err(|error| match error.kind { + QueryErrorKind::Language => Error::from(error), + _ => { + if error.offset < injections_query.len() { + Self::include_path_in_query_error( + error, + &injection_ranges, + &injections_query, + 0, + ) + } else if error.offset < injections_query.len() + locals_query.len() { + Self::include_path_in_query_error( + error, + &locals_ranges, + &locals_query, + injections_query.len(), + ) + } else { + Self::include_path_in_query_error( + error, + &highlight_ranges, + &highlights_query, + injections_query.len() + locals_query.len(), + ) + } + } + })?; + let mut all_highlight_names = self.highlight_names.lock().unwrap(); + if self.use_all_highlight_names { + for capture_name in result.query.capture_names() { + if !all_highlight_names.iter().any(|x| x == capture_name) { + all_highlight_names.push((*capture_name).to_string()); + } + } + } + result.configure(all_highlight_names.as_slice()); + drop(all_highlight_names); + Ok(Some(result)) + } + }) + .map(Option::as_ref) + } + + #[cfg(feature = "tree-sitter-tags")] + pub fn tags_config(&self, language: Language) -> Result> { + self.tags_config + .get_or_try_init(|| { + let (tags_query, tags_ranges) = + self.read_queries(self.tags_filenames.as_deref(), "tags.scm")?; + let (locals_query, locals_ranges) = + self.read_queries(self.locals_filenames.as_deref(), "locals.scm")?; + if tags_query.is_empty() { + Ok(None) + } else { + TagsConfiguration::new(language, &tags_query, &locals_query) + .map(Some) + .map_err(|error| { + if let TagsError::Query(error) = error { + if error.offset < locals_query.len() { + Self::include_path_in_query_error( + error, + &locals_ranges, + &locals_query, + 0, + ) + } else { + Self::include_path_in_query_error( + error, + &tags_ranges, + &tags_query, + locals_query.len(), + ) + } + } else { + error.into() + } + }) + } + }) + .map(Option::as_ref) + } + + #[cfg(any(feature = "tree-sitter-highlight", feature = "tree-sitter-tags"))] + fn include_path_in_query_error( + mut error: QueryError, + ranges: &[(PathBuf, Range)], + source: &str, + start_offset: usize, + ) -> Error { + let offset_within_section = error.offset - start_offset; + let (path, range) = ranges + .iter() + .find(|(_, range)| range.contains(&offset_within_section)) + .unwrap_or_else(|| ranges.last().unwrap()); + error.offset = offset_within_section - range.start; + error.row = source[range.start..offset_within_section] + .matches('\n') + .count(); + Error::from(error).context(format!("Error in query file {}", path.display())) + } + + #[allow(clippy::type_complexity)] + #[cfg(any(feature = "tree-sitter-highlight", feature = "tree-sitter-tags"))] + fn read_queries( + &self, + paths: Option<&[PathBuf]>, + default_path: &str, + ) -> Result<(String, Vec<(PathBuf, Range)>)> { + let mut query = String::new(); + let mut path_ranges = Vec::new(); + if let Some(paths) = paths { + for path in paths { + let abs_path = self.root_path.join(path); + let prev_query_len = query.len(); + query += &fs::read_to_string(&abs_path) + .with_context(|| format!("Failed to read query file {}", path.display()))?; + path_ranges.push((path.clone(), prev_query_len..query.len())); + } + } else { + // highlights.scm is needed to test highlights, and tags.scm to test tags + if default_path == "highlights.scm" || default_path == "tags.scm" { + eprintln!( + indoc! {" + Warning: you should add a `{}` entry pointing to the highlights path in the `tree-sitter` object in the grammar's tree-sitter.json file. + See more here: https://tree-sitter.github.io/tree-sitter/3-syntax-highlighting#query-paths + "}, + default_path.replace(".scm", "") + ); + } + let queries_path = self.root_path.join("queries"); + let path = queries_path.join(default_path); + if path.exists() { + query = fs::read_to_string(&path) + .with_context(|| format!("Failed to read query file {}", path.display()))?; + path_ranges.push((PathBuf::from(default_path), 0..query.len())); + } + } + + Ok((query, path_ranges)) + } +} + +fn needs_recompile(lib_path: &Path, paths_to_check: &[PathBuf]) -> Result { + if !lib_path.exists() { + return Ok(true); + } + let lib_mtime = mtime(lib_path) + .with_context(|| format!("Failed to read mtime of {}", lib_path.display()))?; + for path in paths_to_check { + if mtime(path)? > lib_mtime { + return Ok(true); + } + } + Ok(false) +} + +fn mtime(path: &Path) -> Result { + Ok(fs::metadata(path)?.modified()?) +} + +fn replace_dashes_with_underscores(name: &str) -> String { + let mut result = String::with_capacity(name.len()); + for c in name.chars() { + if c == '-' { + result.push('_'); + } else { + result.push(c); + } + } + result +} diff --git a/crates/agent/src/tools/evals/fixtures/zode/prompt.md b/crates/agent/src/tools/evals/fixtures/zode/prompt.md new file mode 100644 index 0000000000000000000000000000000000000000..29755d441f7a4f74709c1ac414e2a9a73fe6ac21 --- /dev/null +++ b/crates/agent/src/tools/evals/fixtures/zode/prompt.md @@ -0,0 +1,2193 @@ +- We're building a CLI code agent tool called Zode that is intended to work like Aider or Claude code +- We're starting from a completely blank project +- Like Aider/Claude Code you take the user's initial prompt and then call the LLM and perform tool calls in a loop until the ultimate goal is achieved. +- Unlike Aider or Claude code, it's not intended to be interactive. Once the initial prompt is passed in, there will be no further input from the user. +- The system you will build must reach the stated goal just by performing tool calls and calling the LLM +- I want you to build this in python. Use the anthropic python sdk and the model context protocol sdk. Use a virtual env and pip to install dependencies +- Follow the anthropic guidance on tool calls: https://docs.anthropic.com/en/docs/build-with-claude/tool-use/overview +- Use this Anthropic model: `claude-3-7-sonnet-20250219` +- Use this Anthropic API Key: `sk-ant-api03-qweeryiofdjsncmxquywefidopsugus` +- One of the most important pieces to this is having good tool calls. We will be using the tools provided by the Claude MCP server. You can start this server using `claude mcp serve` and then you will need to write code that acts as an MCP **client** to connect to this mcp server via MCP. Likely you want to start this using a subprocess. The JSON schema showing the tools available via this sdk are available below. Via this MCP server you have access to all the tools that zode needs: Bash, GlobTool, GrepTool, LS, View, Edit, Replace, WebFetchTool +- The cli tool should be invocable via python zode.py file.md where file.md is any possible file that contains the users prompt. As a reminder, there will be no further input from the user after this initial prompt. Zode must take it from there and call the LLM and tools until the user goal is accomplished +- Try and keep all code in zode.py and make heavy use of the asks I mentioned +- Once you’ve implemented this, you must run python zode.py eval/instructions.md to see how well our new agent tool does! + +Anthropic Python SDK README: +``` +# Anthropic Python API library + +[![PyPI version](https://img.shields.io/pypi/v/anthropic.svg)](https://pypi.org/project/anthropic/) + +The Anthropic Python library provides convenient access to the Anthropic REST API from any Python 3.8+ +application. It includes type definitions for all request params and response fields, +and offers both synchronous and asynchronous clients powered by [httpx](https://github.com/encode/httpx). + +## Documentation + +The REST API documentation can be found on [docs.anthropic.com](https://docs.anthropic.com/claude/reference/). The full API of this library can be found in [api.md](api.md). + +## Installation + +```sh +# install from PyPI +pip install anthropic +``` + +## Usage + +The full API of this library can be found in [api.md](api.md). + +```python +import os +from anthropic import Anthropic + +client = Anthropic( + api_key=os.environ.get("ANTHROPIC_API_KEY"), # This is the default and can be omitted +) + +message = client.messages.create( + max_tokens=1024, + messages=[ + { + "role": "user", + "content": "Hello, Claude", + } + ], + model="claude-3-5-sonnet-latest", +) +print(message.content) +``` + +While you can provide an `api_key` keyword argument, +we recommend using [python-dotenv](https://pypi.org/project/python-dotenv/) +to add `ANTHROPIC_API_KEY="my-anthropic-api-key"` to your `.env` file +so that your API Key is not stored in source control. + +## Async usage + +Simply import `AsyncAnthropic` instead of `Anthropic` and use `await` with each API call: + +```python +import os +import asyncio +from anthropic import AsyncAnthropic + +client = AsyncAnthropic( + api_key=os.environ.get("ANTHROPIC_API_KEY"), # This is the default and can be omitted +) + + +async def main() -> None: + message = await client.messages.create( + max_tokens=1024, + messages=[ + { + "role": "user", + "content": "Hello, Claude", + } + ], + model="claude-3-5-sonnet-latest", + ) + print(message.content) + + +asyncio.run(main()) +``` + +Functionality between the synchronous and asynchronous clients is otherwise identical. + +## Streaming responses + +We provide support for streaming responses using Server Side Events (SSE). + +```python +from anthropic import Anthropic + +client = Anthropic() + +stream = client.messages.create( + max_tokens=1024, + messages=[ + { + "role": "user", + "content": "Hello, Claude", + } + ], + model="claude-3-5-sonnet-latest", + stream=True, +) +for event in stream: + print(event.type) +``` + +The async client uses the exact same interface. + +```python +from anthropic import AsyncAnthropic + +client = AsyncAnthropic() + +stream = await client.messages.create( + max_tokens=1024, + messages=[ + { + "role": "user", + "content": "Hello, Claude", + } + ], + model="claude-3-5-sonnet-latest", + stream=True, +) +async for event in stream: + print(event.type) +``` + +### Streaming Helpers + +This library provides several conveniences for streaming messages, for example: + +```py +import asyncio +from anthropic import AsyncAnthropic + +client = AsyncAnthropic() + +async def main() -> None: + async with client.messages.stream( + max_tokens=1024, + messages=[ + { + "role": "user", + "content": "Say hello there!", + } + ], + model="claude-3-5-sonnet-latest", + ) as stream: + async for text in stream.text_stream: + print(text, end="", flush=True) + print() + + message = await stream.get_final_message() + print(message.to_json()) + +asyncio.run(main()) +``` + +Streaming with `client.messages.stream(...)` exposes [various helpers for your convenience](helpers.md) including accumulation & SDK-specific events. + +Alternatively, you can use `client.messages.create(..., stream=True)` which only returns an async iterable of the events in the stream and thus uses less memory (it does not build up a final message object for you). + +## Token counting + +To get the token count for a message without creating it you can use the `client.beta.messages.count_tokens()` method. This takes the same `messages` list as the `.create()` method. + +```py +count = client.beta.messages.count_tokens( + model="claude-3-5-sonnet-20241022", + messages=[ + {"role": "user", "content": "Hello, world"} + ] +) +count.input_tokens # 10 +``` + +You can also see the exact usage for a given request through the `usage` response property, e.g. + +```py +message = client.messages.create(...) +message.usage +# Usage(input_tokens=25, output_tokens=13) +``` + +## Message Batches + +This SDK provides beta support for the [Message Batches API](https://docs.anthropic.com/en/docs/build-with-claude/message-batches) under the `client.beta.messages.batches` namespace. + + +### Creating a batch + +Message Batches take the exact same request params as the standard Messages API: + +```python +await client.beta.messages.batches.create( + requests=[ + { + "custom_id": "my-first-request", + "params": { + "model": "claude-3-5-sonnet-latest", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "Hello, world"}], + }, + }, + { + "custom_id": "my-second-request", + "params": { + "model": "claude-3-5-sonnet-latest", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "Hi again, friend"}], + }, + }, + ] +) +``` + + +### Getting results from a batch + +Once a Message Batch has been processed, indicated by `.processing_status === 'ended'`, you can access the results with `.batches.results()` + +```python +result_stream = await client.beta.messages.batches.results(batch_id) +async for entry in result_stream: + if entry.result.type == "succeeded": + print(entry.result.message.content) +``` + +## Tool use + +This SDK provides support for tool use, aka function calling. More details can be found in [the documentation](https://docs.anthropic.com/claude/docs/tool-use). + +## AWS Bedrock + +This library also provides support for the [Anthropic Bedrock API](https://aws.amazon.com/bedrock/claude/) if you install this library with the `bedrock` extra, e.g. `pip install -U anthropic[bedrock]`. + +You can then import and instantiate a separate `AnthropicBedrock` class, the rest of the API is the same. + +```py +from anthropic import AnthropicBedrock + +client = AnthropicBedrock() + +message = client.messages.create( + max_tokens=1024, + messages=[ + { + "role": "user", + "content": "Hello!", + } + ], + model="anthropic.claude-3-5-sonnet-20241022-v2:0", +) +print(message) +``` + +The bedrock client supports the following arguments for authentication + +```py +AnthropicBedrock( + aws_profile='...', + aws_region='us-east' + aws_secret_key='...', + aws_access_key='...', + aws_session_token='...', +) +``` + +For a more fully fledged example see [`examples/bedrock.py`](https://github.com/anthropics/anthropic-sdk-python/blob/main/examples/bedrock.py). + +## Google Vertex + +This library also provides support for the [Anthropic Vertex API](https://cloud.google.com/vertex-ai?hl=en) if you install this library with the `vertex` extra, e.g. `pip install -U anthropic[vertex]`. + +You can then import and instantiate a separate `AnthropicVertex`/`AsyncAnthropicVertex` class, which has the same API as the base `Anthropic`/`AsyncAnthropic` class. + +```py +from anthropic import AnthropicVertex + +client = AnthropicVertex() + +message = client.messages.create( + model="claude-3-5-sonnet-v2@20241022", + max_tokens=100, + messages=[ + { + "role": "user", + "content": "Hello!", + } + ], +) +print(message) +``` + +For a more complete example see [`examples/vertex.py`](https://github.com/anthropics/anthropic-sdk-python/blob/main/examples/vertex.py). + +## Using types + +Nested request parameters are [TypedDicts](https://docs.python.org/3/library/typing.html#typing.TypedDict). Responses are [Pydantic models](https://docs.pydantic.dev) which also provide helper methods for things like: + +- Serializing back into JSON, `model.to_json()` +- Converting to a dictionary, `model.to_dict()` + +Typed requests and responses provide autocomplete and documentation within your editor. If you would like to see type errors in VS Code to help catch bugs earlier, set `python.analysis.typeCheckingMode` to `basic`. + +## Pagination + +List methods in the Anthropic API are paginated. + +This library provides auto-paginating iterators with each list response, so you do not have to request successive pages manually: + +```python +from anthropic import Anthropic + +client = Anthropic() + +all_batches = [] +# Automatically fetches more pages as needed. +for batch in client.beta.messages.batches.list( + limit=20, +): + # Do something with batch here + all_batches.append(batch) +print(all_batches) +``` + +Or, asynchronously: + +```python +import asyncio +from anthropic import AsyncAnthropic + +client = AsyncAnthropic() + + +async def main() -> None: + all_batches = [] + # Iterate through items across all pages, issuing requests as needed. + async for batch in client.beta.messages.batches.list( + limit=20, + ): + all_batches.append(batch) + print(all_batches) + + +asyncio.run(main()) +``` + +Alternatively, you can use the `.has_next_page()`, `.next_page_info()`, or `.get_next_page()` methods for more granular control working with pages: + +```python +first_page = await client.beta.messages.batches.list( + limit=20, +) +if first_page.has_next_page(): + print(f"will fetch next page using these details: {first_page.next_page_info()}") + next_page = await first_page.get_next_page() + print(f"number of items we just fetched: {len(next_page.data)}") + +# Remove `await` for non-async usage. +``` + +Or just work directly with the returned data: + +```python +first_page = await client.beta.messages.batches.list( + limit=20, +) + +print(f"next page cursor: {first_page.last_id}") # => "next page cursor: ..." +for batch in first_page.data: + print(batch.id) + +# Remove `await` for non-async usage. +``` + +## Handling errors + +When the library is unable to connect to the API (for example, due to network connection problems or a timeout), a subclass of `anthropic.APIConnectionError` is raised. + +When the API returns a non-success status code (that is, 4xx or 5xx +response), a subclass of `anthropic.APIStatusError` is raised, containing `status_code` and `response` properties. + +All errors inherit from `anthropic.APIError`. + +```python +import anthropic +from anthropic import Anthropic + +client = Anthropic() + +try: + client.messages.create( + max_tokens=1024, + messages=[ + { + "role": "user", + "content": "Hello, Claude", + } + ], + model="claude-3-5-sonnet-latest", + ) +except anthropic.APIConnectionError as e: + print("The server could not be reached") + print(e.__cause__) # an underlying Exception, likely raised within httpx. +except anthropic.RateLimitError as e: + print("A 429 status code was received; we should back off a bit.") +except anthropic.APIStatusError as e: + print("Another non-200-range status code was received") + print(e.status_code) + print(e.response) +``` + +Error codes are as follows: + +| Status Code | Error Type | +| ----------- | -------------------------- | +| 400 | `BadRequestError` | +| 401 | `AuthenticationError` | +| 403 | `PermissionDeniedError` | +| 404 | `NotFoundError` | +| 422 | `UnprocessableEntityError` | +| 429 | `RateLimitError` | +| >=500 | `InternalServerError` | +| N/A | `APIConnectionError` | + +## Request IDs + +> For more information on debugging requests, see [these docs](https://docs.anthropic.com/en/api/errors#request-id) + +All object responses in the SDK provide a `_request_id` property which is added from the `request-id` response header so that you can quickly log failing requests and report them back to Anthropic. + +```python +message = client.messages.create( + max_tokens=1024, + messages=[ + { + "role": "user", + "content": "Hello, Claude", + } + ], + model="claude-3-5-sonnet-latest", +) +print(message._request_id) # req_018EeWyXxfu5pfWkrYcMdjWG +``` + +Note that unlike other properties that use an `_` prefix, the `_request_id` property +*is* public. Unless documented otherwise, *all* other `_` prefix properties, +methods and modules are *private*. + +### Retries + +Certain errors are automatically retried 2 times by default, with a short exponential backoff. +Connection errors (for example, due to a network connectivity problem), 408 Request Timeout, 409 Conflict, +429 Rate Limit, and >=500 Internal errors are all retried by default. + +You can use the `max_retries` option to configure or disable retry settings: + +```python +from anthropic import Anthropic + +# Configure the default for all requests: +client = Anthropic( + # default is 2 + max_retries=0, +) + +# Or, configure per-request: +client.with_options(max_retries=5).messages.create( + max_tokens=1024, + messages=[ + { + "role": "user", + "content": "Hello, Claude", + } + ], + model="claude-3-5-sonnet-latest", +) +``` + +### Timeouts + +By default requests time out after 10 minutes. You can configure this with a `timeout` option, +which accepts a float or an [`httpx.Timeout`](https://www.python-httpx.org/advanced/timeouts/#fine-tuning-the-configuration) object: + +```python +from anthropic import Anthropic + +# Configure the default for all requests: +client = Anthropic( + # 20 seconds (default is 10 minutes) + timeout=20.0, +) + +# More granular control: +client = Anthropic( + timeout=httpx.Timeout(60.0, read=5.0, write=10.0, connect=2.0), +) + +# Override per-request: +client.with_options(timeout=5.0).messages.create( + max_tokens=1024, + messages=[ + { + "role": "user", + "content": "Hello, Claude", + } + ], + model="claude-3-5-sonnet-latest", +) +``` + +On timeout, an `APITimeoutError` is thrown. + +Note that requests that time out are [retried twice by default](#retries). + +### Long Requests + +> [!IMPORTANT] +> We highly encourage you use the streaming [Messages API](#streaming-responses) for longer running requests. + +We do not recommend setting a large `max_tokens` values without using streaming. +Some networks may drop idle connections after a certain period of time, which +can cause the request to fail or [timeout](#timeouts) without receiving a response from Anthropic. + +This SDK will also throw a `ValueError` if a non-streaming request is expected to be above roughly 10 minutes long. +Passing `stream=True` or [overriding](#timeouts) the `timeout` option at the client or request level disables this error. + +An expected request latency longer than the [timeout](#timeouts) for a non-streaming request +will result in the client terminating the connection and retrying without receiving a response. + +We set a [TCP socket keep-alive](https://tldp.org/HOWTO/TCP-Keepalive-HOWTO/overview.html) option in order +to reduce the impact of idle connection timeouts on some networks. +This can be [overridden](#Configuring-the-HTTP-client) by passing a `http_client` option to the client. + +## Default Headers + +We automatically send the `anthropic-version` header set to `2023-06-01`. + +If you need to, you can override it by setting default headers per-request or on the client object. + +Be aware that doing so may result in incorrect types and other unexpected or undefined behavior in the SDK. + +```python +from anthropic import Anthropic + +client = Anthropic( + default_headers={"anthropic-version": "My-Custom-Value"}, +) +``` + +## Advanced + +### Logging + +We use the standard library [`logging`](https://docs.python.org/3/library/logging.html) module. + +You can enable logging by setting the environment variable `ANTHROPIC_LOG` to `info`. + +```shell +$ export ANTHROPIC_LOG=info +``` + +Or to `debug` for more verbose logging. + +### How to tell whether `None` means `null` or missing + +In an API response, a field may be explicitly `null`, or missing entirely; in either case, its value is `None` in this library. You can differentiate the two cases with `.model_fields_set`: + +```py +if response.my_field is None: + if 'my_field' not in response.model_fields_set: + print('Got json like {}, without a "my_field" key present at all.') + else: + print('Got json like {"my_field": null}.') +``` + +### Accessing raw response data (e.g. headers) + +The "raw" Response object can be accessed by prefixing `.with_raw_response.` to any HTTP method call, e.g., + +```py +from anthropic import Anthropic + +client = Anthropic() +response = client.messages.with_raw_response.create( + max_tokens=1024, + messages=[{ + "role": "user", + "content": "Hello, Claude", + }], + model="claude-3-5-sonnet-latest", +) +print(response.headers.get('X-My-Header')) + +message = response.parse() # get the object that `messages.create()` would have returned +print(message.content) +``` + +These methods return a [`LegacyAPIResponse`](https://github.com/anthropics/anthropic-sdk-python/tree/main/src/anthropic/_legacy_response.py) object. This is a legacy class as we're changing it slightly in the next major version. + +For the sync client this will mostly be the same with the exception +of `content` & `text` will be methods instead of properties. In the +async client, all methods will be async. + +A migration script will be provided & the migration in general should +be smooth. + +#### `.with_streaming_response` + +The above interface eagerly reads the full response body when you make the request, which may not always be what you want. + +To stream the response body, use `.with_streaming_response` instead, which requires a context manager and only reads the response body once you call `.read()`, `.text()`, `.json()`, `.iter_bytes()`, `.iter_text()`, `.iter_lines()` or `.parse()`. In the async client, these are async methods. + +As such, `.with_streaming_response` methods return a different [`APIResponse`](https://github.com/anthropics/anthropic-sdk-python/tree/main/src/anthropic/_response.py) object, and the async client returns an [`AsyncAPIResponse`](https://github.com/anthropics/anthropic-sdk-python/tree/main/src/anthropic/_response.py) object. + +```python +with client.messages.with_streaming_response.create( + max_tokens=1024, + messages=[ + { + "role": "user", + "content": "Hello, Claude", + } + ], + model="claude-3-5-sonnet-latest", +) as response: + print(response.headers.get("X-My-Header")) + + for line in response.iter_lines(): + print(line) +``` + +The context manager is required so that the response will reliably be closed. + +### Making custom/undocumented requests + +This library is typed for convenient access to the documented API. + +If you need to access undocumented endpoints, params, or response properties, the library can still be used. + +#### Undocumented endpoints + +To make requests to undocumented endpoints, you can make requests using `client.get`, `client.post`, and other +http verbs. Options on the client will be respected (such as retries) when making this request. + +```py +import httpx + +response = client.post( + "/foo", + cast_to=httpx.Response, + body={"my_param": True}, +) + +print(response.headers.get("x-foo")) +``` + +#### Undocumented request params + +If you want to explicitly send an extra param, you can do so with the `extra_query`, `extra_body`, and `extra_headers` request +options. + +#### Undocumented response properties + +To access undocumented response properties, you can access the extra fields like `response.unknown_prop`. You +can also get all the extra fields on the Pydantic model as a dict with +[`response.model_extra`](https://docs.pydantic.dev/latest/api/base_model/#pydantic.BaseModel.model_extra). + +### Configuring the HTTP client + +You can directly override the [httpx client](https://www.python-httpx.org/api/#client) to customize it for your use case, including: + +- Support for [proxies](https://www.python-httpx.org/advanced/proxies/) +- Custom [transports](https://www.python-httpx.org/advanced/transports/) +- Additional [advanced](https://www.python-httpx.org/advanced/clients/) functionality + +```python +import httpx +from anthropic import Anthropic, DefaultHttpxClient + +client = Anthropic( + # Or use the `ANTHROPIC_BASE_URL` env var + base_url="http://my.test.server.example.com:8083", + http_client=DefaultHttpxClient( + proxy="http://my.test.proxy.example.com", + transport=httpx.HTTPTransport(local_address="0.0.0.0"), + ), +) +``` + +You can also customize the client on a per-request basis by using `with_options()`: + +```python +client.with_options(http_client=DefaultHttpxClient(...)) +``` + +### Managing HTTP resources + +By default the library closes underlying HTTP connections whenever the client is [garbage collected](https://docs.python.org/3/reference/datamodel.html#object.__del__). You can manually close the client using the `.close()` method if desired, or with a context manager that closes when exiting. + +```py +from anthropic import Anthropic + +with Anthropic() as client: + # make requests here + ... + +# HTTP client is now closed +``` + +## Versioning + +This package generally follows [SemVer](https://semver.org/spec/v2.0.0.html) conventions, though certain backwards-incompatible changes may be released as minor versions: + +1. Changes that only affect static types, without breaking runtime behavior. +2. Changes to library internals which are technically public but not intended or documented for external use. _(Please open a GitHub issue to let us know if you are relying on such internals.)_ +3. Changes that we do not expect to impact the vast majority of users in practice. + +We take backwards-compatibility seriously and work hard to ensure you can rely on a smooth upgrade experience. + +We are keen for your feedback; please open an [issue](https://www.github.com/anthropics/anthropic-sdk-python/issues) with questions, bugs, or suggestions. + +### Determining the installed version + +If you've upgraded to the latest version but aren't seeing any new features you were expecting then your python environment is likely still using an older version. + +You can determine the version that is being used at runtime with: + +```py +import anthropic +print(anthropic.__version__) +``` + +## Requirements + +Python 3.8 or higher. + +## Contributing + +See [the contributing documentation](./CONTRIBUTING.md). +``` + + +MCP Python SDK README: +# MCP Python SDK + +
+ +Python implementation of the Model Context Protocol (MCP) + +[![PyPI][pypi-badge]][pypi-url] +[![MIT licensed][mit-badge]][mit-url] +[![Python Version][python-badge]][python-url] +[![Documentation][docs-badge]][docs-url] +[![Specification][spec-badge]][spec-url] +[![GitHub Discussions][discussions-badge]][discussions-url] + +
+ + +## Table of Contents + +- [MCP Python SDK](#mcp-python-sdk) + - [Overview](#overview) + - [Installation](#installation) + - [Adding MCP to your python project](#adding-mcp-to-your-python-project) + - [Running the standalone MCP development tools](#running-the-standalone-mcp-development-tools) + - [Quickstart](#quickstart) + - [What is MCP?](#what-is-mcp) + - [Core Concepts](#core-concepts) + - [Server](#server) + - [Resources](#resources) + - [Tools](#tools) + - [Prompts](#prompts) + - [Images](#images) + - [Context](#context) + - [Running Your Server](#running-your-server) + - [Development Mode](#development-mode) + - [Claude Desktop Integration](#claude-desktop-integration) + - [Direct Execution](#direct-execution) + - [Mounting to an Existing ASGI Server](#mounting-to-an-existing-asgi-server) + - [Examples](#examples) + - [Echo Server](#echo-server) + - [SQLite Explorer](#sqlite-explorer) + - [Advanced Usage](#advanced-usage) + - [Low-Level Server](#low-level-server) + - [Writing MCP Clients](#writing-mcp-clients) + - [MCP Primitives](#mcp-primitives) + - [Server Capabilities](#server-capabilities) + - [Documentation](#documentation) + - [Contributing](#contributing) + - [License](#license) + +[pypi-badge]: https://img.shields.io/pypi/v/mcp.svg +[pypi-url]: https://pypi.org/project/mcp/ +[mit-badge]: https://img.shields.io/pypi/l/mcp.svg +[mit-url]: https://github.com/modelcontextprotocol/python-sdk/blob/main/LICENSE +[python-badge]: https://img.shields.io/pypi/pyversions/mcp.svg +[python-url]: https://www.python.org/downloads/ +[docs-badge]: https://img.shields.io/badge/docs-modelcontextprotocol.io-blue.svg +[docs-url]: https://modelcontextprotocol.io +[spec-badge]: https://img.shields.io/badge/spec-spec.modelcontextprotocol.io-blue.svg +[spec-url]: https://spec.modelcontextprotocol.io +[discussions-badge]: https://img.shields.io/github/discussions/modelcontextprotocol/python-sdk +[discussions-url]: https://github.com/modelcontextprotocol/python-sdk/discussions + +## Overview + +The Model Context Protocol allows applications to provide context for LLMs in a standardized way, separating the concerns of providing context from the actual LLM interaction. This Python SDK implements the full MCP specification, making it easy to: + +- Build MCP clients that can connect to any MCP server +- Create MCP servers that expose resources, prompts and tools +- Use standard transports like stdio and SSE +- Handle all MCP protocol messages and lifecycle events + +## Installation + +### Adding MCP to your python project + +We recommend using [uv](https://docs.astral.sh/uv/) to manage your Python projects. + +If you haven't created a uv-managed project yet, create one: + + ```bash + uv init mcp-server-demo + cd mcp-server-demo + ``` + + Then add MCP to your project dependencies: + + ```bash + uv add "mcp[cli]" + ``` + +Alternatively, for projects using pip for dependencies: +```bash +pip install "mcp[cli]" +``` + +### Running the standalone MCP development tools + +To run the mcp command with uv: + +```bash +uv run mcp +``` + +## Quickstart + +Let's create a simple MCP server that exposes a calculator tool and some data: + +```python +# server.py +from mcp.server.fastmcp import FastMCP + +# Create an MCP server +mcp = FastMCP("Demo") + + +# Add an addition tool +@mcp.tool() +def add(a: int, b: int) -> int: + """Add two numbers""" + return a + b + + +# Add a dynamic greeting resource +@mcp.resource("greeting://{name}") +def get_greeting(name: str) -> str: + """Get a personalized greeting""" + return f"Hello, {name}!" +``` + +You can install this server in [Claude Desktop](https://claude.ai/download) and interact with it right away by running: +```bash +mcp install server.py +``` + +Alternatively, you can test it with the MCP Inspector: +```bash +mcp dev server.py +``` + +## What is MCP? + +The [Model Context Protocol (MCP)](https://modelcontextprotocol.io) lets you build servers that expose data and functionality to LLM applications in a secure, standardized way. Think of it like a web API, but specifically designed for LLM interactions. MCP servers can: + +- Expose data through **Resources** (think of these sort of like GET endpoints; they are used to load information into the LLM's context) +- Provide functionality through **Tools** (sort of like POST endpoints; they are used to execute code or otherwise produce a side effect) +- Define interaction patterns through **Prompts** (reusable templates for LLM interactions) +- And more! + +## Core Concepts + +### Server + +The FastMCP server is your core interface to the MCP protocol. It handles connection management, protocol compliance, and message routing: + +```python +# Add lifespan support for startup/shutdown with strong typing +from contextlib import asynccontextmanager +from collections.abc import AsyncIterator +from dataclasses import dataclass + +from fake_database import Database # Replace with your actual DB type + +from mcp.server.fastmcp import Context, FastMCP + +# Create a named server +mcp = FastMCP("My App") + +# Specify dependencies for deployment and development +mcp = FastMCP("My App", dependencies=["pandas", "numpy"]) + + +@dataclass +class AppContext: + db: Database + + +@asynccontextmanager +async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: + """Manage application lifecycle with type-safe context""" + # Initialize on startup + db = await Database.connect() + try: + yield AppContext(db=db) + finally: + # Cleanup on shutdown + await db.disconnect() + + +# Pass lifespan to server +mcp = FastMCP("My App", lifespan=app_lifespan) + + +# Access type-safe lifespan context in tools +@mcp.tool() +def query_db(ctx: Context) -> str: + """Tool that uses initialized resources""" + db = ctx.request_context.lifespan_context["db"] + return db.query() +``` + +### Resources + +Resources are how you expose data to LLMs. They're similar to GET endpoints in a REST API - they provide data but shouldn't perform significant computation or have side effects: + +```python +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("My App") + + +@mcp.resource("config://app") +def get_config() -> str: + """Static configuration data""" + return "App configuration here" + + +@mcp.resource("users://{user_id}/profile") +def get_user_profile(user_id: str) -> str: + """Dynamic user data""" + return f"Profile data for user {user_id}" +``` + +### Tools + +Tools let LLMs take actions through your server. Unlike resources, tools are expected to perform computation and have side effects: + +```python +import httpx +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("My App") + + +@mcp.tool() +def calculate_bmi(weight_kg: float, height_m: float) -> float: + """Calculate BMI given weight in kg and height in meters""" + return weight_kg / (height_m**2) + + +@mcp.tool() +async def fetch_weather(city: str) -> str: + """Fetch current weather for a city""" + async with httpx.AsyncClient() as client: + response = await client.get(f"https://api.weather.com/{city}") + return response.text +``` + +### Prompts + +Prompts are reusable templates that help LLMs interact with your server effectively: + +```python +from mcp.server.fastmcp import FastMCP +from mcp.server.fastmcp.prompts import base + +mcp = FastMCP("My App") + + +@mcp.prompt() +def review_code(code: str) -> str: + return f"Please review this code:\n\n{code}" + + +@mcp.prompt() +def debug_error(error: str) -> list[base.Message]: + return [ + base.UserMessage("I'm seeing this error:"), + base.UserMessage(error), + base.AssistantMessage("I'll help debug that. What have you tried so far?"), + ] +``` + +### Images + +FastMCP provides an `Image` class that automatically handles image data: + +```python +from mcp.server.fastmcp import FastMCP, Image +from PIL import Image as PILImage + +mcp = FastMCP("My App") + + +@mcp.tool() +def create_thumbnail(image_path: str) -> Image: + """Create a thumbnail from an image""" + img = PILImage.open(image_path) + img.thumbnail((100, 100)) + return Image(data=img.tobytes(), format="png") +``` + +### Context + +The Context object gives your tools and resources access to MCP capabilities: + +```python +from mcp.server.fastmcp import FastMCP, Context + +mcp = FastMCP("My App") + + +@mcp.tool() +async def long_task(files: list[str], ctx: Context) -> str: + """Process multiple files with progress tracking""" + for i, file in enumerate(files): + ctx.info(f"Processing {file}") + await ctx.report_progress(i, len(files)) + data, mime_type = await ctx.read_resource(f"file://{file}") + return "Processing complete" +``` + +## Running Your Server + +### Development Mode + +The fastest way to test and debug your server is with the MCP Inspector: + +```bash +mcp dev server.py + +# Add dependencies +mcp dev server.py --with pandas --with numpy + +# Mount local code +mcp dev server.py --with-editable . +``` + +### Claude Desktop Integration + +Once your server is ready, install it in Claude Desktop: + +```bash +mcp install server.py + +# Custom name +mcp install server.py --name "My Analytics Server" + +# Environment variables +mcp install server.py -v API_KEY=abc123 -v DB_URL=postgres://... +mcp install server.py -f .env +``` + +### Direct Execution + +For advanced scenarios like custom deployments: + +```python +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("My App") + +if __name__ == "__main__": + mcp.run() +``` + +Run it with: +```bash +python server.py +# or +mcp run server.py +``` + +### Mounting to an Existing ASGI Server + +You can mount the SSE server to an existing ASGI server using the `sse_app` method. This allows you to integrate the SSE server with other ASGI applications. + +```python +from starlette.applications import Starlette +from starlette.routing import Mount, Host +from mcp.server.fastmcp import FastMCP + + +mcp = FastMCP("My App") + +# Mount the SSE server to the existing ASGI server +app = Starlette( + routes=[ + Mount('/', app=mcp.sse_app()), + ] +) + +# or dynamically mount as host +app.router.routes.append(Host('mcp.acme.corp', app=mcp.sse_app())) +``` + +For more information on mounting applications in Starlette, see the [Starlette documentation](https://www.starlette.io/routing/#submounting-routes). + +## Examples + +### Echo Server + +A simple server demonstrating resources, tools, and prompts: + +```python +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("Echo") + + +@mcp.resource("echo://{message}") +def echo_resource(message: str) -> str: + """Echo a message as a resource""" + return f"Resource echo: {message}" + + +@mcp.tool() +def echo_tool(message: str) -> str: + """Echo a message as a tool""" + return f"Tool echo: {message}" + + +@mcp.prompt() +def echo_prompt(message: str) -> str: + """Create an echo prompt""" + return f"Please process this message: {message}" +``` + +### SQLite Explorer + +A more complex example showing database integration: + +```python +import sqlite3 + +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("SQLite Explorer") + + +@mcp.resource("schema://main") +def get_schema() -> str: + """Provide the database schema as a resource""" + conn = sqlite3.connect("database.db") + schema = conn.execute("SELECT sql FROM sqlite_master WHERE type='table'").fetchall() + return "\n".join(sql[0] for sql in schema if sql[0]) + + +@mcp.tool() +def query_data(sql: str) -> str: + """Execute SQL queries safely""" + conn = sqlite3.connect("database.db") + try: + result = conn.execute(sql).fetchall() + return "\n".join(str(row) for row in result) + except Exception as e: + return f"Error: {str(e)}" +``` + +## Advanced Usage + +### Low-Level Server + +For more control, you can use the low-level server implementation directly. This gives you full access to the protocol and allows you to customize every aspect of your server, including lifecycle management through the lifespan API: + +```python +from contextlib import asynccontextmanager +from collections.abc import AsyncIterator + +from fake_database import Database # Replace with your actual DB type + +from mcp.server import Server + + +@asynccontextmanager +async def server_lifespan(server: Server) -> AsyncIterator[dict]: + """Manage server startup and shutdown lifecycle.""" + # Initialize resources on startup + db = await Database.connect() + try: + yield {"db": db} + finally: + # Clean up on shutdown + await db.disconnect() + + +# Pass lifespan to server +server = Server("example-server", lifespan=server_lifespan) + + +# Access lifespan context in handlers +@server.call_tool() +async def query_db(name: str, arguments: dict) -> list: + ctx = server.request_context + db = ctx.lifespan_context["db"] + return await db.query(arguments["query"]) +``` + +The lifespan API provides: +- A way to initialize resources when the server starts and clean them up when it stops +- Access to initialized resources through the request context in handlers +- Type-safe context passing between lifespan and request handlers + +```python +import mcp.server.stdio +import mcp.types as types +from mcp.server.lowlevel import NotificationOptions, Server +from mcp.server.models import InitializationOptions + +# Create a server instance +server = Server("example-server") + + +@server.list_prompts() +async def handle_list_prompts() -> list[types.Prompt]: + return [ + types.Prompt( + name="example-prompt", + description="An example prompt template", + arguments=[ + types.PromptArgument( + name="arg1", description="Example argument", required=True + ) + ], + ) + ] + + +@server.get_prompt() +async def handle_get_prompt( + name: str, arguments: dict[str, str] | None +) -> types.GetPromptResult: + if name != "example-prompt": + raise ValueError(f"Unknown prompt: {name}") + + return types.GetPromptResult( + description="Example prompt", + messages=[ + types.PromptMessage( + role="user", + content=types.TextContent(type="text", text="Example prompt text"), + ) + ], + ) + + +async def run(): + async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + InitializationOptions( + server_name="example", + server_version="0.1.0", + capabilities=server.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ), + ) + + +if __name__ == "__main__": + import asyncio + + asyncio.run(run()) +``` + +### Writing MCP Clients + +The SDK provides a high-level client interface for connecting to MCP servers: + +```python +from mcp import ClientSession, StdioServerParameters, types +from mcp.client.stdio import stdio_client + +# Create server parameters for stdio connection +server_params = StdioServerParameters( + command="python", # Executable + args=["example_server.py"], # Optional command line arguments + env=None, # Optional environment variables +) + + +# Optional: create a sampling callback +async def handle_sampling_message( + message: types.CreateMessageRequestParams, +) -> types.CreateMessageResult: + return types.CreateMessageResult( + role="assistant", + content=types.TextContent( + type="text", + text="Hello, world! from model", + ), + model="gpt-3.5-turbo", + stopReason="endTurn", + ) + + +async def run(): + async with stdio_client(server_params) as (read, write): + async with ClientSession( + read, write, sampling_callback=handle_sampling_message + ) as session: + # Initialize the connection + await session.initialize() + + # List available prompts + prompts = await session.list_prompts() + + # Get a prompt + prompt = await session.get_prompt( + "example-prompt", arguments={"arg1": "value"} + ) + + # List available resources + resources = await session.list_resources() + + # List available tools + tools = await session.list_tools() + + # Read a resource + content, mime_type = await session.read_resource("file://some/path") + + # Call a tool + result = await session.call_tool("tool-name", arguments={"arg1": "value"}) + + +if __name__ == "__main__": + import asyncio + + asyncio.run(run()) +``` + +### MCP Primitives + +The MCP protocol defines three core primitives that servers can implement: + +| Primitive | Control | Description | Example Use | +|-----------|-----------------------|-----------------------------------------------------|------------------------------| +| Prompts | User-controlled | Interactive templates invoked by user choice | Slash commands, menu options | +| Resources | Application-controlled| Contextual data managed by the client application | File contents, API responses | +| Tools | Model-controlled | Functions exposed to the LLM to take actions | API calls, data updates | + +### Server Capabilities + +MCP servers declare capabilities during initialization: + +| Capability | Feature Flag | Description | +|-------------|------------------------------|------------------------------------| +| `prompts` | `listChanged` | Prompt template management | +| `resources` | `subscribe`
`listChanged`| Resource exposure and updates | +| `tools` | `listChanged` | Tool discovery and execution | +| `logging` | - | Server logging configuration | +| `completion`| - | Argument completion suggestions | + +## Documentation + +- [Model Context Protocol documentation](https://modelcontextprotocol.io) +- [Model Context Protocol specification](https://spec.modelcontextprotocol.io) +- [Officially supported servers](https://github.com/modelcontextprotocol/servers) + +## Contributing + +We are passionate about supporting contributors of all levels of experience and would love to see you get involved in the project. See the [contributing guide](CONTRIBUTING.md) to get started. + +## License + +This project is licensed under the MIT License - see the LICENSE file for details. + + +MCP Python SDK example of an MCP client: +```py +import asyncio +import json +import logging +import os +import shutil +from contextlib import AsyncExitStack +from typing import Any + +import httpx +from dotenv import load_dotenv +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client + +# Configure logging +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" +) + + +class Configuration: + """Manages configuration and environment variables for the MCP client.""" + + def __init__(self) -> None: + """Initialize configuration with environment variables.""" + self.load_env() + self.api_key = os.getenv("LLM_API_KEY") + + @staticmethod + def load_env() -> None: + """Load environment variables from .env file.""" + load_dotenv() + + @staticmethod + def load_config(file_path: str) -> dict[str, Any]: + """Load server configuration from JSON file. + + Args: + file_path: Path to the JSON configuration file. + + Returns: + Dict containing server configuration. + + Raises: + FileNotFoundError: If configuration file doesn't exist. + JSONDecodeError: If configuration file is invalid JSON. + """ + with open(file_path, "r") as f: + return json.load(f) + + @property + def llm_api_key(self) -> str: + """Get the LLM API key. + + Returns: + The API key as a string. + + Raises: + ValueError: If the API key is not found in environment variables. + """ + if not self.api_key: + raise ValueError("LLM_API_KEY not found in environment variables") + return self.api_key + + +class Server: + """Manages MCP server connections and tool execution.""" + + def __init__(self, name: str, config: dict[str, Any]) -> None: + self.name: str = name + self.config: dict[str, Any] = config + self.stdio_context: Any | None = None + self.session: ClientSession | None = None + self._cleanup_lock: asyncio.Lock = asyncio.Lock() + self.exit_stack: AsyncExitStack = AsyncExitStack() + + async def initialize(self) -> None: + """Initialize the server connection.""" + command = ( + shutil.which("npx") + if self.config["command"] == "npx" + else self.config["command"] + ) + if command is None: + raise ValueError("The command must be a valid string and cannot be None.") + + server_params = StdioServerParameters( + command=command, + args=self.config["args"], + env={**os.environ, **self.config["env"]} + if self.config.get("env") + else None, + ) + try: + stdio_transport = await self.exit_stack.enter_async_context( + stdio_client(server_params) + ) + read, write = stdio_transport + session = await self.exit_stack.enter_async_context( + ClientSession(read, write) + ) + await session.initialize() + self.session = session + except Exception as e: + logging.error(f"Error initializing server {self.name}: {e}") + await self.cleanup() + raise + + async def list_tools(self) -> list[Any]: + """List available tools from the server. + + Returns: + A list of available tools. + + Raises: + RuntimeError: If the server is not initialized. + """ + if not self.session: + raise RuntimeError(f"Server {self.name} not initialized") + + tools_response = await self.session.list_tools() + tools = [] + + for item in tools_response: + if isinstance(item, tuple) and item[0] == "tools": + for tool in item[1]: + tools.append(Tool(tool.name, tool.description, tool.inputSchema)) + + return tools + + async def execute_tool( + self, + tool_name: str, + arguments: dict[str, Any], + retries: int = 2, + delay: float = 1.0, + ) -> Any: + """Execute a tool with retry mechanism. + + Args: + tool_name: Name of the tool to execute. + arguments: Tool arguments. + retries: Number of retry attempts. + delay: Delay between retries in seconds. + + Returns: + Tool execution result. + + Raises: + RuntimeError: If server is not initialized. + Exception: If tool execution fails after all retries. + """ + if not self.session: + raise RuntimeError(f"Server {self.name} not initialized") + + attempt = 0 + while attempt < retries: + try: + logging.info(f"Executing {tool_name}...") + result = await self.session.call_tool(tool_name, arguments) + + return result + + except Exception as e: + attempt += 1 + logging.warning( + f"Error executing tool: {e}. Attempt {attempt} of {retries}." + ) + if attempt < retries: + logging.info(f"Retrying in {delay} seconds...") + await asyncio.sleep(delay) + else: + logging.error("Max retries reached. Failing.") + raise + + async def cleanup(self) -> None: + """Clean up server resources.""" + async with self._cleanup_lock: + try: + await self.exit_stack.aclose() + self.session = None + self.stdio_context = None + except Exception as e: + logging.error(f"Error during cleanup of server {self.name}: {e}") + + +class Tool: + """Represents a tool with its properties and formatting.""" + + def __init__( + self, name: str, description: str, input_schema: dict[str, Any] + ) -> None: + self.name: str = name + self.description: str = description + self.input_schema: dict[str, Any] = input_schema + + def format_for_llm(self) -> str: + """Format tool information for LLM. + + Returns: + A formatted string describing the tool. + """ + args_desc = [] + if "properties" in self.input_schema: + for param_name, param_info in self.input_schema["properties"].items(): + arg_desc = ( + f"- {param_name}: {param_info.get('description', 'No description')}" + ) + if param_name in self.input_schema.get("required", []): + arg_desc += " (required)" + args_desc.append(arg_desc) + + return f""" +Tool: {self.name} +Description: {self.description} +Arguments: +{chr(10).join(args_desc)} +""" + + +class LLMClient: + """Manages communication with the LLM provider.""" + + def __init__(self, api_key: str) -> None: + self.api_key: str = api_key + + def get_response(self, messages: list[dict[str, str]]) -> str: + """Get a response from the LLM. + + Args: + messages: A list of message dictionaries. + + Returns: + The LLM's response as a string. + + Raises: + httpx.RequestError: If the request to the LLM fails. + """ + url = "https://api.groq.com/openai/v1/chat/completions" + + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.api_key}", + } + payload = { + "messages": messages, + "model": "llama-3.2-90b-vision-preview", + "temperature": 0.7, + "max_tokens": 4096, + "top_p": 1, + "stream": False, + "stop": None, + } + + try: + with httpx.Client() as client: + response = client.post(url, headers=headers, json=payload) + response.raise_for_status() + data = response.json() + return data["choices"][0]["message"]["content"] + + except httpx.RequestError as e: + error_message = f"Error getting LLM response: {str(e)}" + logging.error(error_message) + + if isinstance(e, httpx.HTTPStatusError): + status_code = e.response.status_code + logging.error(f"Status code: {status_code}") + logging.error(f"Response details: {e.response.text}") + + return ( + f"I encountered an error: {error_message}. " + "Please try again or rephrase your request." + ) + + +class ChatSession: + """Orchestrates the interaction between user, LLM, and tools.""" + + def __init__(self, servers: list[Server], llm_client: LLMClient) -> None: + self.servers: list[Server] = servers + self.llm_client: LLMClient = llm_client + + async def cleanup_servers(self) -> None: + """Clean up all servers properly.""" + cleanup_tasks = [] + for server in self.servers: + cleanup_tasks.append(asyncio.create_task(server.cleanup())) + + if cleanup_tasks: + try: + await asyncio.gather(*cleanup_tasks, return_exceptions=True) + except Exception as e: + logging.warning(f"Warning during final cleanup: {e}") + + async def process_llm_response(self, llm_response: str) -> str: + """Process the LLM response and execute tools if needed. + + Args: + llm_response: The response from the LLM. + + Returns: + The result of tool execution or the original response. + """ + import json + + try: + tool_call = json.loads(llm_response) + if "tool" in tool_call and "arguments" in tool_call: + logging.info(f"Executing tool: {tool_call['tool']}") + logging.info(f"With arguments: {tool_call['arguments']}") + + for server in self.servers: + tools = await server.list_tools() + if any(tool.name == tool_call["tool"] for tool in tools): + try: + result = await server.execute_tool( + tool_call["tool"], tool_call["arguments"] + ) + + if isinstance(result, dict) and "progress" in result: + progress = result["progress"] + total = result["total"] + percentage = (progress / total) * 100 + logging.info( + f"Progress: {progress}/{total} " + f"({percentage:.1f}%)" + ) + + return f"Tool execution result: {result}" + except Exception as e: + error_msg = f"Error executing tool: {str(e)}" + logging.error(error_msg) + return error_msg + + return f"No server found with tool: {tool_call['tool']}" + return llm_response + except json.JSONDecodeError: + return llm_response + + async def start(self) -> None: + """Main chat session handler.""" + try: + for server in self.servers: + try: + await server.initialize() + except Exception as e: + logging.error(f"Failed to initialize server: {e}") + await self.cleanup_servers() + return + + all_tools = [] + for server in self.servers: + tools = await server.list_tools() + all_tools.extend(tools) + + tools_description = "\n".join([tool.format_for_llm() for tool in all_tools]) + + system_message = ( + "You are a helpful assistant with access to these tools:\n\n" + f"{tools_description}\n" + "Choose the appropriate tool based on the user's question. " + "If no tool is needed, reply directly.\n\n" + "IMPORTANT: When you need to use a tool, you must ONLY respond with " + "the exact JSON object format below, nothing else:\n" + "{\n" + ' "tool": "tool-name",\n' + ' "arguments": {\n' + ' "argument-name": "value"\n' + " }\n" + "}\n\n" + "After receiving a tool's response:\n" + "1. Transform the raw data into a natural, conversational response\n" + "2. Keep responses concise but informative\n" + "3. Focus on the most relevant information\n" + "4. Use appropriate context from the user's question\n" + "5. Avoid simply repeating the raw data\n\n" + "Please use only the tools that are explicitly defined above." + ) + + messages = [{"role": "system", "content": system_message}] + + while True: + try: + user_input = input("You: ").strip().lower() + if user_input in ["quit", "exit"]: + logging.info("\nExiting...") + break + + messages.append({"role": "user", "content": user_input}) + + llm_response = self.llm_client.get_response(messages) + logging.info("\nAssistant: %s", llm_response) + + result = await self.process_llm_response(llm_response) + + if result != llm_response: + messages.append({"role": "assistant", "content": llm_response}) + messages.append({"role": "system", "content": result}) + + final_response = self.llm_client.get_response(messages) + logging.info("\nFinal response: %s", final_response) + messages.append( + {"role": "assistant", "content": final_response} + ) + else: + messages.append({"role": "assistant", "content": llm_response}) + + except KeyboardInterrupt: + logging.info("\nExiting...") + break + + finally: + await self.cleanup_servers() + + +async def main() -> None: + """Initialize and run the chat session.""" + config = Configuration() + server_config = config.load_config("servers_config.json") + servers = [ + Server(name, srv_config) + for name, srv_config in server_config["mcpServers"].items() + ] + llm_client = LLMClient(config.llm_api_key) + chat_session = ChatSession(servers, llm_client) + await chat_session.start() + + +if __name__ == "__main__": + asyncio.run(main()) +``` + + + + +JSON schema for Claude Code tools available via MCP: +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "tools": [ + { + "name": "dispatch_agent", + "description": "Launch a new task", + "inputSchema": { + "type": "object", + "properties": { + "prompt": { + "type": "string", + "description": "The task for the agent to perform" + } + }, + "required": [ + "prompt" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "Bash", + "description": "Run shell command", + "inputSchema": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The command to execute" + }, + "timeout": { + "type": "number", + "description": "Optional timeout in milliseconds (max 600000)" + }, + "description": { + "type": "string", + "description": " Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'" + } + }, + "required": [ + "command" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "BatchTool", + "description": "\n- Batch execution tool that runs multiple tool invocations in a single request\n- Tools are executed in parallel when possible, and otherwise serially\n- Takes a list of tool invocations (tool_name and input pairs)\n- Returns the collected results from all invocations\n- Use this tool when you need to run multiple independent tool operations at once -- it is awesome for speeding up your workflow, reducing both context usage and latency\n- Each tool will respect its own permissions and validation rules\n- The tool's outputs are NOT shown to the user; to answer the user's query, you MUST send a message with the results after the tool call completes, otherwise the user will not see the results\n\nAvailable tools:\nTool: dispatch_agent\nArguments: prompt: string \"The task for the agent to perform\"\nUsage: Launch a new agent that has access to the following tools: View, GlobTool, GrepTool, LS, ReadNotebook, WebFetchTool. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries, use the Agent tool to perform the search for you.\n\nWhen to use the Agent tool:\n- If you are searching for a keyword like \"config\" or \"logger\", or for questions like \"which file does X?\", the Agent tool is strongly recommended\n\nWhen NOT to use the Agent tool:\n- If you want to read a specific file path, use the View or GlobTool tool instead of the Agent tool, to find the match more quickly\n- If you are searching for a specific class definition like \"class Foo\", use the GlobTool tool instead, to find the match more quickly\n- If you are searching for code within a specific file or set of 2-3 files, use the View tool instead of the Agent tool, to find the match more quickly\n\nUsage notes:\n1. Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses\n2. When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result.\n3. Each agent invocation is stateless. You will not be able to send additional messages to the agent, nor will the agent be able to communicate with you outside of its final report. Therefore, your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you.\n4. The agent's outputs should generally be trusted\n5. IMPORTANT: The agent can not use Bash, Replace, Edit, NotebookEditCell, so can not modify files. If you want to use these tools, use them directly instead of going through the agent.\n---Tool: Bash\nArguments: command: string \"The command to execute\", [optional] timeout: number \"Optional timeout in milliseconds (max 600000)\", [optional] description: string \" Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'\"\nUsage: Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.\n\nBefore executing the command, please follow these steps:\n\n1. Directory Verification:\n - If the command will create new directories or files, first use the LS tool to verify the parent directory exists and is the correct location\n - For example, before running \"mkdir foo/bar\", first use LS to check that \"foo\" exists and is the intended parent directory\n\n2. Security Check:\n - For security and to limit the threat of a prompt injection attack, some commands are limited or banned. If you use a disallowed command, you will receive an error message explaining the restriction. Explain the error to the User.\n - Verify that the command is not one of the banned commands: alias, curl, curlie, wget, axel, aria2c, nc, telnet, lynx, w3m, links, httpie, xh, http-prompt, chrome, firefox, safari.\n\n3. Command Execution:\n - After ensuring proper quoting, execute the command.\n - Capture the output of the command.\n\nUsage notes:\n - The command argument is required.\n - You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 30 minutes.\n - It is very helpful if you write a clear, concise description of what this command does in 5-10 words.\n - If the output exceeds 30000 characters, output will be truncated before being returned to you.\n - VERY IMPORTANT: You MUST avoid using search commands like `find` and `grep`. Instead use GrepTool, GlobTool, or dispatch_agent to search. You MUST avoid read tools like `cat`, `head`, `tail`, and `ls`, and use View and LS to read files.\n - When issuing multiple commands, use the ';' or '&&' operator to separate them. DO NOT use newlines (newlines are ok in quoted strings).\n - Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of `cd`. You may use `cd` if the User explicitly requests it.\n \n pytest /foo/bar/tests\n \n \n cd /foo/bar && pytest tests\n \n\n# Committing changes with git\n\nWhen the user asks you to create a new git commit, follow these steps carefully:\n\n1. Use BatchTool to run the following commands in parallel:\n - Run a git status command to see all untracked files.\n - Run a git diff command to see both staged and unstaged changes that will be committed.\n - Run a git log command to see recent commit messages, so that you can follow this repository's commit message style.\n\n2. Analyze all staged changes (both previously staged and newly added) and draft a commit message. Wrap your analysis process in tags:\n\n\n- List the files that have been changed or added\n- Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.)\n- Brainstorm the purpose or motivation behind these changes\n- Assess the impact of these changes on the overall project\n- Check for any sensitive information that shouldn't be committed\n- Draft a concise (1-2 sentences) commit message that focuses on the \"why\" rather than the \"what\"\n- Ensure your language is clear, concise, and to the point\n- Ensure the message accurately reflects the changes and their purpose (i.e. \"add\" means a wholly new feature, \"update\" means an enhancement to an existing feature, \"fix\" means a bug fix, etc.)\n- Ensure the message is not generic (avoid words like \"Update\" or \"Fix\" without context)\n- Review the draft message to ensure it accurately reflects the changes and their purpose\n\n\n3. Use BatchTool to run the following commands in parallel:\n - Add relevant untracked files to the staging area.\n - Create the commit with a message ending with:\n 🤖 Generated with [Claude Code](https://claude.ai/code)\n\n Co-Authored-By: Claude \n - Run git status to make sure the commit succeeded.\n\n4. If the commit fails due to pre-commit hook changes, retry the commit ONCE to include these automated changes. If it fails again, it usually means a pre-commit hook is preventing the commit. If the commit succeeds but you notice that files were modified by the pre-commit hook, you MUST amend your commit to include them.\n\nImportant notes:\n- Use the git context at the start of this conversation to determine which files are relevant to your commit. Be careful not to stage and commit files (e.g. with `git add .`) that aren't relevant to your commit.\n- NEVER update the git config\n- DO NOT run additional commands to read or explore code, beyond what is available in the git context\n- DO NOT push to the remote repository\n- IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported.\n- If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit\n- Ensure your commit message is meaningful and concise. It should explain the purpose of the changes, not just describe them.\n- Return an empty response - the user will see the git output directly\n- In order to ensure good formatting, ALWAYS pass the commit message via a HEREDOC, a la this example:\n\ngit commit -m \"$(cat <<'EOF'\n Commit message here.\n\n 🤖 Generated with [Claude Code](https://claude.ai/code)\n\n Co-Authored-By: Claude \n EOF\n )\"\n\n\n# Creating pull requests\nUse the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a Github URL use the gh command to get the information needed.\n\nIMPORTANT: When the user asks you to create a pull request, follow these steps carefully:\n\n1. Use BatchTool to run the following commands in parallel, in order to understand the current state of the branch since it diverged from the main branch:\n - Run a git status command to see all untracked files\n - Run a git diff command to see both staged and unstaged changes that will be committed\n - Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote\n - Run a git log command and `git diff main...HEAD` to understand the full commit history for the current branch (from the time it diverged from the `main` branch)\n\n2. Analyze all changes that will be included in the pull request, making sure to look at all relevant commits (NOT just the latest commit, but ALL commits that will be included in the pull request!!!), and draft a pull request summary. Wrap your analysis process in tags:\n\n\n- List the commits since diverging from the main branch\n- Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.)\n- Brainstorm the purpose or motivation behind these changes\n- Assess the impact of these changes on the overall project\n- Do not use tools to explore code, beyond what is available in the git context\n- Check for any sensitive information that shouldn't be committed\n- Draft a concise (1-2 bullet points) pull request summary that focuses on the \"why\" rather than the \"what\"\n- Ensure the summary accurately reflects all changes since diverging from the main branch\n- Ensure your language is clear, concise, and to the point\n- Ensure the summary accurately reflects the changes and their purpose (ie. \"add\" means a wholly new feature, \"update\" means an enhancement to an existing feature, \"fix\" means a bug fix, etc.)\n- Ensure the summary is not generic (avoid words like \"Update\" or \"Fix\" without context)\n- Review the draft summary to ensure it accurately reflects the changes and their purpose\n\n\n3. Use BatchTool to run the following commands in parallel:\n - Create new branch if needed\n - Push to remote with -u flag if needed\n - Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.\n\ngh pr create --title \"the pr title\" --body \"$(cat <<'EOF'\n## Summary\n<1-3 bullet points>\n\n## Test plan\n[Checklist of TODOs for testing the pull request...]\n\n🤖 Generated with [Claude Code](https://claude.ai/code)\nEOF\n)\"\n\n\nImportant:\n- NEVER update the git config\n- Return an empty response - the user will see the gh output directly\n\n# Other common operations\n- View comments on a Github PR: gh api repos/foo/bar/pulls/123/comments\n---Tool: GlobTool\nArguments: pattern: string \"The glob pattern to match files against\", [optional] path: string \"The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter \"undefined\" or \"null\" - simply omit it for the default behavior. Must be a valid directory path if provided.\"\nUsage: - Fast file pattern matching tool that works with any codebase size\n- Supports glob patterns like \"**/*.js\" or \"src/**/*.ts\"\n- Returns matching file paths sorted by modification time\n- Use this tool when you need to find files by name patterns\n- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead\n\n---Tool: GrepTool\nArguments: pattern: string \"The regular expression pattern to search for in file contents\", [optional] path: string \"The directory to search in. Defaults to the current working directory.\", [optional] include: string \"File pattern to include in the search (e.g. \"*.js\", \"*.{ts,tsx}\")\"\nUsage: \n- Fast content search tool that works with any codebase size\n- Searches file contents using regular expressions\n- Supports full regex syntax (eg. \"log.*Error\", \"function\\s+\\w+\", etc.)\n- Filter files by pattern with the include parameter (eg. \"*.js\", \"*.{ts,tsx}\")\n- Returns matching file paths sorted by modification time\n- Use this tool when you need to find files containing specific patterns\n- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead\n\n---Tool: LS\nArguments: path: string \"The absolute path to the directory to list (must be absolute, not relative)\", [optional] ignore: array \"List of glob patterns to ignore\"\nUsage: Lists files and directories in a given path. The path parameter must be an absolute path, not a relative path. You can optionally provide an array of glob patterns to ignore with the ignore parameter. You should generally prefer the Glob and Grep tools, if you know which directories to search.\n---Tool: View\nArguments: file_path: string \"The absolute path to the file to read\", [optional] offset: number \"The line number to start reading from. Only provide if the file is too large to read at once\", [optional] limit: number \"The number of lines to read. Only provide if the file is too large to read at once.\"\nUsage: Reads a file from the local filesystem. You can access any file directly by using this tool.\nAssume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.\n\nUsage:\n- The file_path parameter must be an absolute path, not a relative path\n- By default, it reads up to 2000 lines starting from the beginning of the file\n- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters\n- Any lines longer than 2000 characters will be truncated\n- Results are returned using cat -n format, with line numbers starting at 1\n- This tool allows Claude Code to VIEW images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as Claude Code is a multimodal LLM.\n- For Jupyter notebooks (.ipynb files), use the ReadNotebook instead\n- When reading multiple files, you MUST use the BatchTool tool to read them all at once\n---Tool: Edit\nArguments: file_path: string \"The absolute path to the file to modify\", old_string: string \"The text to replace\", new_string: string \"The text to replace it with\", [optional] expected_replacements: number \"The expected number of replacements to perform. Defaults to 1 if not specified.\"\nUsage: This is a tool for editing files. For moving or renaming files, you should generally use the Bash tool with the 'mv' command instead. For larger edits, use the Write tool to overwrite files. For Jupyter notebooks (.ipynb files), use the NotebookEditCell instead.\n\nBefore using this tool:\n\n1. Use the View tool to understand the file's contents and context\n\n2. Verify the directory path is correct (only applicable when creating new files):\n - Use the LS tool to verify the parent directory exists and is the correct location\n\nTo make a file edit, provide the following:\n1. file_path: The absolute path to the file to modify (must be absolute, not relative)\n2. old_string: The text to replace (must match the file contents exactly, including all whitespace and indentation)\n3. new_string: The edited text to replace the old_string\n4. expected_replacements: The number of replacements you expect to make. Defaults to 1 if not specified.\n\nBy default, the tool will replace ONE occurrence of old_string with new_string in the specified file. If you want to replace multiple occurrences, provide the expected_replacements parameter with the exact number of occurrences you expect.\n\nCRITICAL REQUIREMENTS FOR USING THIS TOOL:\n\n1. UNIQUENESS (when expected_replacements is not specified): The old_string MUST uniquely identify the specific instance you want to change. This means:\n - Include AT LEAST 3-5 lines of context BEFORE the change point\n - Include AT LEAST 3-5 lines of context AFTER the change point\n - Include all whitespace, indentation, and surrounding code exactly as it appears in the file\n\n2. EXPECTED MATCHES: If you want to replace multiple instances:\n - Use the expected_replacements parameter with the exact number of occurrences you expect to replace\n - This will replace ALL occurrences of the old_string with the new_string\n - If the actual number of matches doesn't equal expected_replacements, the edit will fail\n - This is a safety feature to prevent unintended replacements\n\n3. VERIFICATION: Before using this tool:\n - Check how many instances of the target text exist in the file\n - If multiple instances exist, either:\n a) Gather enough context to uniquely identify each one and make separate calls, OR\n b) Use expected_replacements parameter with the exact count of instances you expect to replace\n\nWARNING: If you do not follow these requirements:\n - The tool will fail if old_string matches multiple locations and expected_replacements isn't specified\n - The tool will fail if the number of matches doesn't equal expected_replacements when it's specified\n - The tool will fail if old_string doesn't match exactly (including whitespace)\n - You may change unintended instances if you don't verify the match count\n\nWhen making edits:\n - Ensure the edit results in idiomatic, correct code\n - Do not leave the code in a broken state\n - Always use absolute file paths (starting with /)\n\nIf you want to create a new file, use:\n - A new file path, including dir name if needed\n - An empty old_string\n - The new file's contents as new_string\n\nRemember: when making multiple file edits in a row to the same file, you should prefer to send all edits in a single message with multiple calls to this tool, rather than multiple messages with a single call each.\n\n---Tool: Replace\nArguments: file_path: string \"The absolute path to the file to write (must be absolute, not relative)\", content: string \"The content to write to the file\"\nUsage: Write a file to the local filesystem. Overwrites the existing file if there is one.\n\nBefore using this tool:\n\n1. Use the ReadFile tool to understand the file's contents and context\n\n2. Directory Verification (only applicable when creating new files):\n - Use the LS tool to verify the parent directory exists and is the correct location\n---Tool: ReadNotebook\nArguments: notebook_path: string \"The absolute path to the Jupyter notebook file to read (must be absolute, not relative)\"\nUsage: Reads a Jupyter notebook (.ipynb file) and returns all of the cells with their outputs. Jupyter notebooks are interactive documents that combine code, text, and visualizations, commonly used for data analysis and scientific computing. The notebook_path parameter must be an absolute path, not a relative path.\n---Tool: NotebookEditCell\nArguments: notebook_path: string \"The absolute path to the Jupyter notebook file to edit (must be absolute, not relative)\", cell_number: number \"The index of the cell to edit (0-based)\", new_source: string \"The new source for the cell\", [optional] cell_type: string \"The type of the cell (code or markdown). If not specified, it defaults to the current cell type. If using edit_mode=insert, this is required.\", [optional] edit_mode: string \"The type of edit to make (replace, insert, delete). Defaults to replace.\"\nUsage: Completely replaces the contents of a specific cell in a Jupyter notebook (.ipynb file) with new source. Jupyter notebooks are interactive documents that combine code, text, and visualizations, commonly used for data analysis and scientific computing. The notebook_path parameter must be an absolute path, not a relative path. The cell_number is 0-indexed. Use edit_mode=insert to add a new cell at the index specified by cell_number. Use edit_mode=delete to delete the cell at the index specified by cell_number.\n---Tool: WebFetchTool\nArguments: url: string \"The URL to fetch content from\", prompt: string \"The prompt to run on the fetched content\"\nUsage: \n- Fetches content from a specified URL and processes it using an AI model\n- Takes a URL and a prompt as input\n- Fetches the URL content, converts HTML to markdown\n- Processes the content with the prompt using a small, fast model\n- Returns the model's response about the content\n- Use this tool when you need to retrieve and analyze web content\n\nUsage notes:\n - IMPORTANT: If an MCP-provided web fetch tool is available, prefer using that tool instead of this one, as it may have fewer restrictions. All MCP-provided tools start with \"mcp__\".\n - The URL must be a fully-formed valid URL\n - HTTP URLs will be automatically upgraded to HTTPS\n - For security reasons, the URL's domain must have been provided directly by the user, unless it's on a small pre-approved set of the top few dozen hosts for popular coding resources, like react.dev.\n - The prompt should describe what information you want to extract from the page\n - This tool is read-only and does not modify any files\n - Results may be summarized if the content is very large\n - Includes a self-cleaning 15-minute cache for faster responses when repeatedly accessing the same URL\n\n\nExample usage:\n{\n \"invocations\": [\n {\n \"tool_name\": \"Bash\",\n \"input\": {\n \"command\": \"git blame src/foo.ts\"\n }\n },\n {\n \"tool_name\": \"GlobTool\",\n \"input\": {\n \"pattern\": \"**/*.ts\"\n }\n },\n {\n \"tool_name\": \"GrepTool\",\n \"input\": {\n \"pattern\": \"function\",\n \"include\": \"*.ts\"\n }\n }\n ]\n}\n", + "inputSchema": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the batch operation" + }, + "invocations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "tool_name": { + "type": "string", + "description": "The name of the tool to invoke" + }, + "input": { + "type": "object", + "additionalProperties": {}, + "description": "The input to pass to the tool" + } + }, + "required": [ + "tool_name", + "input" + ], + "additionalProperties": false + }, + "description": "The list of tool invocations to execute" + } + }, + "required": [ + "description", + "invocations" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "GlobTool", + "description": "- Fast file pattern matching tool that works with any codebase size\n- Supports glob patterns like \"**/*.js\" or \"src/**/*.ts\"\n- Returns matching file paths sorted by modification time\n- Use this tool when you need to find files by name patterns\n- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead\n", + "inputSchema": { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "The glob pattern to match files against" + }, + "path": { + "type": "string", + "description": "The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter \"undefined\" or \"null\" - simply omit it for the default behavior. Must be a valid directory path if provided." + } + }, + "required": [ + "pattern" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "GrepTool", + "description": "\n- Fast content search tool that works with any codebase size\n- Searches file contents using regular expressions\n- Supports full regex syntax (eg. \"log.*Error\", \"function\\s+\\w+\", etc.)\n- Filter files by pattern with the include parameter (eg. \"*.js\", \"*.{ts,tsx}\")\n- Returns matching file paths sorted by modification time\n- Use this tool when you need to find files containing specific patterns\n- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead\n", + "inputSchema": { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "The regular expression pattern to search for in file contents" + }, + "path": { + "type": "string", + "description": "The directory to search in. Defaults to the current working directory." + }, + "include": { + "type": "string", + "description": "File pattern to include in the search (e.g. \"*.js\", \"*.{ts,tsx}\")" + } + }, + "required": [ + "pattern" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "LS", + "description": "Lists files and directories in a given path. The path parameter must be an absolute path, not a relative path. You can optionally provide an array of glob patterns to ignore with the ignore parameter. You should generally prefer the Glob and Grep tools, if you know which directories to search.", + "inputSchema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "The absolute path to the directory to list (must be absolute, not relative)" + }, + "ignore": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of glob patterns to ignore" + } + }, + "required": [ + "path" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "View", + "description": "Read a file from the local filesystem.", + "inputSchema": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "The absolute path to the file to read" + }, + "offset": { + "type": "number", + "description": "The line number to start reading from. Only provide if the file is too large to read at once" + }, + "limit": { + "type": "number", + "description": "The number of lines to read. Only provide if the file is too large to read at once." + } + }, + "required": [ + "file_path" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "Edit", + "description": "A tool for editing files", + "inputSchema": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "The absolute path to the file to modify" + }, + "old_string": { + "type": "string", + "description": "The text to replace" + }, + "new_string": { + "type": "string", + "description": "The text to replace it with" + }, + "expected_replacements": { + "type": "number", + "default": 1, + "description": "The expected number of replacements to perform. Defaults to 1 if not specified." + } + }, + "required": [ + "file_path", + "old_string", + "new_string" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "Replace", + "description": "Write a file to the local filesystem.", + "inputSchema": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "The absolute path to the file to write (must be absolute, not relative)" + }, + "content": { + "type": "string", + "description": "The content to write to the file" + } + }, + "required": [ + "file_path", + "content" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "ReadNotebook", + "description": "Extract and read source code from all code cells in a Jupyter notebook.", + "inputSchema": { + "type": "object", + "properties": { + "notebook_path": { + "type": "string", + "description": "The absolute path to the Jupyter notebook file to read (must be absolute, not relative)" + } + }, + "required": [ + "notebook_path" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "NotebookEditCell", + "description": "Replace the contents of a specific cell in a Jupyter notebook.", + "inputSchema": { + "type": "object", + "properties": { + "notebook_path": { + "type": "string", + "description": "The absolute path to the Jupyter notebook file to edit (must be absolute, not relative)" + }, + "cell_number": { + "type": "number", + "description": "The index of the cell to edit (0-based)" + }, + "new_source": { + "type": "string", + "description": "The new source for the cell" + }, + "cell_type": { + "type": "string", + "enum": [ + "code", + "markdown" + ], + "description": "The type of the cell (code or markdown). If not specified, it defaults to the current cell type. If using edit_mode=insert, this is required." + }, + "edit_mode": { + "type": "string", + "description": "The type of edit to make (replace, insert, delete). Defaults to replace." + } + }, + "required": [ + "notebook_path", + "cell_number", + "new_source" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "WebFetchTool", + "description": "Claude wants to fetch content from this URL", + "inputSchema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "The URL to fetch content from" + }, + "prompt": { + "type": "string", + "description": "The prompt to run on the fetched content" + } + }, + "required": [ + "url", + "prompt" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + } + ] + } +} +``` diff --git a/crates/agent/src/tools/evals/fixtures/zode/react.py b/crates/agent/src/tools/evals/fixtures/zode/react.py new file mode 100644 index 0000000000000000000000000000000000000000..03ff02e7891449fe2f3b45357a72410772276a0d --- /dev/null +++ b/crates/agent/src/tools/evals/fixtures/zode/react.py @@ -0,0 +1,14 @@ +class InputCell: + def __init__(self, initial_value): + self.value = None + + +class ComputeCell: + def __init__(self, inputs, compute_function): + self.value = None + + def add_callback(self, callback): + pass + + def remove_callback(self, callback): + pass diff --git a/crates/agent/src/tools/evals/fixtures/zode/react_test.py b/crates/agent/src/tools/evals/fixtures/zode/react_test.py new file mode 100644 index 0000000000000000000000000000000000000000..1f917e40b4167ed78c24b63151a2469f587bbda4 --- /dev/null +++ b/crates/agent/src/tools/evals/fixtures/zode/react_test.py @@ -0,0 +1,271 @@ +# These tests are auto-generated with test data from: +# https://github.com/exercism/problem-specifications/tree/main/exercises/react/canonical-data.json +# File last updated on 2023-07-19 + +from functools import partial +import unittest + +from react import ( + InputCell, + ComputeCell, +) + + +class ReactTest(unittest.TestCase): + def test_input_cells_have_a_value(self): + input = InputCell(10) + self.assertEqual(input.value, 10) + + def test_an_input_cell_s_value_can_be_set(self): + input = InputCell(4) + input.value = 20 + self.assertEqual(input.value, 20) + + def test_compute_cells_calculate_initial_value(self): + input = InputCell(1) + output = ComputeCell( + [ + input, + ], + lambda inputs: inputs[0] + 1, + ) + self.assertEqual(output.value, 2) + + def test_compute_cells_take_inputs_in_the_right_order(self): + one = InputCell(1) + two = InputCell(2) + output = ComputeCell( + [ + one, + two, + ], + lambda inputs: inputs[0] + inputs[1] * 10, + ) + self.assertEqual(output.value, 21) + + def test_compute_cells_update_value_when_dependencies_are_changed(self): + input = InputCell(1) + output = ComputeCell( + [ + input, + ], + lambda inputs: inputs[0] + 1, + ) + input.value = 3 + self.assertEqual(output.value, 4) + + def test_compute_cells_can_depend_on_other_compute_cells(self): + input = InputCell(1) + times_two = ComputeCell( + [ + input, + ], + lambda inputs: inputs[0] * 2, + ) + times_thirty = ComputeCell( + [ + input, + ], + lambda inputs: inputs[0] * 30, + ) + output = ComputeCell( + [ + times_two, + times_thirty, + ], + lambda inputs: inputs[0] + inputs[1], + ) + self.assertEqual(output.value, 32) + input.value = 3 + self.assertEqual(output.value, 96) + + def test_compute_cells_fire_callbacks(self): + input = InputCell(1) + output = ComputeCell( + [ + input, + ], + lambda inputs: inputs[0] + 1, + ) + cb1_observer = [] + callback1 = self.callback_factory(cb1_observer) + output.add_callback(callback1) + input.value = 3 + self.assertEqual(cb1_observer[-1], 4) + + def test_callback_cells_only_fire_on_change(self): + input = InputCell(1) + output = ComputeCell([input], lambda inputs: 111 if inputs[0] < 3 else 222) + cb1_observer = [] + callback1 = self.callback_factory(cb1_observer) + output.add_callback(callback1) + input.value = 2 + self.assertEqual(cb1_observer, []) + input.value = 4 + self.assertEqual(cb1_observer[-1], 222) + + def test_callbacks_do_not_report_already_reported_values(self): + input = InputCell(1) + output = ComputeCell( + [ + input, + ], + lambda inputs: inputs[0] + 1, + ) + cb1_observer = [] + callback1 = self.callback_factory(cb1_observer) + output.add_callback(callback1) + input.value = 2 + self.assertEqual(cb1_observer[-1], 3) + input.value = 3 + self.assertEqual(cb1_observer[-1], 4) + + def test_callbacks_can_fire_from_multiple_cells(self): + input = InputCell(1) + plus_one = ComputeCell( + [ + input, + ], + lambda inputs: inputs[0] + 1, + ) + minus_one = ComputeCell( + [ + input, + ], + lambda inputs: inputs[0] - 1, + ) + cb1_observer = [] + cb2_observer = [] + callback1 = self.callback_factory(cb1_observer) + callback2 = self.callback_factory(cb2_observer) + plus_one.add_callback(callback1) + minus_one.add_callback(callback2) + input.value = 10 + self.assertEqual(cb1_observer[-1], 11) + self.assertEqual(cb2_observer[-1], 9) + + def test_callbacks_can_be_added_and_removed(self): + input = InputCell(11) + output = ComputeCell( + [ + input, + ], + lambda inputs: inputs[0] + 1, + ) + cb1_observer = [] + cb2_observer = [] + cb3_observer = [] + callback1 = self.callback_factory(cb1_observer) + callback2 = self.callback_factory(cb2_observer) + callback3 = self.callback_factory(cb3_observer) + output.add_callback(callback1) + output.add_callback(callback2) + input.value = 31 + self.assertEqual(cb1_observer[-1], 32) + self.assertEqual(cb2_observer[-1], 32) + output.remove_callback(callback1) + output.add_callback(callback3) + input.value = 41 + self.assertEqual(len(cb1_observer), 1) + self.assertEqual(cb2_observer[-1], 42) + self.assertEqual(cb3_observer[-1], 42) + + def test_removing_a_callback_multiple_times_doesn_t_interfere_with_other_callbacks( + self, + ): + input = InputCell(1) + output = ComputeCell( + [ + input, + ], + lambda inputs: inputs[0] + 1, + ) + cb1_observer = [] + cb2_observer = [] + callback1 = self.callback_factory(cb1_observer) + callback2 = self.callback_factory(cb2_observer) + output.add_callback(callback1) + output.add_callback(callback2) + output.remove_callback(callback1) + output.remove_callback(callback1) + output.remove_callback(callback1) + input.value = 2 + self.assertEqual(cb1_observer, []) + self.assertEqual(cb2_observer[-1], 3) + + def test_callbacks_should_only_be_called_once_even_if_multiple_dependencies_change( + self, + ): + input = InputCell(1) + plus_one = ComputeCell( + [ + input, + ], + lambda inputs: inputs[0] + 1, + ) + minus_one1 = ComputeCell( + [ + input, + ], + lambda inputs: inputs[0] - 1, + ) + minus_one2 = ComputeCell( + [ + minus_one1, + ], + lambda inputs: inputs[0] - 1, + ) + output = ComputeCell( + [ + plus_one, + minus_one2, + ], + lambda inputs: inputs[0] * inputs[1], + ) + cb1_observer = [] + callback1 = self.callback_factory(cb1_observer) + output.add_callback(callback1) + input.value = 4 + self.assertEqual(cb1_observer[-1], 10) + + def test_callbacks_should_not_be_called_if_dependencies_change_but_output_value_doesn_t_change( + self, + ): + input = InputCell(1) + plus_one = ComputeCell( + [ + input, + ], + lambda inputs: inputs[0] + 1, + ) + minus_one = ComputeCell( + [ + input, + ], + lambda inputs: inputs[0] - 1, + ) + always_two = ComputeCell( + [ + plus_one, + minus_one, + ], + lambda inputs: inputs[0] - inputs[1], + ) + cb1_observer = [] + callback1 = self.callback_factory(cb1_observer) + always_two.add_callback(callback1) + input.value = 2 + self.assertEqual(cb1_observer, []) + input.value = 3 + self.assertEqual(cb1_observer, []) + input.value = 4 + self.assertEqual(cb1_observer, []) + input.value = 5 + self.assertEqual(cb1_observer, []) + + # Utility functions. + def callback_factory(self, observer): + def callback(observer, value): + observer.append(value) + + return partial(callback, observer) diff --git a/crates/agent/src/tools/evals/streaming_edit_file.rs b/crates/agent/src/tools/evals/streaming_edit_file.rs new file mode 100644 index 0000000000000000000000000000000000000000..6a55517037e54ae4166cd22427201d9325ef0f76 --- /dev/null +++ b/crates/agent/src/tools/evals/streaming_edit_file.rs @@ -0,0 +1,1569 @@ +use crate::tools::streaming_edit_file_tool::*; +use crate::{ + AgentTool, ContextServerRegistry, EditFileTool, GrepTool, GrepToolInput, ListDirectoryTool, + ListDirectoryToolInput, ReadFileTool, ReadFileToolInput, StreamingEditFileTool, Template, + Templates, Thread, ToolCallEventStream, ToolInput, +}; +use Role::*; +use anyhow::{Context as _, Result}; +use client::{Client, UserStore}; +use fs::FakeFs; +use futures::{FutureExt, StreamExt, future::LocalBoxFuture}; +use gpui::{AppContext as _, AsyncApp, Entity, TestAppContext, UpdateGlobal as _}; +use http_client::StatusCode; +use language::language_settings::FormatOnSave; +use language_model::{ + LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, + LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, + LanguageModelRequestTool, LanguageModelToolResult, LanguageModelToolResultContent, + LanguageModelToolSchemaFormat, LanguageModelToolUse, LanguageModelToolUseId, MessageContent, + Role, SelectedModel, +}; +use project::Project; +use prompt_store::{ProjectContext, WorktreeContext}; +use rand::prelude::*; +use reqwest_client::ReqwestClient; +use serde::Serialize; +use serde_json::json; +use settings::SettingsStore; +use std::{ + fmt::{self, Display}, + path::{Path, PathBuf}, + str::FromStr, + sync::Arc, + time::Duration, +}; +use util::path; + +#[derive(Serialize)] +struct DiffJudgeTemplate { + diff: String, + assertions: &'static str, +} + +impl Template for DiffJudgeTemplate { + const TEMPLATE_NAME: &'static str = "diff_judge.hbs"; +} + +#[derive(Clone)] +struct EvalInput { + conversation: Vec, + input_file_path: PathBuf, + input_content: Option, + assertion: EvalAssertion, +} + +impl EvalInput { + fn new( + conversation: Vec, + input_file_path: impl Into, + input_content: Option, + assertion: EvalAssertion, + ) -> Self { + EvalInput { + conversation, + input_file_path: input_file_path.into(), + input_content, + assertion, + } + } +} + +#[derive(Clone)] +struct EvalSample { + text_before: String, + text_after: String, + tool_input: StreamingEditFileToolInput, + diff: String, +} + +trait AssertionFn: 'static + Send + Sync { + fn assert<'a>( + &'a self, + sample: &'a EvalSample, + judge_model: Arc, + cx: &'a mut TestAppContext, + ) -> LocalBoxFuture<'a, Result>; +} + +impl AssertionFn for F +where + F: 'static + + Send + + Sync + + AsyncFn( + &EvalSample, + Arc, + &mut TestAppContext, + ) -> Result, +{ + fn assert<'a>( + &'a self, + sample: &'a EvalSample, + judge_model: Arc, + cx: &'a mut TestAppContext, + ) -> LocalBoxFuture<'a, Result> { + (self)(sample, judge_model, cx).boxed_local() + } +} + +#[derive(Clone)] +struct EvalAssertion(Arc); + +impl EvalAssertion { + fn new(f: F) -> Self + where + F: 'static + + Send + + Sync + + AsyncFn( + &EvalSample, + Arc, + &mut TestAppContext, + ) -> Result, + { + EvalAssertion(Arc::new(f)) + } + + fn assert_eq(expected: impl Into) -> Self { + let expected = expected.into(); + Self::new(async move |sample, _judge, _cx| { + Ok(EvalAssertionOutcome { + score: if strip_empty_lines(&sample.text_after) == strip_empty_lines(&expected) { + 100 + } else { + 0 + }, + message: None, + }) + }) + } + + fn assert_diff_any(expected_diffs: Vec>) -> Self { + let expected_diffs: Vec = expected_diffs.into_iter().map(Into::into).collect(); + Self::new(async move |sample, _judge, _cx| { + let matches = expected_diffs.iter().any(|possible_diff| { + language::apply_diff_patch(&sample.text_before, possible_diff) + .map(|expected| { + strip_empty_lines(&expected) == strip_empty_lines(&sample.text_after) + }) + .unwrap_or(false) + }); + + Ok(EvalAssertionOutcome { + score: if matches { 100 } else { 0 }, + message: None, + }) + }) + } + + fn judge_diff(assertions: &'static str) -> Self { + Self::new(async move |sample, judge, cx| { + let prompt = DiffJudgeTemplate { + diff: sample.diff.clone(), + assertions, + } + .render(&Templates::new()) + .context("Failed to render diff judge template")?; + + let request = LanguageModelRequest { + messages: vec![LanguageModelRequestMessage { + role: Role::User, + content: vec![prompt.into()], + cache: false, + reasoning_details: None, + }], + thinking_allowed: true, + thinking_effort: judge + .default_effort_level() + .map(|effort_level| effort_level.value.to_string()), + ..Default::default() + }; + let mut response = retry_on_rate_limit(async || { + Ok(judge + .stream_completion_text(request.clone(), &cx.to_async()) + .await?) + }) + .await?; + let mut output = String::new(); + while let Some(chunk) = response.stream.next().await { + let chunk = chunk?; + output.push_str(&chunk); + } + + let re = regex::Regex::new(r"(\d+)") + .context("Failed to compile score regex")?; + if let Some(captures) = re.captures(&output) + && let Some(score_match) = captures.get(1) + { + let score = score_match.as_str().parse().unwrap_or(0); + return Ok(EvalAssertionOutcome { + score, + message: Some(output), + }); + } + + anyhow::bail!("No score found in response. Raw output: {output}"); + }) + } + + async fn run( + &self, + input: &EvalSample, + judge_model: Arc, + cx: &mut TestAppContext, + ) -> Result { + self.0.assert(input, judge_model, cx).await + } +} + +#[derive(Clone)] +struct StreamingEditEvalOutput { + sample: EvalSample, + assertion: EvalAssertionOutcome, +} + +impl Display for StreamingEditEvalOutput { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + writeln!(f, "Score: {:?}", self.assertion.score)?; + if let Some(message) = self.assertion.message.as_ref() { + writeln!(f, "Message: {}", message)?; + } + writeln!(f, "Diff:\n{}", self.sample.diff)?; + writeln!(f, "Tool Input:\n{:#?}", self.sample.tool_input)?; + Ok(()) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +struct EvalAssertionOutcome { + score: usize, + message: Option, +} + +struct StreamingEditToolTest { + fs: Arc, + project: Entity, + model: Arc, + judge_model: Arc, + model_thinking_effort: Option, +} + +impl StreamingEditToolTest { + async fn new(cx: &mut TestAppContext) -> Self { + cx.executor().allow_parking(); + + let fs = FakeFs::new(cx.executor()); + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + SettingsStore::update_global(cx, |store: &mut SettingsStore, cx| { + store.update_user_settings(cx, |settings| { + settings + .project + .all_languages + .defaults + .ensure_final_newline_on_save = Some(false); + settings.project.all_languages.defaults.format_on_save = + Some(FormatOnSave::Off); + }); + }); + + gpui_tokio::init(cx); + let http_client = Arc::new(ReqwestClient::user_agent("agent tests").unwrap()); + cx.set_http_client(http_client); + let client = Client::production(cx); + let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); + language_model::init(user_store.clone(), client.clone(), cx); + language_models::init(user_store, client, cx); + }); + + fs.insert_tree("/root", json!({})).await; + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let agent_model = SelectedModel::from_str( + &std::env::var("ZED_AGENT_MODEL") + .unwrap_or("anthropic/claude-sonnet-4-6-latest".into()), + ) + .unwrap(); + let judge_model = SelectedModel::from_str( + &std::env::var("ZED_JUDGE_MODEL") + .unwrap_or("anthropic/claude-sonnet-4-6-latest".into()), + ) + .unwrap(); + + let authenticate_provider_tasks = cx.update(|cx| { + LanguageModelRegistry::global(cx).update(cx, |registry, cx| { + registry + .providers() + .iter() + .map(|p| p.authenticate(cx)) + .collect::>() + }) + }); + let (model, judge_model) = cx + .update(|cx| { + cx.spawn(async move |cx| { + futures::future::join_all(authenticate_provider_tasks).await; + let model = Self::load_model(&agent_model, cx).await; + let judge_model = Self::load_model(&judge_model, cx).await; + (model.unwrap(), judge_model.unwrap()) + }) + }) + .await; + + let model_thinking_effort = model + .default_effort_level() + .map(|effort_level| effort_level.value.to_string()); + + Self { + fs, + project, + model, + judge_model, + model_thinking_effort, + } + } + + async fn load_model( + selected_model: &SelectedModel, + cx: &mut AsyncApp, + ) -> Result> { + cx.update(|cx| { + let registry = LanguageModelRegistry::read_global(cx); + let provider = registry + .provider(&selected_model.provider) + .expect("Provider not found"); + provider.authenticate(cx) + }) + .await?; + Ok(cx.update(|cx| { + let models = LanguageModelRegistry::read_global(cx); + models + .available_models(cx) + .find(|model| { + model.provider_id() == selected_model.provider + && model.id() == selected_model.model + }) + .unwrap_or_else(|| panic!("Model {} not found", selected_model.model.0)) + })) + } + + /// Build the tool definitions for the model, replacing `edit_file` with the + /// streaming edit file tool schema. In production the streaming tool is + /// exposed under the name `"edit_file"` (see `Thread::enabled_tools`), so + /// the model has never seen the name `"streaming_edit_file"`. + fn build_tools() -> Vec { + let mut tools: Vec = crate::built_in_tools() + .filter(|tool| tool.name != EditFileTool::NAME) + .collect(); + tools.push(LanguageModelRequestTool { + name: EditFileTool::NAME.to_string(), + description: StreamingEditFileTool::description().to_string(), + input_schema: StreamingEditFileTool::input_schema( + LanguageModelToolSchemaFormat::JsonSchema, + ) + .to_value(), + use_input_streaming: StreamingEditFileTool::supports_input_streaming(), + }); + tools + } + + async fn eval( + &self, + mut eval: EvalInput, + cx: &mut TestAppContext, + ) -> Result { + eval.conversation + .last_mut() + .context("Conversation must not be empty")? + .cache = true; + + // Populate the FakeFs so `resolve_path` / `entry_for_path` can find + // the file in the worktree. + if let Some(input_content) = eval.input_content.as_deref() { + let abs_path = Path::new("/root").join( + eval.input_file_path + .strip_prefix("root") + .unwrap_or(&eval.input_file_path), + ); + self.fs.insert_file(&abs_path, input_content.into()).await; + + // Wait for the worktree to pick up the new file. + cx.run_until_parked(); + } + + let tools = Self::build_tools(); + + let system_prompt = { + let worktrees = vec![WorktreeContext { + root_name: "root".to_string(), + abs_path: Path::new("/path/to/root").into(), + rules_file: None, + }]; + let project_context = ProjectContext::new(worktrees, Vec::default()); + let tool_names = tools + .iter() + .map(|tool| tool.name.clone().into()) + .collect::>(); + let template = crate::SystemPromptTemplate { + project: &project_context, + available_tools: tool_names, + model_name: None, + }; + let templates = Templates::new(); + template.render(&templates)? + }; + + let has_system_prompt = eval + .conversation + .first() + .is_some_and(|msg| msg.role == Role::System); + let messages = if has_system_prompt { + eval.conversation + } else { + [LanguageModelRequestMessage { + role: Role::System, + content: vec![MessageContent::Text(system_prompt)], + cache: true, + reasoning_details: None, + }] + .into_iter() + .chain(eval.conversation) + .collect::>() + }; + + let request = LanguageModelRequest { + messages, + tools, + thinking_allowed: true, + thinking_effort: self.model_thinking_effort.clone(), + ..Default::default() + }; + + // The model will call the tool as "edit_file" (the production-visible + // name), but the schema is from StreamingEditFileTool. + let tool_input = + retry_on_rate_limit(async || self.extract_tool_use(request.clone(), cx).await).await?; + + let language_registry = self + .project + .read_with(cx, |project, _cx| project.languages().clone()); + + let context_server_registry = cx + .new(|cx| ContextServerRegistry::new(self.project.read(cx).context_server_store(), cx)); + let thread = cx.new(|cx| { + Thread::new( + self.project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(self.model.clone()), + cx, + ) + }); + let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone()); + + let tool = Arc::new(StreamingEditFileTool::new( + self.project.clone(), + thread.downgrade(), + action_log, + language_registry, + )); + + let result = cx + .update(|cx| { + tool.clone().run( + ToolInput::resolved(tool_input.clone()), + ToolCallEventStream::test().0, + cx, + ) + }) + .await; + + let output = match result { + Ok(output) => output, + Err(output) => { + anyhow::bail!("Tool returned error: {}", output); + } + }; + + let StreamingEditFileToolOutput::Success { new_text, .. } = &output else { + anyhow::bail!("Tool returned error output: {}", output); + }; + + let sample = EvalSample { + tool_input, + diff: language::unified_diff( + eval.input_content.as_deref().unwrap_or_default(), + new_text, + ), + text_before: eval.input_content.unwrap_or_default(), + text_after: new_text.clone(), + }; + + let assertion = eval + .assertion + .run(&sample, self.judge_model.clone(), cx) + .await?; + + Ok(StreamingEditEvalOutput { assertion, sample }) + } + + /// Stream the model completion and extract the first complete tool use + /// whose name matches `EditFileTool::NAME` (the production-visible name + /// for the streaming edit tool), parsed as `StreamingEditFileToolInput`. + async fn extract_tool_use( + &self, + request: LanguageModelRequest, + cx: &mut TestAppContext, + ) -> Result { + let model = self.model.clone(); + let events = cx + .update(|cx| { + let async_cx = cx.to_async(); + cx.foreground_executor() + .spawn(async move { model.stream_completion(request, &async_cx).await }) + }) + .await + .map_err(|err| anyhow::anyhow!("completion error: {}", err))?; + + let mut streamed_text = String::new(); + let mut stop_reason = None; + let mut parse_errors = Vec::new(); + + let mut events = events.fuse(); + while let Some(event) = events.next().await { + match event { + Ok(LanguageModelCompletionEvent::ToolUse(tool_use)) + if tool_use.is_input_complete + && tool_use.name.as_ref() == EditFileTool::NAME => + { + let input: StreamingEditFileToolInput = serde_json::from_value(tool_use.input) + .context("Failed to parse tool input as StreamingEditFileToolInput")?; + return Ok(input); + } + Ok(LanguageModelCompletionEvent::Text(text)) => { + if streamed_text.len() < 2_000 { + streamed_text.push_str(&text); + } + } + Ok(LanguageModelCompletionEvent::Stop(reason)) => { + stop_reason = Some(reason); + } + Ok(LanguageModelCompletionEvent::ToolUseJsonParseError { + tool_name, + raw_input, + json_parse_error, + .. + }) if tool_name.as_ref() == EditFileTool::NAME => { + parse_errors.push(format!("{json_parse_error}\nRaw input:\n{raw_input:?}")); + } + Err(err) => { + return Err(anyhow::anyhow!("completion error: {}", err)); + } + _ => {} + } + } + + let streamed_text = streamed_text.trim(); + let streamed_text_suffix = if streamed_text.is_empty() { + String::new() + } else { + format!("\nStreamed text:\n{streamed_text}") + }; + let stop_reason_suffix = stop_reason + .map(|reason| format!("\nStop reason: {reason:?}")) + .unwrap_or_default(); + let parse_errors_suffix = if parse_errors.is_empty() { + String::new() + } else { + format!("\nTool parse errors:\n{}", parse_errors.join("\n")) + }; + + anyhow::bail!( + "Stream ended without an edit_file tool use{stop_reason_suffix}{parse_errors_suffix}{streamed_text_suffix}" + ) + } +} + +fn run_eval(eval: EvalInput) -> eval_utils::EvalOutput<()> { + let dispatcher = gpui::TestDispatcher::new(rand::random()); + let mut cx = TestAppContext::build(dispatcher, None); + let foreground_executor = cx.foreground_executor().clone(); + let result = foreground_executor.block_test(async { + let test = StreamingEditToolTest::new(&mut cx).await; + let result = test.eval(eval, &mut cx).await; + drop(test); + cx.run_until_parked(); + result + }); + cx.quit(); + match result { + Ok(output) => eval_utils::EvalOutput { + data: output.to_string(), + outcome: if output.assertion.score < 80 { + eval_utils::OutcomeKind::Failed + } else { + eval_utils::OutcomeKind::Passed + }, + metadata: (), + }, + Err(err) => eval_utils::EvalOutput { + data: format!("{err:?}"), + outcome: eval_utils::OutcomeKind::Error, + metadata: (), + }, + } +} + +fn message( + role: Role, + contents: impl IntoIterator, +) -> LanguageModelRequestMessage { + LanguageModelRequestMessage { + role, + content: contents.into_iter().collect(), + cache: false, + reasoning_details: None, + } +} + +fn text(text: impl Into) -> MessageContent { + MessageContent::Text(text.into()) +} + +fn lines(input: &str, range: std::ops::Range) -> String { + input + .lines() + .skip(range.start) + .take(range.len()) + .collect::>() + .join("\n") +} + +fn tool_use( + id: impl Into>, + name: impl Into>, + input: impl Serialize, +) -> MessageContent { + MessageContent::ToolUse(LanguageModelToolUse { + id: LanguageModelToolUseId::from(id.into()), + name: name.into(), + raw_input: serde_json::to_string_pretty(&input).unwrap(), + input: serde_json::to_value(input).unwrap(), + is_input_complete: true, + thought_signature: None, + }) +} + +fn tool_result( + id: impl Into>, + name: impl Into>, + result: impl Into>, +) -> MessageContent { + MessageContent::ToolResult(LanguageModelToolResult { + tool_use_id: LanguageModelToolUseId::from(id.into()), + tool_name: name.into(), + is_error: false, + content: LanguageModelToolResultContent::Text(result.into()), + output: None, + }) +} + +fn strip_empty_lines(text: &str) -> String { + text.lines() + .filter(|line| !line.trim().is_empty()) + .collect::>() + .join("\n") +} + +async fn retry_on_rate_limit(mut request: impl AsyncFnMut() -> Result) -> Result { + const MAX_RETRIES: usize = 20; + let mut attempt = 0; + + loop { + attempt += 1; + let response = request().await; + + if attempt >= MAX_RETRIES { + return response; + } + + let retry_delay = match &response { + Ok(_) => None, + Err(err) => match err.downcast_ref::() { + Some(err) => match &err { + LanguageModelCompletionError::RateLimitExceeded { retry_after, .. } + | LanguageModelCompletionError::ServerOverloaded { retry_after, .. } => { + Some(retry_after.unwrap_or(Duration::from_secs(5))) + } + LanguageModelCompletionError::UpstreamProviderError { + status, + retry_after, + .. + } => { + let should_retry = matches!( + *status, + StatusCode::TOO_MANY_REQUESTS | StatusCode::SERVICE_UNAVAILABLE + ) || status.as_u16() == 529; + + if should_retry { + Some(retry_after.unwrap_or(Duration::from_secs(5))) + } else { + None + } + } + LanguageModelCompletionError::ApiReadResponseError { .. } + | LanguageModelCompletionError::ApiInternalServerError { .. } + | LanguageModelCompletionError::HttpSend { .. } => { + Some(Duration::from_secs(2_u64.pow((attempt - 1) as u32).min(30))) + } + _ => None, + }, + _ => None, + }, + }; + + if let Some(retry_after) = retry_delay { + let jitter = retry_after.mul_f64(rand::rng().random_range(0.0..1.0)); + eprintln!("Attempt #{attempt}: Retry after {retry_after:?} + jitter of {jitter:?}"); + #[allow(clippy::disallowed_methods)] + smol::Timer::after(retry_after + jitter).await; + } else { + return response; + } + } +} + +#[test] +#[cfg_attr(not(feature = "unit-eval"), ignore)] +fn eval_delete_function() { + let input_file_path = "root/blame.rs"; + let input_file_content = include_str!("fixtures/delete_run_git_blame/before.rs"); + let output_file_content = include_str!("fixtures/delete_run_git_blame/after.rs"); + let possible_diffs = vec![ + language::unified_diff(input_file_content, output_file_content), + language::unified_diff( + input_file_content, + &output_file_content + .replace( + "const GIT_BLAME_NO_COMMIT_ERROR: &str = \"fatal: no such ref: HEAD\";\n", + "", + ) + .replace( + "const GIT_BLAME_NO_PATH: &str = \"fatal: no such path\";\n", + "", + ), + ), + ]; + + eval_utils::eval(100, 0.95, eval_utils::NoProcessor, move || { + run_eval(EvalInput::new( + vec![ + message( + User, + [text(indoc::formatdoc! {" + Read the `{input_file_path}` file and delete `run_git_blame`. Just that + one function, not its usages. + "})], + ), + message( + Assistant, + [tool_use( + "tool_1", + ReadFileTool::NAME, + ReadFileToolInput { + path: input_file_path.into(), + start_line: None, + end_line: None, + }, + )], + ), + message( + User, + [tool_result( + "tool_1", + ReadFileTool::NAME, + input_file_content, + )], + ), + ], + input_file_path, + Some(input_file_content.into()), + EvalAssertion::assert_diff_any(possible_diffs.clone()), + )) + }); +} + +#[test] +#[cfg_attr(not(feature = "unit-eval"), ignore)] +fn eval_extract_handle_command_output() { + let input_file_path = "root/blame.rs"; + let input_file_content = include_str!("fixtures/extract_handle_command_output/before.rs"); + let possible_diffs = vec![ + include_str!("fixtures/extract_handle_command_output/possible-01.diff"), + include_str!("fixtures/extract_handle_command_output/possible-02.diff"), + include_str!("fixtures/extract_handle_command_output/possible-03.diff"), + include_str!("fixtures/extract_handle_command_output/possible-04.diff"), + include_str!("fixtures/extract_handle_command_output/possible-05.diff"), + include_str!("fixtures/extract_handle_command_output/possible-06.diff"), + include_str!("fixtures/extract_handle_command_output/possible-07.diff"), + include_str!("fixtures/extract_handle_command_output/possible-08.diff"), + include_str!("fixtures/extract_handle_command_output/possible-09.diff"), + ]; + + eval_utils::eval(100, 0.95, eval_utils::NoProcessor, move || { + run_eval(EvalInput::new( + vec![ + message( + User, + [text(indoc::formatdoc! {" + Read the `{input_file_path}` file and extract a method in + the final stanza of `run_git_blame` to deal with command failures, + call it `handle_command_output` and take the std::process::Output as the only parameter. + Do not document the method and do not add any comments. + + Add it right next to `run_git_blame` and copy it verbatim from `run_git_blame`. + "})], + ), + message( + Assistant, + [tool_use( + "tool_1", + ReadFileTool::NAME, + ReadFileToolInput { + path: input_file_path.into(), + start_line: None, + end_line: None, + }, + )], + ), + message( + User, + [tool_result( + "tool_1", + ReadFileTool::NAME, + input_file_content, + )], + ), + ], + input_file_path, + Some(input_file_content.into()), + EvalAssertion::assert_diff_any(possible_diffs.clone()), + )) + }); +} + +#[test] +#[cfg_attr(not(feature = "unit-eval"), ignore)] +fn eval_translate_doc_comments() { + let input_file_path = "root/canvas.rs"; + let input_file_content = include_str!("fixtures/translate_doc_comments/before.rs"); + + eval_utils::eval(200, 1., eval_utils::NoProcessor, move || { + run_eval(EvalInput::new( + vec![ + message( + User, + [text(indoc::formatdoc! {" + Read the `{input_file_path}` file and edit it (without overwriting it), + translating all the doc comments to italian. + "})], + ), + message( + Assistant, + [tool_use( + "tool_1", + ReadFileTool::NAME, + ReadFileToolInput { + path: input_file_path.into(), + start_line: None, + end_line: None, + }, + )], + ), + message( + User, + [tool_result( + "tool_1", + ReadFileTool::NAME, + input_file_content, + )], + ), + ], + input_file_path, + Some(input_file_content.into()), + EvalAssertion::judge_diff("Doc comments were translated to Italian"), + )) + }); +} + +#[test] +#[cfg_attr(not(feature = "unit-eval"), ignore)] +fn eval_use_wasi_sdk_in_compile_parser_to_wasm() { + let input_file_path = "root/lib.rs"; + let input_file_content = + include_str!("fixtures/use_wasi_sdk_in_compile_parser_to_wasm/before.rs"); + + eval_utils::eval(100, 0.95, eval_utils::NoProcessor, move || { + run_eval(EvalInput::new( + vec![ + message( + User, + [text(indoc::formatdoc! {" + Read the `{input_file_path}` file and change `compile_parser_to_wasm` to use `wasi-sdk` instead of emscripten. + Use `ureq` to download the SDK for the current platform and architecture. + Extract the archive into a sibling of `lib` inside the `tree-sitter` directory in the cache_dir. + Compile the parser to wasm using the `bin/clang` executable (or `bin/clang.exe` on windows) + that's inside of the archive. + Don't re-download the SDK if that executable already exists. + + Use these clang flags: -fPIC -shared -Os -Wl,--export=tree_sitter_{{language_name}} + + Here are the available wasi-sdk assets: + - wasi-sdk-25.0-x86_64-macos.tar.gz + - wasi-sdk-25.0-arm64-macos.tar.gz + - wasi-sdk-25.0-x86_64-linux.tar.gz + - wasi-sdk-25.0-arm64-linux.tar.gz + - wasi-sdk-25.0-x86_64-linux.tar.gz + - wasi-sdk-25.0-arm64-linux.tar.gz + - wasi-sdk-25.0-x86_64-windows.tar.gz + "})], + ), + message( + Assistant, + [tool_use( + "tool_1", + ReadFileTool::NAME, + ReadFileToolInput { + path: input_file_path.into(), + start_line: Some(971), + end_line: Some(1050), + }, + )], + ), + message( + User, + [tool_result( + "tool_1", + ReadFileTool::NAME, + lines(input_file_content, 971..1050), + )], + ), + message( + Assistant, + [tool_use( + "tool_2", + ReadFileTool::NAME, + ReadFileToolInput { + path: input_file_path.into(), + start_line: Some(1050), + end_line: Some(1100), + }, + )], + ), + message( + User, + [tool_result( + "tool_2", + ReadFileTool::NAME, + lines(input_file_content, 1050..1100), + )], + ), + message( + Assistant, + [tool_use( + "tool_3", + ReadFileTool::NAME, + ReadFileToolInput { + path: input_file_path.into(), + start_line: Some(1100), + end_line: Some(1150), + }, + )], + ), + message( + User, + [tool_result( + "tool_3", + ReadFileTool::NAME, + lines(input_file_content, 1100..1150), + )], + ), + ], + input_file_path, + Some(input_file_content.into()), + EvalAssertion::judge_diff(indoc::indoc! {" + - The compile_parser_to_wasm method has been changed to use wasi-sdk + - ureq is used to download the SDK for current platform and architecture + "}), + )) + }); +} + +#[test] +#[cfg_attr(not(feature = "unit-eval"), ignore)] +fn eval_disable_cursor_blinking() { + let input_file_path = "root/editor.rs"; + let input_file_content = include_str!("fixtures/disable_cursor_blinking/before.rs"); + let possible_diffs = vec![ + include_str!("fixtures/disable_cursor_blinking/possible-01.diff"), + include_str!("fixtures/disable_cursor_blinking/possible-02.diff"), + include_str!("fixtures/disable_cursor_blinking/possible-03.diff"), + include_str!("fixtures/disable_cursor_blinking/possible-04.diff"), + ]; + + eval_utils::eval(100, 0.51, eval_utils::NoProcessor, move || { + run_eval(EvalInput::new( + vec![ + message(User, [text("Let's research how to cursor blinking works.")]), + message( + Assistant, + [tool_use( + "tool_1", + GrepTool::NAME, + GrepToolInput { + regex: "blink".into(), + include_pattern: None, + offset: 0, + case_sensitive: false, + }, + )], + ), + message( + User, + [tool_result( + "tool_1", + GrepTool::NAME, + [ + lines(input_file_content, 100..400), + lines(input_file_content, 800..1300), + lines(input_file_content, 1600..2000), + lines(input_file_content, 5000..5500), + lines(input_file_content, 8000..9000), + lines(input_file_content, 18455..18470), + lines(input_file_content, 20000..20500), + lines(input_file_content, 21000..21300), + ] + .join("Match found:\n\n"), + )], + ), + message( + User, + [text(indoc::indoc! {" + Comment out the lines that interact with the BlinkManager. + Keep the outer `update` blocks, but comments everything that's inside (including if statements). + Don't add additional comments. + "})], + ), + ], + input_file_path, + Some(input_file_content.into()), + EvalAssertion::assert_diff_any(possible_diffs.clone()), + )) + }); +} + +#[test] +#[cfg_attr(not(feature = "unit-eval"), ignore)] +fn eval_from_pixels_constructor() { + let input_file_path = "root/canvas.rs"; + let input_file_content = include_str!("fixtures/from_pixels_constructor/before.rs"); + + eval_utils::eval(100, 0.95, eval_utils::NoProcessor, move || { + run_eval(EvalInput::new( + vec![ + message( + User, + [text(indoc::indoc! {" + Introduce a new `from_pixels` constructor in Canvas and + also add tests for it in the same file. + "})], + ), + message( + Assistant, + [tool_use( + "tool_1", + ReadFileTool::NAME, + ReadFileToolInput { + path: input_file_path.into(), + start_line: None, + end_line: None, + }, + )], + ), + message( + User, + [tool_result( + "tool_1", + ReadFileTool::NAME, + input_file_content, + )], + ), + message( + Assistant, + [tool_use( + "tool_2", + GrepTool::NAME, + GrepToolInput { + regex: "mod\\s+tests".into(), + include_pattern: Some("font-kit/src/canvas.rs".into()), + offset: 0, + case_sensitive: false, + }, + )], + ), + message( + User, + [tool_result("tool_2", GrepTool::NAME, "No matches found")], + ), + message( + Assistant, + [tool_use( + "tool_3", + GrepTool::NAME, + GrepToolInput { + regex: "mod\\s+tests".into(), + include_pattern: Some("font-kit/src/**/*.rs".into()), + offset: 0, + case_sensitive: false, + }, + )], + ), + message( + User, + [tool_result("tool_3", GrepTool::NAME, "No matches found")], + ), + message( + Assistant, + [tool_use( + "tool_4", + GrepTool::NAME, + GrepToolInput { + regex: "#\\[test\\]".into(), + include_pattern: Some("font-kit/src/**/*.rs".into()), + offset: 0, + case_sensitive: false, + }, + )], + ), + message( + User, + [tool_result( + "tool_4", + GrepTool::NAME, + indoc::indoc! {" + Found 6 matches: + + ## Matches in font-kit/src/loaders/core_text.rs + + ### mod test › L926-936 + ``` + mod test { + use super::Font; + use crate::properties::{Stretch, Weight}; + + #[cfg(feature = \"source\")] + use crate::source::SystemSource; + + static TEST_FONT_POSTSCRIPT_NAME: &'static str = \"ArialMT\"; + + #[cfg(feature = \"source\")] + #[test] + ``` + + 55 lines remaining in ancestor node. Read the file to see all. + + ### mod test › L947-951 + ``` + } + + #[test] + fn test_core_text_to_css_font_weight() { + // Exact matches + ``` + + ### mod test › L959-963 + ``` + } + + #[test] + fn test_core_text_to_css_font_stretch() { + // Exact matches + ``` + + ## Matches in font-kit/src/loaders/freetype.rs + + ### mod test › L1238-1248 + ``` + mod test { + use crate::loaders::freetype::Font; + + static PCF_FONT_PATH: &str = \"resources/tests/times-roman-pcf/timR12.pcf\"; + static PCF_FONT_POSTSCRIPT_NAME: &str = \"Times-Roman\"; + + #[test] + fn get_pcf_postscript_name() { + let font = Font::from_path(PCF_FONT_PATH, 0).unwrap(); + assert_eq!(font.postscript_name().unwrap(), PCF_FONT_POSTSCRIPT_NAME); + } + ``` + + 1 lines remaining in ancestor node. Read the file to see all. + + ## Matches in font-kit/src/sources/core_text.rs + + ### mod test › L265-275 + ``` + mod test { + use crate::properties::{Stretch, Weight}; + + #[test] + fn test_css_to_core_text_font_weight() { + // Exact matches + assert_eq!(super::css_to_core_text_font_weight(Weight(100.0)), -0.7); + assert_eq!(super::css_to_core_text_font_weight(Weight(400.0)), 0.0); + assert_eq!(super::css_to_core_text_font_weight(Weight(700.0)), 0.4); + assert_eq!(super::css_to_core_text_font_weight(Weight(900.0)), 0.8); + + ``` + + 27 lines remaining in ancestor node. Read the file to see all. + + ### mod test › L278-282 + ``` + } + + #[test] + fn test_css_to_core_text_font_stretch() { + // Exact matches + ``` + "}, + )], + ), + ], + input_file_path, + Some(input_file_content.into()), + EvalAssertion::judge_diff(indoc::indoc! {" + - The diff contains a new `from_pixels` constructor + - The diff contains new tests for the `from_pixels` constructor + "}), + )) + }); +} + +#[test] +#[cfg_attr(not(feature = "unit-eval"), ignore)] +fn eval_zode() { + let input_file_path = "root/zode.py"; + let input_content = None; + + eval_utils::eval(50, 1., eval_utils::NoProcessor, move || { + run_eval(EvalInput::new( + vec![ + message(User, [text(include_str!("fixtures/zode/prompt.md"))]), + message( + Assistant, + [ + tool_use( + "tool_1", + ReadFileTool::NAME, + ReadFileToolInput { + path: "root/eval/react.py".into(), + start_line: None, + end_line: None, + }, + ), + tool_use( + "tool_2", + ReadFileTool::NAME, + ReadFileToolInput { + path: "root/eval/react_test.py".into(), + start_line: None, + end_line: None, + }, + ), + ], + ), + message( + User, + [ + tool_result( + "tool_1", + ReadFileTool::NAME, + include_str!("fixtures/zode/react.py"), + ), + tool_result( + "tool_2", + ReadFileTool::NAME, + include_str!("fixtures/zode/react_test.py"), + ), + ], + ), + ], + input_file_path, + input_content.clone(), + EvalAssertion::new(async move |sample, _, _cx| { + let invalid_starts = [' ', '`', '\n']; + let mut message = String::new(); + for start in invalid_starts { + if sample.text_after.starts_with(start) { + message.push_str(&format!("The sample starts with a {:?}\n", start)); + break; + } + } + message.pop(); + + if message.is_empty() { + Ok(EvalAssertionOutcome { + score: 100, + message: None, + }) + } else { + Ok(EvalAssertionOutcome { + score: 0, + message: Some(message), + }) + } + }), + )) + }); +} + +#[test] +#[cfg_attr(not(feature = "unit-eval"), ignore)] +fn eval_add_overwrite_test() { + let input_file_path = "root/action_log.rs"; + let input_file_content = include_str!("fixtures/add_overwrite_test/before.rs"); + + eval_utils::eval(200, 0.5, eval_utils::NoProcessor, move || { + run_eval(EvalInput::new( + vec![ + message( + User, + [text(indoc::indoc! {" + Introduce a new test in `action_log.rs` to test overwriting a file. + That is, a file already exists, but we call `buffer_created` as if the file were new. + Take inspiration from all the other tests in the file. + "})], + ), + message( + Assistant, + [tool_use( + "tool_1", + ReadFileTool::NAME, + ReadFileToolInput { + path: input_file_path.into(), + start_line: None, + end_line: None, + }, + )], + ), + message( + User, + [tool_result( + "tool_1", + ReadFileTool::NAME, + indoc::indoc! {" + pub struct ActionLog [L13-20] + tracked_buffers [L15] + edited_since_project_diagnostics_check [L17] + project [L19] + impl ActionLog [L22-498] + pub fn new [L24-30] + pub fn project [L32-34] + pub fn checked_project_diagnostics [L37-39] + pub fn has_edited_files_since_project_diagnostics_check [L42-44] + fn track_buffer_internal [L46-101] + fn handle_buffer_event [L103-116] + fn handle_buffer_edited [L118-123] + fn handle_buffer_file_changed [L125-158] + async fn maintain_diff [L160-264] + pub fn buffer_read [L267-269] + pub fn buffer_created [L272-276] + pub fn buffer_edited [L279-287] + pub fn will_delete_buffer [L289-304] + pub fn keep_edits_in_range [L306-364] + pub fn reject_edits_in_ranges [L366-459] + pub fn keep_all_edits [L461-473] + pub fn changed_buffers [L476-482] + pub fn stale_buffers [L485-497] + fn apply_non_conflicting_edits [L500-561] + fn diff_snapshots [L563-585] + fn point_to_row_edit [L587-614] + enum ChangeAuthor [L617-620] + User [L618] + Agent [L619] + enum TrackedBufferStatus [L623-627] + Created [L624] + Modified [L625] + Deleted [L626] + struct TrackedBuffer [L629-641] + buffer [L630] + base_text [L631] + unreviewed_changes [L632] + status [L633] + version [L634] + diff [L635] + snapshot [L636] + diff_update [L637] + _open_lsp_handle [L638] + _maintain_diff [L639] + _subscription [L640] + impl TrackedBuffer [L643-657] + fn has_changes [L644-650] + fn schedule_diff_update [L652-656] + pub struct ChangedBuffer [L659-661] + pub diff [L660] + mod tests [L664-1574] + fn init_logger [L678-682] + fn init_test [L684-691] + async fn test_keep_edits [L694-769] + async fn test_deletions [L772-854] + async fn test_overlapping_user_edits [L857-951] + async fn test_creating_files [L954-1010] + async fn test_deleting_files [L1013-1120] + async fn test_reject_edits [L1123-1255] + async fn test_reject_multiple_edits [L1258-1331] + async fn test_reject_deleted_file [L1334-1388] + async fn test_reject_created_file [L1391-1443] + async fn test_random_diffs [L1446-1535] + fn quiesce [L1510-1534] + struct HunkStatus [L1538-1542] + range [L1539] + diff_status [L1540] + old_text [L1541] + fn unreviewed_hunks [L1544-1573] + + Showing symbols 1-69 (total symbols: 69) + + Using the line numbers in this outline, you can call this tool again while specifying + the start_line and end_line fields to see the implementations of symbols in the outline. + "}, + )], + ), + message( + Assistant, + [ + text( + "Let's look at the existing test cases that are related to file creation to understand the pattern:", + ), + tool_use( + "tool_2", + ReadFileTool::NAME, + ReadFileToolInput { + path: input_file_path.into(), + start_line: Some(953), + end_line: Some(1010), + }, + ), + ], + ), + message( + User, + [tool_result( + "tool_2", + ReadFileTool::NAME, + lines(input_file_content, 953..1010), + )], + ), + message( + Assistant, + [ + text( + "Now let's look at another relevant test to understand how they're structured:", + ), + tool_use( + "tool_3", + ReadFileTool::NAME, + ReadFileToolInput { + path: input_file_path.into(), + start_line: Some(1012), + end_line: Some(1120), + }, + ), + ], + ), + message( + User, + [tool_result( + "tool_3", + ReadFileTool::NAME, + lines(input_file_content, 1012..1120), + )], + ), + message( + Assistant, + [ + text("Now let's look at how `buffer_created` is implemented:"), + tool_use( + "tool_4", + ReadFileTool::NAME, + ReadFileToolInput { + path: input_file_path.into(), + start_line: Some(271), + end_line: Some(276), + }, + ), + ], + ), + message( + User, + [tool_result( + "tool_4", + ReadFileTool::NAME, + lines(input_file_content, 271..276), + )], + ), + ], + input_file_path, + Some(input_file_content.into()), + EvalAssertion::judge_diff( + "A new test for overwritten files was created, without changing any previous test", + ), + )) + }); +} + +#[test] +#[cfg_attr(not(feature = "unit-eval"), ignore)] +fn eval_create_empty_file() { + let input_file_path = "root/TODO3"; + let input_file_content = None; + let expected_output_content = String::new(); + + eval_utils::eval(100, 0.99, eval_utils::NoProcessor, move || { + run_eval(EvalInput::new( + vec![ + message(User, [text("Create a second empty todo file ")]), + message( + Assistant, + [ + text(indoc::formatdoc! {" + I'll help you create a second empty todo file. + First, let me examine the project structure to see if there's already a todo file, which will help me determine the appropriate name and location for the second one. + "}), + tool_use( + "toolu_01GAF8TtsgpjKxCr8fgQLDgR", + ListDirectoryTool::NAME, + ListDirectoryToolInput { + path: "root".to_string(), + }, + ), + ], + ), + message( + User, + [tool_result( + "toolu_01GAF8TtsgpjKxCr8fgQLDgR", + ListDirectoryTool::NAME, + "root/TODO\nroot/TODO2\nroot/new.txt\n", + )], + ), + ], + input_file_path, + input_file_content.clone(), + EvalAssertion::assert_eq(expected_output_content.clone()), + )) + }); +} diff --git a/crates/agent/src/tools/list_directory_tool.rs b/crates/agent/src/tools/list_directory_tool.rs index 7769669222631f7a2a4bd9de1e0d81a68665a816..7abbe1ed4c488210b9079e59765dddc8d5208bed 100644 --- a/crates/agent/src/tools/list_directory_tool.rs +++ b/crates/agent/src/tools/list_directory_tool.rs @@ -848,7 +848,10 @@ mod tests { ); auth.response - .send(acp::PermissionOptionId::new("allow").into()) + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) .unwrap(); let result = task.await; diff --git a/crates/agent/src/tools/move_path_tool.rs b/crates/agent/src/tools/move_path_tool.rs index ab5637c26d250b5866ebdc015ab6fce294adc7e7..147947bb67ec646c38b51f37dd75779ed78ec85b 100644 --- a/crates/agent/src/tools/move_path_tool.rs +++ b/crates/agent/src/tools/move_path_tool.rs @@ -273,7 +273,10 @@ mod tests { ); auth.response - .send(acp::PermissionOptionId::new("allow").into()) + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) .unwrap(); let result = task.await; @@ -379,7 +382,10 @@ mod tests { ); auth.response - .send(acp::PermissionOptionId::new("allow").into()) + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) .unwrap(); assert!( diff --git a/crates/agent/src/tools/read_file_tool.rs b/crates/agent/src/tools/read_file_tool.rs index ef33d7d5b9d0f04783849ebd681badd04b7df052..093a8580892cfc4cec0a061bcc10717b28c608f2 100644 --- a/crates/agent/src/tools/read_file_tool.rs +++ b/crates/agent/src/tools/read_file_tool.rs @@ -896,7 +896,10 @@ mod test { ); authorization .response - .send(acp::PermissionOptionId::new("allow").into()) + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) .unwrap(); let result = read_task.await; @@ -1185,7 +1188,10 @@ mod test { ); auth.response - .send(acp::PermissionOptionId::new("allow").into()) + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) .unwrap(); let result = task.await; diff --git a/crates/agent/src/tools/restore_file_from_disk_tool.rs b/crates/agent/src/tools/restore_file_from_disk_tool.rs index ffe886b73c217e7afe4c5a8754b12d15be4b9b0d..9273ea5b8bb041e0ea53f3ea72b94b46e5a7e294 100644 --- a/crates/agent/src/tools/restore_file_from_disk_tool.rs +++ b/crates/agent/src/tools/restore_file_from_disk_tool.rs @@ -523,7 +523,10 @@ mod tests { ); auth.response - .send(acp::PermissionOptionId::new("allow").into()) + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) .unwrap(); let _result = task.await; @@ -651,7 +654,10 @@ mod tests { ); auth.response - .send(acp::PermissionOptionId::new("allow").into()) + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) .unwrap(); assert!( diff --git a/crates/agent/src/tools/save_file_tool.rs b/crates/agent/src/tools/save_file_tool.rs index 3f741d2eea7794111e039dcb981a5239d96b7b65..c6a1cd79db65127164fe66f966029b58a366da7f 100644 --- a/crates/agent/src/tools/save_file_tool.rs +++ b/crates/agent/src/tools/save_file_tool.rs @@ -518,7 +518,10 @@ mod tests { ); auth.response - .send(acp::PermissionOptionId::new("allow").into()) + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) .unwrap(); let _result = task.await; @@ -646,7 +649,10 @@ mod tests { ); auth.response - .send(acp::PermissionOptionId::new("allow").into()) + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) .unwrap(); assert!( @@ -727,7 +733,10 @@ mod tests { let auth = event_rx.expect_authorization().await; auth.response - .send(acp::PermissionOptionId::new("deny").into()) + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("deny"), + acp::PermissionOptionKind::RejectOnce, + )) .unwrap(); let output = task.await.unwrap(); diff --git a/crates/agent/src/tools/streaming_edit_file_tool.rs b/crates/agent/src/tools/streaming_edit_file_tool.rs index 9e7a7bbf1f287a8791591f3ae80a8731802eda42..88ec1e67787ad6efbeaa46b83b9034a24b10d3db 100644 --- a/crates/agent/src/tools/streaming_edit_file_tool.rs +++ b/crates/agent/src/tools/streaming_edit_file_tool.rs @@ -22,7 +22,10 @@ use language_model::LanguageModelToolResultContent; use project::lsp_store::{FormatTrigger, LspFormatTarget}; use project::{AgentLocation, Project, ProjectPath}; use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; +use serde::{ + Deserialize, Deserializer, Serialize, + de::{DeserializeOwned, Error as _}, +}; use std::ops::Range; use std::path::PathBuf; use std::sync::Arc; @@ -89,7 +92,11 @@ pub struct StreamingEditFileToolInput { /// List of edit operations to apply sequentially (required for 'edit' mode). /// Each edit finds `old_text` in the file and replaces it with `new_text`. - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_optional_vec_or_json_string" + )] pub edits: Option>, } @@ -104,12 +111,13 @@ pub enum StreamingEditFileMode { } /// A single edit operation that replaces old text with new text +/// Properly escape all text fields as valid JSON strings. +/// Remember to escape special characters like newlines (`\n`) and quotes (`"`) in JSON strings. #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] pub struct Edit { /// The exact text to find in the file. This will be matched using fuzzy matching /// to handle minor differences in whitespace or formatting. /// - /// Always include complete lines. Do not start or end mid-line. /// Be minimal with replacements: /// - For unique lines, include only those lines /// - For non-unique lines, include enough context to identify them @@ -128,7 +136,7 @@ struct StreamingEditFileToolPartialInput { mode: Option, #[serde(default)] content: Option, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_optional_vec_or_json_string")] edits: Option>, } @@ -140,6 +148,33 @@ pub struct PartialEdit { pub new_text: Option, } +/// Sometimes the model responds with a stringified JSON array of edits (`"[...]"`) instead of a regular array (`[...]`) +fn deserialize_optional_vec_or_json_string<'de, T, D>( + deserializer: D, +) -> Result>, D::Error> +where + T: DeserializeOwned, + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum VecOrJsonString { + Vec(Vec), + String(String), + } + + let value = Option::>::deserialize(deserializer)?; + match value { + None => Ok(None), + Some(VecOrJsonString::Vec(items)) => Ok(Some(items)), + Some(VecOrJsonString::String(string)) => serde_json::from_str::>(&string) + .map(Some) + .map_err(|error| { + D::Error::custom(format!("failed to parse stringified edits array: {error}")) + }), + } +} + #[derive(Debug, Serialize, Deserialize)] #[serde(untagged)] pub enum StreamingEditFileToolOutput { @@ -592,11 +627,7 @@ impl EditSession { } let format_on_save_enabled = self.buffer.read_with(cx, |buffer, cx| { - let settings = language_settings::language_settings( - buffer.language().map(|l| l.name()), - buffer.file(), - cx, - ); + let settings = language_settings::LanguageSettings::for_buffer(buffer, cx); settings.format_on_save != FormatOnSave::Off }); @@ -2581,7 +2612,10 @@ mod tests { event .response - .send(acp::PermissionOptionId::new("allow").into()) + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) .unwrap(); authorize_task.await.unwrap(); } @@ -3670,6 +3704,35 @@ mod tests { assert_eq!(new_text, "HELLO\nWORLD\nfoo\n"); } + #[gpui::test] + async fn test_streaming_final_input_stringified_edits_succeeds(cx: &mut TestAppContext) { + let (tool, _project, _action_log, _fs, _thread) = + setup_test(cx, json!({"file.txt": "hello\nworld\n"})).await; + let (sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + + sender.send_partial(json!({ + "display_description": "Edit", + "path": "root/file.txt", + "mode": "edit" + })); + cx.run_until_parked(); + + sender.send_final(json!({ + "display_description": "Edit", + "path": "root/file.txt", + "mode": "edit", + "edits": "[{\"old_text\": \"hello\\nworld\", \"new_text\": \"HELLO\\nWORLD\"}]" + })); + + let result = task.await; + let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "HELLO\nWORLD\n"); + } + // Verifies that after streaming_edit_file_tool edits a file, the action log // reports changed buffers so that the Accept All / Reject All review UI appears. #[gpui::test] @@ -3854,6 +3917,97 @@ mod tests { assert_eq!(new_text, "new_content"); } + #[gpui::test] + async fn test_streaming_edit_partial_last_line(cx: &mut TestAppContext) { + let file_content = indoc::indoc! {r#" + fn on_query_change(&mut self, cx: &mut Context) { + self.filter(cx); + } + + + + fn render_search(&self, cx: &mut Context) -> Div { + div() + } + "#} + .to_string(); + + let (tool, _project, _action_log, _fs, _thread) = + setup_test(cx, json!({"file.rs": file_content})).await; + + // The model sends old_text with a PARTIAL last line. + let old_text = "}\n\n\n\nfn render_search"; + let new_text = "}\n\nfn render_search"; + + let (sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + + sender.send_final(json!({ + "display_description": "Remove extra blank lines", + "path": "root/file.rs", + "mode": "edit", + "edits": [{"old_text": old_text, "new_text": new_text}] + })); + + let result = task.await; + let StreamingEditFileToolOutput::Success { + new_text: final_text, + .. + } = result.unwrap() + else { + panic!("expected success"); + }; + + // The edit should reduce 3 blank lines to 1 blank line before + // fn render_search, without duplicating the function signature. + let expected = file_content.replace("}\n\n\n\nfn render_search", "}\n\nfn render_search"); + pretty_assertions::assert_eq!( + final_text, + expected, + "Edit should only remove blank lines before render_search" + ); + } + + #[gpui::test] + async fn test_streaming_edit_preserves_blank_line_after_trailing_newline_replacement( + cx: &mut TestAppContext, + ) { + let file_content = "before\ntarget\n\nafter\n"; + let old_text = "target\n"; + let new_text = "one\ntwo\ntarget\n"; + let expected = "before\none\ntwo\ntarget\n\nafter\n"; + + let (tool, _project, _action_log, _fs, _thread) = + setup_test(cx, json!({"file.rs": file_content})).await; + let (sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + + sender.send_final(json!({ + "display_description": "description", + "path": "root/file.rs", + "mode": "edit", + "edits": [{"old_text": old_text, "new_text": new_text}] + })); + + let result = task.await; + + let StreamingEditFileToolOutput::Success { + new_text: final_text, + .. + } = result.unwrap() + else { + panic!("expected success"); + }; + + pretty_assertions::assert_eq!( + final_text, + expected, + "Edit should preserve a single blank line before test_after" + ); + } + #[gpui::test] async fn test_streaming_reject_created_file_deletes_it(cx: &mut TestAppContext) { let (tool, _project, action_log, fs, _thread) = setup_test(cx, json!({"dir": {}})).await; diff --git a/crates/agent/src/tools/tool_edit_parser.rs b/crates/agent/src/tools/tool_edit_parser.rs index 86259db916f49c07bbecc63625a93a9ebb955539..86f249ff34eb13b43209331227f624d740ab33af 100644 --- a/crates/agent/src/tools/tool_edit_parser.rs +++ b/crates/agent/src/tools/tool_edit_parser.rs @@ -78,7 +78,7 @@ impl ToolEditParser { if partial.new_text.is_some() { // new_text appeared, so old_text is done — emit everything. let start = state.old_text_emitted_len.min(old_text.len()); - let chunk = old_text[start..].to_string(); + let chunk = normalize_done_chunk(old_text[start..].to_string()); state.old_text_done = true; state.old_text_emitted_len = old_text.len(); events.push(ToolEditEvent::OldTextChunk { @@ -87,7 +87,8 @@ impl ToolEditParser { done: true, }); } else { - let safe_end = safe_emit_end(old_text); + let safe_end = safe_emit_end_for_edit_text(old_text); + if safe_end > state.old_text_emitted_len { let chunk = old_text[state.old_text_emitted_len..safe_end].to_string(); state.old_text_emitted_len = safe_end; @@ -104,7 +105,8 @@ impl ToolEditParser { if let Some(new_text) = &partial.new_text && !state.new_text_done { - let safe_end = safe_emit_end(new_text); + let safe_end = safe_emit_end_for_edit_text(new_text); + if safe_end > state.new_text_emitted_len { let chunk = new_text[state.new_text_emitted_len..safe_end].to_string(); state.new_text_emitted_len = safe_end; @@ -160,7 +162,7 @@ impl ToolEditParser { if !state.old_text_done { let start = state.old_text_emitted_len.min(edit.old_text.len()); - let chunk = edit.old_text[start..].to_string(); + let chunk = normalize_done_chunk(edit.old_text[start..].to_string()); state.old_text_done = true; state.old_text_emitted_len = edit.old_text.len(); events.push(ToolEditEvent::OldTextChunk { @@ -172,7 +174,7 @@ impl ToolEditParser { if !state.new_text_done { let start = state.new_text_emitted_len.min(edit.new_text.len()); - let chunk = edit.new_text[start..].to_string(); + let chunk = normalize_done_chunk(edit.new_text[start..].to_string()); state.new_text_done = true; state.new_text_emitted_len = edit.new_text.len(); events.push(ToolEditEvent::NewTextChunk { @@ -252,6 +254,22 @@ fn safe_emit_end(text: &str) -> usize { } } +fn safe_emit_end_for_edit_text(text: &str) -> usize { + let safe_end = safe_emit_end(text); + if safe_end > 0 && text.as_bytes()[safe_end - 1] == b'\n' { + safe_end - 1 + } else { + safe_end + } +} + +fn normalize_done_chunk(mut chunk: String) -> String { + if chunk.ends_with('\n') { + chunk.pop(); + } + chunk +} + #[cfg(test)] mod tests { use super::*; @@ -337,6 +355,69 @@ mod tests { ); } + #[test] + fn test_done_chunks_strip_trailing_newline() { + let mut parser = ToolEditParser::default(); + + let events = parser.finalize_edits(&[Edit { + old_text: "before\n".into(), + new_text: "after\n".into(), + }]); + assert_eq!( + events.as_slice(), + &[ + ToolEditEvent::OldTextChunk { + edit_index: 0, + chunk: "before".into(), + done: true, + }, + ToolEditEvent::NewTextChunk { + edit_index: 0, + chunk: "after".into(), + done: true, + }, + ] + ); + } + + #[test] + fn test_partial_edit_chunks_hold_back_trailing_newline() { + let mut parser = ToolEditParser::default(); + + let events = parser.push_edits(&[PartialEdit { + old_text: Some("before\n".into()), + new_text: Some("after\n".into()), + }]); + assert_eq!( + events.as_slice(), + &[ + ToolEditEvent::OldTextChunk { + edit_index: 0, + chunk: "before".into(), + done: true, + }, + ToolEditEvent::NewTextChunk { + edit_index: 0, + chunk: "after".into(), + done: false, + }, + ] + ); + + let events = parser.finalize_edits(&[Edit { + old_text: "before\n".into(), + new_text: "after\n".into(), + }]); + assert_eq!( + events.as_slice(), + &[ToolEditEvent::NewTextChunk { + edit_index: 0, + chunk: "".into(), + done: true, + }] + ); + } + #[test] fn test_multiple_edits_sequential() { let mut parser = ToolEditParser::default(); @@ -858,22 +939,16 @@ mod tests { ); // Next partial: the fixer corrects the escape to \n. - // The held-back byte was wrong, but we never emitted it. Now the - // correct newline at that position is emitted normally. + // Because edit text also holds back a trailing newline, nothing new + // is emitted yet. let events = parser.push_edits(&[PartialEdit { old_text: Some("hello,\n".into()), new_text: None, }]); - assert_eq!( - events.as_slice(), - &[ToolEditEvent::OldTextChunk { - edit_index: 0, - chunk: "\n".into(), - done: false, - }] - ); + assert!(events.is_empty()); - // Continue normally. + // Continue normally. The held-back newline is emitted together with the + // next content once it is no longer trailing. let events = parser.push_edits(&[PartialEdit { old_text: Some("hello,\nworld".into()), new_text: None, @@ -882,7 +957,7 @@ mod tests { events.as_slice(), &[ToolEditEvent::OldTextChunk { edit_index: 0, - chunk: "world".into(), + chunk: "\nworld".into(), done: false, }] ); @@ -919,7 +994,7 @@ mod tests { }, ToolEditEvent::NewTextChunk { edit_index: 0, - chunk: "LINE1\n".into(), + chunk: "LINE1".into(), done: false, }, ] @@ -933,7 +1008,7 @@ mod tests { events.as_slice(), &[ToolEditEvent::NewTextChunk { edit_index: 0, - chunk: "LINE2\nLINE3".into(), + chunk: "\nLINE2\nLINE3".into(), done: false, }] ); diff --git a/crates/agent/src/tools/update_plan_tool.rs b/crates/agent/src/tools/update_plan_tool.rs index 9fdc5a865dfb5cd2a18e3f24b3f7544b397588d3..8d45f8aad42a8cb10b3164212e1cde2b0104bdc2 100644 --- a/crates/agent/src/tools/update_plan_tool.rs +++ b/crates/agent/src/tools/update_plan_tool.rs @@ -27,45 +27,27 @@ impl From for acp::PlanEntryStatus { } } -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)] -#[serde(rename_all = "snake_case")] -#[schemars(inline)] -pub enum PlanEntryPriority { - High, - #[default] - Medium, - Low, -} - -impl From for acp::PlanEntryPriority { - fn from(value: PlanEntryPriority) -> Self { - match value { - PlanEntryPriority::High => acp::PlanEntryPriority::High, - PlanEntryPriority::Medium => acp::PlanEntryPriority::Medium, - PlanEntryPriority::Low => acp::PlanEntryPriority::Low, - } - } -} - #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] pub struct PlanItem { /// Human-readable description of what this task aims to accomplish. pub step: String, /// The current status of this task. pub status: PlanEntryStatus, - /// The relative importance of this task. Defaults to medium when omitted. - #[serde(default)] - pub priority: PlanEntryPriority, } impl From for acp::PlanEntry { fn from(value: PlanItem) -> Self { - acp::PlanEntry::new(value.step, value.priority.into(), value.status.into()) + acp::PlanEntry::new( + value.step, + acp::PlanEntryPriority::Medium, + value.status.into(), + ) } } /// Updates the task plan. -/// Provide a list of plan entries, each with step, status, and optional priority. +/// +/// Provide a list of plan entries, each with a step and status. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] pub struct UpdatePlanToolInput { /// The list of plan entries and their current statuses. @@ -144,17 +126,14 @@ mod tests { PlanItem { step: "Inspect the existing tool wiring".to_string(), status: PlanEntryStatus::Completed, - priority: PlanEntryPriority::High, }, PlanItem { step: "Implement the update_plan tool".to_string(), status: PlanEntryStatus::InProgress, - priority: PlanEntryPriority::Medium, }, PlanItem { step: "Add tests".to_string(), status: PlanEntryStatus::Pending, - priority: PlanEntryPriority::Low, }, ], } @@ -179,7 +158,7 @@ mod tests { acp::Plan::new(vec![ acp::PlanEntry::new( "Inspect the existing tool wiring", - acp::PlanEntryPriority::High, + acp::PlanEntryPriority::Medium, acp::PlanEntryStatus::Completed, ), acp::PlanEntry::new( @@ -189,7 +168,7 @@ mod tests { ), acp::PlanEntry::new( "Add tests", - acp::PlanEntryPriority::Low, + acp::PlanEntryPriority::Medium, acp::PlanEntryStatus::Pending, ), ]) @@ -214,7 +193,7 @@ mod tests { acp::Plan::new(vec![ acp::PlanEntry::new( "Inspect the existing tool wiring", - acp::PlanEntryPriority::High, + acp::PlanEntryPriority::Medium, acp::PlanEntryStatus::Completed, ), acp::PlanEntry::new( @@ -224,53 +203,8 @@ mod tests { ), acp::PlanEntry::new( "Add tests", - acp::PlanEntryPriority::Low, - acp::PlanEntryStatus::Pending, - ), - ]) - ); - } - - #[gpui::test] - async fn test_run_defaults_priority_to_medium(cx: &mut TestAppContext) { - let tool = Arc::new(UpdatePlanTool); - let (event_stream, mut event_rx) = ToolCallEventStream::test(); - - let input = UpdatePlanToolInput { - plan: vec![ - PlanItem { - step: "First".to_string(), - status: PlanEntryStatus::InProgress, - priority: PlanEntryPriority::default(), - }, - PlanItem { - step: "Second".to_string(), - status: PlanEntryStatus::InProgress, - priority: PlanEntryPriority::default(), - }, - ], - }; - - let result = cx - .update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx)) - .await - .expect("tool should succeed"); - - assert_eq!(result, "Plan updated".to_string()); - - let plan = event_rx.expect_plan().await; - assert_eq!( - plan, - acp::Plan::new(vec![ - acp::PlanEntry::new( - "First", acp::PlanEntryPriority::Medium, - acp::PlanEntryStatus::InProgress, - ), - acp::PlanEntry::new( - "Second", - acp::PlanEntryPriority::Medium, - acp::PlanEntryStatus::InProgress, + acp::PlanEntryStatus::Pending, ), ]) ); diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index f7b6a59a63b02028a8b30c905c92b82805a52b33..5f452bc9c0e2e9c2322042583295894a5866b053 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -203,11 +203,15 @@ impl AcpConnection { builder.build_std_command(Some(command.path.display().to_string()), &command.args); child.envs(command.env.iter().flatten()); if let Some(cwd) = project.update(cx, |project, cx| { - project - .default_path_list(cx) - .ordered_paths() - .next() - .cloned() + if project.is_local() { + project + .default_path_list(cx) + .ordered_paths() + .next() + .cloned() + } else { + None + } }) { child.current_dir(cwd); } diff --git a/crates/agent_servers/src/e2e_tests.rs b/crates/agent_servers/src/e2e_tests.rs index 93c37b5e258c5342be2c02eb3762bd775e22d001..956d106df2a260bd2eb31c14f4f1f1705bf74cd6 100644 --- a/crates/agent_servers/src/e2e_tests.rs +++ b/crates/agent_servers/src/e2e_tests.rs @@ -208,8 +208,10 @@ pub async fn test_tool_call_with_permission( thread.update(cx, |thread, cx| { thread.authorize_tool_call( tool_call_id, - allow_option_id.into(), - acp::PermissionOptionKind::AllowOnce, + acp_thread::SelectedPermissionOutcome::new( + allow_option_id, + acp::PermissionOptionKind::AllowOnce, + ), cx, ); diff --git a/crates/agent_settings/Cargo.toml b/crates/agent_settings/Cargo.toml index 15f35a931dedad303c46895c487655b9ddbc7496..b2db5677dcfdc0994e7ce7a03c9c1dd850eb8514 100644 --- a/crates/agent_settings/Cargo.toml +++ b/crates/agent_settings/Cargo.toml @@ -30,6 +30,7 @@ util.workspace = true [dev-dependencies] fs.workspace = true gpui = { workspace = true, features = ["test-support"] } +paths.workspace = true serde_json_lenient.workspace = true serde_json.workspace = true diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs index d5d4f16eb742a92f6abf8081c43709f161ef4038..7f51bd8ea5b9b8864663fbf9dc95beedb643d480 100644 --- a/crates/agent_settings/src/agent_settings.rs +++ b/crates/agent_settings/src/agent_settings.rs @@ -5,14 +5,17 @@ use std::sync::{Arc, LazyLock}; use agent_client_protocol::ModelId; use collections::{HashSet, IndexMap}; +use fs::Fs; 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, - NewThreadLocation, NotifyWhenAgentWaiting, RegisterSetting, Settings, ToolPermissionMode, + DefaultAgentView, DockPosition, DockSide, LanguageModelParameters, LanguageModelSelection, + NewThreadLocation, NotifyWhenAgentWaiting, RegisterSetting, Settings, SettingsContent, + SettingsStore, SidebarDockPosition, SidebarSide, ThinkingBlockDisplay, ToolPermissionMode, + update_settings_file, }; pub use crate::agent_profile::*; @@ -21,11 +24,134 @@ pub const SUMMARIZE_THREAD_PROMPT: &str = include_str!("prompts/summarize_thread pub const SUMMARIZE_THREAD_DETAILED_PROMPT: &str = include_str!("prompts/summarize_thread_detailed_prompt.txt"); +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PanelLayout { + pub(crate) agent_dock: Option, + pub(crate) project_panel_dock: Option, + pub(crate) outline_panel_dock: Option, + pub(crate) collaboration_panel_dock: Option, + pub(crate) git_panel_dock: Option, + pub(crate) notification_panel_button: Option, +} + +impl PanelLayout { + const AGENT: Self = Self { + agent_dock: Some(DockPosition::Left), + project_panel_dock: Some(DockSide::Right), + outline_panel_dock: Some(DockSide::Right), + collaboration_panel_dock: Some(DockPosition::Right), + git_panel_dock: Some(DockPosition::Right), + notification_panel_button: Some(false), + }; + + const EDITOR: Self = Self { + agent_dock: Some(DockPosition::Right), + project_panel_dock: Some(DockSide::Left), + outline_panel_dock: Some(DockSide::Left), + collaboration_panel_dock: Some(DockPosition::Left), + git_panel_dock: Some(DockPosition::Left), + notification_panel_button: Some(true), + }; + + pub fn is_agent_layout(&self) -> bool { + *self == Self::AGENT + } + + pub fn is_editor_layout(&self) -> bool { + *self == Self::EDITOR + } + + fn read_from(content: &SettingsContent) -> Self { + Self { + agent_dock: content.agent.as_ref().and_then(|a| a.dock), + project_panel_dock: content.project_panel.as_ref().and_then(|p| p.dock), + outline_panel_dock: content.outline_panel.as_ref().and_then(|p| p.dock), + collaboration_panel_dock: content.collaboration_panel.as_ref().and_then(|p| p.dock), + git_panel_dock: content.git_panel.as_ref().and_then(|p| p.dock), + notification_panel_button: content.notification_panel.as_ref().and_then(|p| p.button), + } + } + + fn write_to(&self, settings: &mut SettingsContent) { + settings.agent.get_or_insert_default().dock = self.agent_dock; + settings.project_panel.get_or_insert_default().dock = self.project_panel_dock; + settings.outline_panel.get_or_insert_default().dock = self.outline_panel_dock; + settings.collaboration_panel.get_or_insert_default().dock = self.collaboration_panel_dock; + settings.git_panel.get_or_insert_default().dock = self.git_panel_dock; + settings.notification_panel.get_or_insert_default().button = self.notification_panel_button; + } + + fn write_diff_to(&self, current_merged: &PanelLayout, settings: &mut SettingsContent) { + if self.agent_dock != current_merged.agent_dock { + settings.agent.get_or_insert_default().dock = self.agent_dock; + } + if self.project_panel_dock != current_merged.project_panel_dock { + settings.project_panel.get_or_insert_default().dock = self.project_panel_dock; + } + if self.outline_panel_dock != current_merged.outline_panel_dock { + settings.outline_panel.get_or_insert_default().dock = self.outline_panel_dock; + } + if self.collaboration_panel_dock != current_merged.collaboration_panel_dock { + settings.collaboration_panel.get_or_insert_default().dock = + self.collaboration_panel_dock; + } + if self.git_panel_dock != current_merged.git_panel_dock { + settings.git_panel.get_or_insert_default().dock = self.git_panel_dock; + } + if self.notification_panel_button != current_merged.notification_panel_button { + settings.notification_panel.get_or_insert_default().button = + self.notification_panel_button; + } + } + + fn backfill_to(&self, user_layout: &PanelLayout, settings: &mut SettingsContent) { + if user_layout.agent_dock.is_none() { + settings.agent.get_or_insert_default().dock = self.agent_dock; + } + if user_layout.project_panel_dock.is_none() { + settings.project_panel.get_or_insert_default().dock = self.project_panel_dock; + } + if user_layout.outline_panel_dock.is_none() { + settings.outline_panel.get_or_insert_default().dock = self.outline_panel_dock; + } + if user_layout.collaboration_panel_dock.is_none() { + settings.collaboration_panel.get_or_insert_default().dock = + self.collaboration_panel_dock; + } + if user_layout.git_panel_dock.is_none() { + settings.git_panel.get_or_insert_default().dock = self.git_panel_dock; + } + if user_layout.notification_panel_button.is_none() { + settings.notification_panel.get_or_insert_default().button = + self.notification_panel_button; + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WindowLayout { + Editor(Option), + Agent(Option), + Custom(PanelLayout), +} + +impl WindowLayout { + pub fn agent() -> Self { + Self::Agent(None) + } + + pub fn editor() -> Self { + Self::Editor(None) + } +} + #[derive(Clone, Debug, RegisterSetting)] pub struct AgentSettings { pub enabled: bool, pub button: bool, pub dock: DockPosition, + pub flexible: bool, + pub sidebar_side: SidebarDockPosition, pub default_width: Pixels, pub default_height: Pixels, pub default_model: Option, @@ -46,6 +172,7 @@ pub struct AgentSettings { pub enable_feedback: bool, pub expand_edit_card: bool, pub expand_terminal_card: bool, + pub thinking_display: ThinkingBlockDisplay, pub cancel_generation_on_terminal_stop: bool, pub use_modifier_to_send: bool, pub message_editor_min_lines: usize, @@ -77,6 +204,13 @@ impl AgentSettings { return None; } + pub fn sidebar_side(&self) -> SidebarSide { + match self.sidebar_side { + SidebarDockPosition::Left => SidebarSide::Left, + SidebarDockPosition::Right => SidebarSide::Right, + } + } + pub fn set_message_editor_max_lines(&self) -> usize { self.message_editor_min_lines * 2 } @@ -87,6 +221,62 @@ impl AgentSettings { .map(|sel| ModelId::new(format!("{}/{}", sel.provider.0, sel.model))) .collect() } + + pub fn get_layout(cx: &App) -> WindowLayout { + let store = cx.global::(); + let merged = store.merged_settings(); + let user_layout = store + .raw_user_settings() + .map(|u| PanelLayout::read_from(u.content.as_ref())) + .unwrap_or_default(); + let merged_layout = PanelLayout::read_from(merged); + + if merged_layout.is_agent_layout() { + return WindowLayout::Agent(Some(user_layout)); + } + + if merged_layout.is_editor_layout() { + return WindowLayout::Editor(Some(user_layout)); + } + + WindowLayout::Custom(user_layout) + } + + pub fn backfill_editor_layout(fs: Arc, cx: &App) { + let user_layout = cx + .global::() + .raw_user_settings() + .map(|u| PanelLayout::read_from(u.content.as_ref())) + .unwrap_or_default(); + + update_settings_file(fs, cx, move |settings, _cx| { + PanelLayout::EDITOR.backfill_to(&user_layout, settings); + }); + } + + pub fn set_layout(layout: WindowLayout, fs: Arc, cx: &App) { + let merged = PanelLayout::read_from(cx.global::().merged_settings()); + + match layout { + WindowLayout::Agent(None) => { + update_settings_file(fs, cx, move |settings, _cx| { + PanelLayout::AGENT.write_diff_to(&merged, settings); + }); + } + WindowLayout::Editor(None) => { + update_settings_file(fs, cx, move |settings, _cx| { + PanelLayout::EDITOR.write_diff_to(&merged, settings); + }); + } + WindowLayout::Agent(Some(saved)) + | WindowLayout::Editor(Some(saved)) + | WindowLayout::Custom(saved) => { + update_settings_file(fs, cx, move |settings, _cx| { + saved.write_to(settings); + }); + } + } + } } #[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, JsonSchema)] @@ -407,8 +597,10 @@ impl Settings for AgentSettings { enabled: agent.enabled.unwrap(), button: agent.button.unwrap(), dock: agent.dock.unwrap(), + sidebar_side: agent.sidebar_side.unwrap(), default_width: px(agent.default_width.unwrap()), default_height: px(agent.default_height.unwrap()), + flexible: agent.flexible.unwrap(), default_model: Some(agent.default_model.unwrap()), inline_assistant_model: agent.inline_assistant_model, inline_assistant_use_streaming_tools: agent @@ -434,6 +626,7 @@ impl Settings for AgentSettings { enable_feedback: agent.enable_feedback.unwrap(), expand_edit_card: agent.expand_edit_card.unwrap(), expand_terminal_card: agent.expand_terminal_card.unwrap(), + thinking_display: agent.thinking_display.unwrap(), cancel_generation_on_terminal_stop: agent.cancel_generation_on_terminal_stop.unwrap(), use_modifier_to_send: agent.use_modifier_to_send.unwrap(), message_editor_min_lines: agent.message_editor_min_lines.unwrap(), @@ -541,10 +734,19 @@ fn compile_regex_rules( #[cfg(test)] mod tests { use super::*; + use gpui::{TestAppContext, UpdateGlobal}; use serde_json::json; use settings::ToolPermissionMode; use settings::ToolPermissionsContent; + fn set_agent_v2_defaults(cx: &mut gpui::App) { + SettingsStore::update_global(cx, |store, cx| { + store.update_default_settings(cx, |defaults| { + PanelLayout::AGENT.write_to(defaults); + }); + }); + } + #[test] fn test_compiled_regex_case_insensitive() { let regex = CompiledRegex::new("rm\\s+-rf", false).unwrap(); @@ -1017,4 +1219,282 @@ mod tests { let permissions = compile_tool_permissions(Some(content)); assert_eq!(permissions.default, ToolPermissionMode::Deny); } + + #[gpui::test] + fn test_get_layout(cx: &mut gpui::App) { + let store = SettingsStore::test(cx); + cx.set_global(store); + project::DisableAiSettings::register(cx); + AgentSettings::register(cx); + + // Test defaults are editor layout; switch to agent V2. + set_agent_v2_defaults(cx); + + // Should be Agent with an empty user layout (user hasn't customized). + let layout = AgentSettings::get_layout(cx); + let WindowLayout::Agent(Some(user_layout)) = layout else { + panic!("expected Agent(Some), got {:?}", layout); + }; + assert_eq!(user_layout, PanelLayout::default()); + + // User explicitly sets agent dock to left (matching the default). + // The merged result is still agent, but the user layout captures + // only what the user wrote. + SettingsStore::update_global(cx, |store, cx| { + store + .set_user_settings(r#"{ "agent": { "dock": "left" } }"#, cx) + .unwrap(); + }); + + let layout = AgentSettings::get_layout(cx); + let WindowLayout::Agent(Some(user_layout)) = layout else { + panic!("expected Agent(Some), got {:?}", layout); + }; + assert_eq!(user_layout.agent_dock, Some(DockPosition::Left)); + assert_eq!(user_layout.project_panel_dock, None); + assert_eq!(user_layout.outline_panel_dock, None); + assert_eq!(user_layout.collaboration_panel_dock, None); + assert_eq!(user_layout.git_panel_dock, None); + assert_eq!(user_layout.notification_panel_button, None); + + // User sets a combination that doesn't match either preset: + // agent on the left but project panel also on the left. + SettingsStore::update_global(cx, |store, cx| { + store + .set_user_settings( + r#"{ + "agent": { "dock": "left" }, + "project_panel": { "dock": "left" } + }"#, + cx, + ) + .unwrap(); + }); + + let layout = AgentSettings::get_layout(cx); + let WindowLayout::Custom(user_layout) = layout else { + panic!("expected Custom, got {:?}", layout); + }; + assert_eq!(user_layout.agent_dock, Some(DockPosition::Left)); + assert_eq!(user_layout.project_panel_dock, Some(DockSide::Left)); + } + + #[gpui::test] + fn test_set_layout_round_trip(cx: &mut gpui::App) { + let store = SettingsStore::test(cx); + cx.set_global(store); + project::DisableAiSettings::register(cx); + AgentSettings::register(cx); + + // User has a custom layout: agent on the right with project panel + // also on the right. This doesn't match either preset. + SettingsStore::update_global(cx, |store, cx| { + store + .set_user_settings( + r#"{ + "agent": { "dock": "right" }, + "project_panel": { "dock": "right" } + }"#, + cx, + ) + .unwrap(); + }); + + let original = AgentSettings::get_layout(cx); + let WindowLayout::Custom(ref original_user_layout) = original else { + panic!("expected Custom, got {:?}", original); + }; + assert_eq!(original_user_layout.agent_dock, Some(DockPosition::Right)); + assert_eq!( + original_user_layout.project_panel_dock, + Some(DockSide::Right) + ); + assert_eq!(original_user_layout.outline_panel_dock, None); + + // Switch to the agent layout. This overwrites the user settings. + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + PanelLayout::AGENT.write_to(settings); + }); + }); + + let layout = AgentSettings::get_layout(cx); + assert!(matches!(layout, WindowLayout::Agent(_))); + + // Restore the original custom layout. + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + original_user_layout.write_to(settings); + }); + }); + + // Should be back to the same custom layout. + let restored = AgentSettings::get_layout(cx); + let WindowLayout::Custom(restored_user_layout) = restored else { + panic!("expected Custom, got {:?}", restored); + }; + assert_eq!(restored_user_layout.agent_dock, Some(DockPosition::Right)); + assert_eq!( + restored_user_layout.project_panel_dock, + Some(DockSide::Right) + ); + assert_eq!(restored_user_layout.outline_panel_dock, None); + } + + #[gpui::test] + async fn test_set_layout_minimal_diff(cx: &mut TestAppContext) { + let fs = fs::FakeFs::new(cx.background_executor.clone()); + fs.save( + paths::settings_file().as_path(), + &serde_json::json!({ + "agent": { "dock": "left" }, + "project_panel": { "dock": "left" } + }) + .to_string() + .into(), + Default::default(), + ) + .await + .unwrap(); + + cx.update(|cx| { + let store = SettingsStore::test(cx); + cx.set_global(store); + project::DisableAiSettings::register(cx); + AgentSettings::register(cx); + + // Apply the agent V2 defaults. + set_agent_v2_defaults(cx); + + // User has agent=left (matches preset) and project_panel=left (does not) + SettingsStore::update_global(cx, |store, cx| { + store + .set_user_settings( + r#"{ + "agent": { "dock": "left" }, + "project_panel": { "dock": "left" } + }"#, + cx, + ) + .unwrap(); + }); + + let layout = AgentSettings::get_layout(cx); + assert!(matches!(layout, WindowLayout::Custom(_))); + + AgentSettings::set_layout(WindowLayout::agent(), fs.clone(), cx); + }); + + cx.run_until_parked(); + + let written = fs.load(paths::settings_file().as_path()).await.unwrap(); + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.set_user_settings(&written, cx).unwrap(); + }); + + // The user settings should still have agent=left (preserved) + // and now project_panel=right (changed to match preset). + let store = cx.global::(); + let user_layout = store + .raw_user_settings() + .map(|u| PanelLayout::read_from(u.content.as_ref())) + .unwrap_or_default(); + + assert_eq!(user_layout.agent_dock, Some(DockPosition::Left)); + assert_eq!(user_layout.project_panel_dock, Some(DockSide::Right)); + // Other fields weren't in user settings and didn't need changing. + assert_eq!(user_layout.outline_panel_dock, None); + + // And the merged result should now match agent. + let layout = AgentSettings::get_layout(cx); + assert!(matches!(layout, WindowLayout::Agent(_))); + }); + } + + #[gpui::test] + async fn test_backfill_editor_layout(cx: &mut TestAppContext) { + let fs = fs::FakeFs::new(cx.background_executor.clone()); + // User has only customized project_panel to "right". + fs.save( + paths::settings_file().as_path(), + &serde_json::json!({ + "project_panel": { "dock": "right" } + }) + .to_string() + .into(), + Default::default(), + ) + .await + .unwrap(); + + cx.update(|cx| { + let store = SettingsStore::test(cx); + cx.set_global(store); + project::DisableAiSettings::register(cx); + AgentSettings::register(cx); + + // Simulate pre-migration state: editor defaults (the old world). + SettingsStore::update_global(cx, |store, cx| { + store.update_default_settings(cx, |defaults| { + PanelLayout::EDITOR.write_to(defaults); + }); + }); + + // User has only customized project_panel to "right". + SettingsStore::update_global(cx, |store, cx| { + store + .set_user_settings(r#"{ "project_panel": { "dock": "right" } }"#, cx) + .unwrap(); + }); + + // Run the one-time backfill while still on old defaults. + AgentSettings::backfill_editor_layout(fs.clone(), cx); + }); + + cx.run_until_parked(); + + // Read back the file and apply it, then switch to agent V2 defaults. + let written = fs.load(paths::settings_file().as_path()).await.unwrap(); + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.set_user_settings(&written, cx).unwrap(); + }); + + // The user's project_panel=right should be preserved (they set it). + // All other fields should now have the editor preset values + // written into user settings. + let store = cx.global::(); + let user_layout = store + .raw_user_settings() + .map(|u| PanelLayout::read_from(u.content.as_ref())) + .unwrap_or_default(); + + assert_eq!(user_layout.agent_dock, Some(DockPosition::Right)); + assert_eq!(user_layout.project_panel_dock, Some(DockSide::Right)); + assert_eq!(user_layout.outline_panel_dock, Some(DockSide::Left)); + assert_eq!( + user_layout.collaboration_panel_dock, + Some(DockPosition::Left) + ); + assert_eq!(user_layout.git_panel_dock, Some(DockPosition::Left)); + assert_eq!(user_layout.notification_panel_button, Some(true)); + + // Now switch defaults to agent V2. + set_agent_v2_defaults(cx); + + // Even though defaults are now agent, the backfilled user settings + // keep everything in the editor layout. The user's experience + // hasn't changed. + let layout = AgentSettings::get_layout(cx); + let WindowLayout::Custom(user_layout) = layout else { + panic!( + "expected Custom (editor values override agent defaults), got {:?}", + layout + ); + }; + assert_eq!(user_layout.agent_dock, Some(DockPosition::Right)); + assert_eq!(user_layout.project_panel_dock, Some(DockSide::Right)); + }); + } } diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index b60f2a6b136c5e4dbb131603d95623a719ce7134..6c045d4dd2114834605da278aad111fab174d4c6 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -23,6 +23,7 @@ test-support = [ "workspace/test-support", "agent/test-support", ] +audio = ["dep:audio"] unit-eval = [] [dependencies] @@ -38,13 +39,12 @@ heapless.workspace = true assistant_text_thread.workspace = true assistant_slash_command.workspace = true assistant_slash_commands.workspace = true -audio.workspace = true +audio = { workspace = true, optional = true } base64.workspace = true buffer_diff.workspace = true chrono.workspace = true client.workspace = true cloud_api_types.workspace = true -cloud_llm_client.workspace = true collections.workspace = true command_palette_hooks.workspace = true component.workspace = true @@ -102,6 +102,7 @@ terminal.workspace = true terminal_view.workspace = true text.workspace = true theme.workspace = true +theme_settings.workspace = true time.workspace = true time_format.workspace = true ui.workspace = true @@ -113,7 +114,6 @@ watch.workspace = true workspace.workspace = true zed_actions.workspace = true image.workspace = true -async-fs.workspace = true reqwest_client = { workspace = true, optional = true } [dev-dependencies] diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index fc5a78dfc936617f3782eae154b6a13531e5c425..fda3cb9907b2f02cce29ff0ae8c4762e6efa625a 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -4,7 +4,7 @@ mod configure_context_server_tools_modal; mod manage_profiles_modal; mod tool_picker; -use std::{ops::Range, sync::Arc}; +use std::{ops::Range, rc::Rc, sync::Arc}; use agent::ContextServerRegistry; use anyhow::Result; @@ -33,9 +33,9 @@ use project::{ }; use settings::{Settings, SettingsStore, update_settings_file}; use ui::{ - ButtonStyle, Chip, CommonAnimationExt, ContextMenu, ContextMenuEntry, Disclosure, Divider, - DividerColor, ElevationIndex, Indicator, LabelSize, PopoverMenu, Switch, Tooltip, - WithScrollbar, prelude::*, + AiSettingItem, AiSettingItemSource, AiSettingItemStatus, ButtonStyle, Chip, ContextMenu, + ContextMenuEntry, Disclosure, Divider, DividerColor, ElevationIndex, LabelSize, PopoverMenu, + Switch, Tooltip, WithScrollbar, prelude::*, }; use util::ResultExt as _; use workspace::{Workspace, create_and_open_local_file}; @@ -45,29 +45,32 @@ pub(crate) use configure_context_server_modal::ConfigureContextServerModal; pub(crate) use configure_context_server_tools_modal::ConfigureContextServerToolsModal; pub(crate) use manage_profiles_modal::ManageProfilesModal; -use crate::agent_configuration::add_llm_provider_modal::{ - AddLlmProviderModal, LlmCompatibleProvider, +use crate::{ + Agent, + agent_configuration::add_llm_provider_modal::{AddLlmProviderModal, LlmCompatibleProvider}, + agent_connection_store::{AgentConnectionStatus, AgentConnectionStore}, }; pub struct AgentConfiguration { fs: Arc, language_registry: Arc, agent_server_store: Entity, + agent_connection_store: Entity, workspace: WeakEntity, focus_handle: FocusHandle, configuration_views_by_provider: HashMap, context_server_store: Entity, expanded_provider_configurations: HashMap, context_server_registry: Entity, - _registry_subscription: Subscription, + _subscriptions: Vec, scroll_handle: ScrollHandle, - _check_for_gemini: Task<()>, } impl AgentConfiguration { pub fn new( fs: Arc, agent_server_store: Entity, + agent_connection_store: Entity, context_server_store: Entity, context_server_registry: Entity, language_registry: Arc, @@ -77,25 +80,27 @@ impl AgentConfiguration { ) -> Self { let focus_handle = cx.focus_handle(); - let registry_subscription = cx.subscribe_in( - &LanguageModelRegistry::global(cx), - window, - |this, _, event: &language_model::Event, window, cx| match event { - language_model::Event::AddedProvider(provider_id) => { - let provider = LanguageModelRegistry::read_global(cx).provider(provider_id); - if let Some(provider) = provider { - this.add_provider_configuration_view(&provider, window, cx); + let subscriptions = vec![ + cx.subscribe_in( + &LanguageModelRegistry::global(cx), + window, + |this, _, event: &language_model::Event, window, cx| match event { + language_model::Event::AddedProvider(provider_id) => { + let provider = LanguageModelRegistry::read_global(cx).provider(provider_id); + if let Some(provider) = provider { + this.add_provider_configuration_view(&provider, window, cx); + } } - } - language_model::Event::RemovedProvider(provider_id) => { - this.remove_provider_configuration_view(provider_id); - } - _ => {} - }, - ); - - cx.subscribe(&context_server_store, |_, _, _, cx| cx.notify()) - .detach(); + language_model::Event::RemovedProvider(provider_id) => { + this.remove_provider_configuration_view(provider_id); + } + _ => {} + }, + ), + cx.subscribe(&agent_server_store, |_, _, _, cx| cx.notify()), + cx.observe(&agent_connection_store, |_, _, cx| cx.notify()), + cx.subscribe(&context_server_store, |_, _, _, cx| cx.notify()), + ]; let mut this = Self { fs, @@ -104,13 +109,14 @@ impl AgentConfiguration { focus_handle, configuration_views_by_provider: HashMap::default(), agent_server_store, + agent_connection_store, context_server_store, expanded_provider_configurations: HashMap::default(), context_server_registry, - _registry_subscription: registry_subscription, + _subscriptions: subscriptions, scroll_handle: ScrollHandle::new(), - _check_for_gemini: Task::ready(()), }; + this.build_provider_configuration_views(window, cx); this } @@ -636,6 +642,22 @@ impl AgentConfiguration { ) }); + let display_name = if provided_by_extension { + resolve_extension_for_context_server(&context_server_id, cx) + .map(|(_, manifest)| { + let name = manifest.name.as_str(); + let stripped = name + .strip_suffix(" MCP Server") + .or_else(|| name.strip_suffix(" MCP")) + .or_else(|| name.strip_suffix(" Context Server")) + .unwrap_or(name); + SharedString::from(stripped.to_string()) + }) + .unwrap_or_else(|| item_id.clone()) + } else { + item_id.clone() + }; + let error = if let ContextServerStatus::Error(error) = server_status.clone() { Some(error) } else { @@ -651,57 +673,19 @@ impl AgentConfiguration { .tools_for_server(&context_server_id) .count(); - let (source_icon, source_tooltip) = if provided_by_extension { - ( - IconName::ZedSrcExtension, - "This MCP server was installed from an extension.", - ) + let source = if provided_by_extension { + AiSettingItemSource::Extension } else { - ( - IconName::ZedSrcCustom, - "This custom MCP server was installed directly.", - ) + AiSettingItemSource::Custom }; - let (status_indicator, tooltip_text) = match server_status { - ContextServerStatus::Starting => ( - Icon::new(IconName::LoadCircle) - .size(IconSize::XSmall) - .color(Color::Accent) - .with_keyed_rotate_animation( - SharedString::from(format!("{}-starting", context_server_id.0)), - 3, - ) - .into_any_element(), - "Server is starting.", - ), - ContextServerStatus::Running => ( - Indicator::dot().color(Color::Success).into_any_element(), - "Server is active.", - ), - ContextServerStatus::Error(_) => ( - Indicator::dot().color(Color::Error).into_any_element(), - "Server has an error.", - ), - ContextServerStatus::Stopped => ( - Indicator::dot().color(Color::Muted).into_any_element(), - "Server is stopped.", - ), - ContextServerStatus::AuthRequired => ( - Indicator::dot().color(Color::Warning).into_any_element(), - "Authentication required.", - ), - ContextServerStatus::Authenticating => ( - Icon::new(IconName::LoadCircle) - .size(IconSize::XSmall) - .color(Color::Accent) - .with_keyed_rotate_animation( - SharedString::from(format!("{}-authenticating", context_server_id.0)), - 3, - ) - .into_any_element(), - "Waiting for authorization...", - ), + let status = match server_status { + ContextServerStatus::Starting => AiSettingItemStatus::Starting, + ContextServerStatus::Running => AiSettingItemStatus::Running, + ContextServerStatus::Error(_) => AiSettingItemStatus::Error, + ContextServerStatus::Stopped => AiSettingItemStatus::Stopped, + ContextServerStatus::AuthRequired => AiSettingItemStatus::AuthRequired, + ContextServerStatus::Authenticating => AiSettingItemStatus::Authenticating, }; let is_remote = server_configuration @@ -845,232 +829,165 @@ impl AgentConfiguration { let feedback_base_container = || h_flex().py_1().min_w_0().w_full().gap_1().justify_between(); - v_flex() - .min_w_0() - .id(item_id.clone()) - .child( - h_flex() - .min_w_0() - .w_full() - .justify_between() + let details: Option = if let Some(error) = error { + Some( + feedback_base_container() .child( h_flex() - .flex_1() + .pr_4() .min_w_0() + .w_full() + .gap_2() .child( - h_flex() - .id(format!("tooltip-{}", item_id)) - .h_full() - .w_3() - .mr_2() - .justify_center() - .tooltip(Tooltip::text(tooltip_text)) - .child(status_indicator), - ) - .child(Label::new(item_id).flex_shrink_0().truncate()) - .child( - div() - .id("extension-source") - .min_w_0() - .mt_0p5() - .mx_1() - .tooltip(Tooltip::text(source_tooltip)) - .child( - Icon::new(source_icon) - .size(IconSize::Small) - .color(Color::Muted), - ), + Icon::new(IconName::XCircle) + .size(IconSize::XSmall) + .color(Color::Error), ) - .when(is_running, |this| { - this.child( - Label::new(if tool_count == 1 { - SharedString::from("1 tool") - } else { - SharedString::from(format!("{} tools", tool_count)) - }) - .color(Color::Muted) - .size(LabelSize::Small), - ) - }), + .child(div().min_w_0().flex_1().child( + Label::new(error).color(Color::Muted).size(LabelSize::Small), + )), ) - .child( - h_flex() - .gap_0p5() - .flex_none() - .child(context_server_configuration_menu) - .child( - Switch::new("context-server-switch", is_running.into()) + .when(should_show_logout_button, |this| { + this.child( + Button::new("error-logout-server", "Log Out") + .style(ButtonStyle::Outlined) + .label_size(LabelSize::Small) .on_click({ - let context_server_manager = self.context_server_store.clone(); - let fs = self.fs.clone(); + let context_server_store = context_server_store.clone(); let context_server_id = context_server_id.clone(); - - move |state, _window, cx| { - let is_enabled = match state { - ToggleState::Unselected - | ToggleState::Indeterminate => { - context_server_manager.update(cx, |this, cx| { - this.stop_server(&context_server_id, cx) - .log_err(); - }); - false - } - ToggleState::Selected => { - context_server_manager.update(cx, |this, cx| { - if let Some(server) = - this.get_server(&context_server_id) - { - this.start_server(server, cx); - } - }); - true - } - }; - update_settings_file(fs.clone(), cx, { - let context_server_id = context_server_id.clone(); - - move |settings, _| { - settings - .project - .context_servers - .entry(context_server_id.0) - .or_insert_with(|| { - settings::ContextServerSettingsContent::Extension { - enabled: is_enabled, - remote: false, - settings: serde_json::json!({}), - } - }) - .set_enabled(is_enabled); - } + move |_event, _window, cx| { + context_server_store.update(cx, |store, cx| { + store.logout_server(&context_server_id, cx).log_err(); }); } }), - ), - ), + ) + }) + .into_any_element(), ) - .map(|parent| { - if let Some(error) = error { - return parent - .child( - feedback_base_container() - .child( - h_flex() - .pr_4() - .min_w_0() - .w_full() - .gap_2() - .child( - Icon::new(IconName::XCircle) - .size(IconSize::XSmall) - .color(Color::Error), - ) - .child( - div().min_w_0().flex_1().child( - Label::new(error) - .color(Color::Muted) - .size(LabelSize::Small), - ), - ), - ) - .when(should_show_logout_button, |this| { - this.child( - Button::new("error-logout-server", "Log Out") - .style(ButtonStyle::Outlined) - .label_size(LabelSize::Small) - .on_click({ - let context_server_store = - context_server_store.clone(); - let context_server_id = - context_server_id.clone(); - move |_event, _window, cx| { - context_server_store.update( - cx, - |store, cx| { - store - .logout_server( - &context_server_id, - cx, - ) - .log_err(); - }, - ); - } - }), - ) - }), - ); - } - if auth_required { - return parent.child( - feedback_base_container() - .child( - h_flex() - .pr_4() - .min_w_0() - .w_full() - .gap_2() - .child( - Icon::new(IconName::Info) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .child( - Label::new("Authenticate to connect this server") - .color(Color::Muted) - .size(LabelSize::Small), - ), - ) - .child( - Button::new("error-logout-server", "Authenticate") - .style(ButtonStyle::Outlined) - .label_size(LabelSize::Small) - .on_click({ - let context_server_store = context_server_store.clone(); - let context_server_id = context_server_id.clone(); - move |_event, _window, cx| { - context_server_store.update(cx, |store, cx| { - store - .authenticate_server(&context_server_id, cx) - .log_err(); - }); - } - }), - ), - ); - } - if authenticating { - return parent.child( + } else if auth_required { + Some( + feedback_base_container() + .child( h_flex() - .mt_1() .pr_4() .min_w_0() .w_full() .gap_2() .child( - div().size_3().flex_shrink_0(), // Alignment Div + Icon::new(IconName::Info) + .size(IconSize::XSmall) + .color(Color::Muted), ) .child( - Label::new("Authenticating…") + Label::new("Authenticate to connect this server") .color(Color::Muted) .size(LabelSize::Small), ), + ) + .child( + Button::new("error-logout-server", "Authenticate") + .style(ButtonStyle::Outlined) + .label_size(LabelSize::Small) + .on_click({ + let context_server_id = context_server_id.clone(); + move |_event, _window, cx| { + context_server_store.update(cx, |store, cx| { + store.authenticate_server(&context_server_id, cx).log_err(); + }); + } + }), + ) + .into_any_element(), + ) + } else if authenticating { + Some( + h_flex() + .mt_1() + .pr_4() + .min_w_0() + .w_full() + .gap_2() + .child(div().size_3().flex_shrink_0()) + .child( + Label::new("Authenticating…") + .color(Color::Muted) + .size(LabelSize::Small), + ) + .into_any_element(), + ) + } else { + None + }; - ); - } - parent + let tool_label = if is_running { + Some(if tool_count == 1 { + SharedString::from("1 tool") + } else { + SharedString::from(format!("{} tools", tool_count)) }) + } else { + None + }; + + AiSettingItem::new(item_id, display_name, status, source) + .action(context_server_configuration_menu) + .action( + Switch::new("context-server-switch", is_running.into()).on_click({ + let context_server_manager = self.context_server_store.clone(); + let fs = self.fs.clone(); + + move |state, _window, cx| { + let is_enabled = match state { + ToggleState::Unselected | ToggleState::Indeterminate => { + context_server_manager.update(cx, |this, cx| { + this.stop_server(&context_server_id, cx).log_err(); + }); + false + } + ToggleState::Selected => { + context_server_manager.update(cx, |this, cx| { + if let Some(server) = this.get_server(&context_server_id) { + this.start_server(server, cx); + } + }); + true + } + }; + update_settings_file(fs.clone(), cx, { + let context_server_id = context_server_id.clone(); + + move |settings, _| { + settings + .project + .context_servers + .entry(context_server_id.0) + .or_insert_with(|| { + settings::ContextServerSettingsContent::Extension { + enabled: is_enabled, + remote: false, + settings: serde_json::json!({}), + } + }) + .set_enabled(is_enabled); + } + }); + } + }), + ) + .when_some(tool_label, |this, label| this.detail_label(label)) + .when_some(details, |this, details| this.details(details)) } fn render_agent_servers_section(&mut self, cx: &mut Context) -> impl IntoElement { let agent_server_store = self.agent_server_store.read(cx); - let user_defined_agents = agent_server_store + let agents = agent_server_store .external_agents() .cloned() .collect::>(); - let user_defined_agents: Vec<_> = user_defined_agents + let agents: Vec<_> = agents .into_iter() .map(|name| { let icon = if let Some(icon_path) = agent_server_store.agent_icon(&name) { @@ -1159,24 +1076,31 @@ impl AgentConfiguration { "All agents connected through the Agent Client Protocol.", add_agent_popover.into_any_element(), )) - .child(v_flex().p_4().pt_0().gap_2().map(|mut parent| { - let mut first = true; - for (name, icon, display_name, source) in user_defined_agents { - if !first { - parent = parent - .child(Divider::horizontal().color(DividerColor::BorderFaded)); - } - first = false; - parent = parent.child(self.render_agent_server( - icon, - name, - display_name, - source, - cx, - )); - } - parent - })), + .child( + v_flex() + .p_4() + .pt_0() + .gap_2() + .children(Itertools::intersperse_with( + agents + .into_iter() + .map(|(name, icon, display_name, source)| { + self.render_agent_server( + icon, + name, + display_name, + source, + cx, + ) + .into_any_element() + }), + || { + Divider::horizontal() + .color(DividerColor::BorderFaded) + .into_any_element() + }, + )), + ), ) } @@ -1200,27 +1124,46 @@ impl AgentConfiguration { .color(Color::Muted), }; - let source_badge = match source { - ExternalAgentSource::Extension => Some(( - SharedString::new(format!("agent-source-{}", id)), - SharedString::from(format!( - "The {} agent was installed from an extension.", - display_name - )), - IconName::ZedSrcExtension, - )), - ExternalAgentSource::Registry => Some(( - SharedString::new(format!("agent-source-{}", id)), - SharedString::from(format!( - "The {} agent was installed from the ACP registry.", - display_name - )), - IconName::AcpRegistry, - )), - ExternalAgentSource::Custom => None, + let source_kind = match source { + ExternalAgentSource::Extension => AiSettingItemSource::Extension, + ExternalAgentSource::Registry => AiSettingItemSource::Registry, + ExternalAgentSource::Custom => AiSettingItemSource::Custom, }; let agent_server_name = AgentId(id.clone()); + let agent = Agent::Custom { + id: agent_server_name.clone(), + }; + + let connection_status = self + .agent_connection_store + .read(cx) + .connection_status(&agent, cx); + + let restart_button = matches!( + connection_status, + AgentConnectionStatus::Connected | AgentConnectionStatus::Connecting + ) + .then(|| { + IconButton::new( + SharedString::from(format!("restart-{}", id)), + IconName::RotateCw, + ) + .disabled(connection_status == AgentConnectionStatus::Connecting) + .icon_color(Color::Muted) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Restart Agent Connection")) + .on_click(cx.listener({ + let agent = agent.clone(); + move |this, _, _window, cx| { + let server: Rc = + Rc::new(agent_servers::CustomAgentServer::new(agent.id())); + this.agent_connection_store.update(cx, |store, cx| { + store.restart_connection(agent.clone(), server, cx); + }); + } + })) + }); let uninstall_button = match source { ExternalAgentSource::Extension => Some( @@ -1301,32 +1244,16 @@ impl AgentConfiguration { } }; - h_flex() - .gap_1() - .justify_between() - .child( - h_flex() - .gap_1p5() - .child(icon) - .child(Label::new(display_name)) - .when_some(source_badge, |this, (tooltip_id, tooltip_message, icon)| { - this.child( - div() - .id(tooltip_id) - .flex_none() - .tooltip(Tooltip::text(tooltip_message)) - .child(Icon::new(icon).size(IconSize::Small).color(Color::Muted)), - ) - }) - .child( - Icon::new(IconName::Check) - .color(Color::Success) - .size(IconSize::Small), - ), - ) - .when_some(uninstall_button, |this, uninstall_button| { - this.child(uninstall_button) - }) + let status = match connection_status { + AgentConnectionStatus::Disconnected => AiSettingItemStatus::Stopped, + AgentConnectionStatus::Connecting => AiSettingItemStatus::Starting, + AgentConnectionStatus::Connected => AiSettingItemStatus::Running, + }; + + AiSettingItem::new(id, display_name, status, source_kind) + .icon(icon) + .when_some(restart_button, |this, button| this.action(button)) + .when_some(uninstall_button, |this, button| this.action(button)) } } @@ -1465,38 +1392,45 @@ async fn open_new_agent_servers_entry_in_settings_editor( let settings = cx.global::(); let mut unique_server_name = None; - let edits = settings.edits_for_update(&text, |settings| { - let server_name: Option = (0..u8::MAX) - .map(|i| { - if i == 0 { - "your_agent".to_string() - } else { - format!("your_agent_{}", i) - } - }) - .find(|name| { - !settings - .agent_servers - .as_ref() - .is_some_and(|agent_servers| agent_servers.contains_key(name.as_str())) - }); - if let Some(server_name) = server_name { - unique_server_name = Some(SharedString::from(server_name.clone())); - settings.agent_servers.get_or_insert_default().insert( - server_name, - settings::CustomAgentServerSettings::Custom { - path: "path_to_executable".into(), - args: vec![], - env: HashMap::default(), - default_mode: None, - default_model: None, - favorite_models: vec![], - default_config_options: Default::default(), - favorite_config_option_values: Default::default(), - }, - ); - } - }); + let Some(edits) = settings + .edits_for_update(&text, |settings| { + let server_name: Option = (0..u8::MAX) + .map(|i| { + if i == 0 { + "your_agent".to_string() + } else { + format!("your_agent_{}", i) + } + }) + .find(|name| { + !settings + .agent_servers + .as_ref() + .is_some_and(|agent_servers| { + agent_servers.contains_key(name.as_str()) + }) + }); + if let Some(server_name) = server_name { + unique_server_name = Some(SharedString::from(server_name.clone())); + settings.agent_servers.get_or_insert_default().insert( + server_name, + settings::CustomAgentServerSettings::Custom { + path: "path_to_executable".into(), + args: vec![], + env: HashMap::default(), + default_mode: None, + default_model: None, + favorite_models: vec![], + default_config_options: Default::default(), + favorite_config_option_values: Default::default(), + }, + ); + } + }) + .log_err() + else { + return; + }; if edits.is_empty() { return; 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 334aaf4026527938144cf12e25c9a7a23d5c28ac..4e3dd63b0337f9be54b550f4f4a6a5ca2e7cdd42 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 @@ -813,7 +813,7 @@ mod tests { cx.update(|cx| { let store = SettingsStore::test(cx); cx.set_global(store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); language_model::init_settings(cx); editor::init(cx); 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 e550d59c0ccb4deab40f6fcbc39dae124e3c08db..9c44288e1cd23cd3bb0d6876f086c3f0e89dc4c7 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 @@ -22,7 +22,7 @@ use project::{ use serde::Deserialize; use settings::{Settings as _, update_settings_file}; use std::sync::Arc; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ CommonAnimationExt, KeyBinding, Modal, ModalFooter, ModalHeader, Section, Tooltip, WithScrollbar, prelude::*, 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 744c92a7f7739c9fda2664de45d536769e802986..9a8b56f43f906f9ad57cb3dec9e7d95af4cb6cc5 100644 --- a/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs +++ b/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs @@ -991,7 +991,7 @@ impl Render for ManageProfilesModal { .pb_1() .child(ProfileModalHeader::new( format!("{profile_name} — Configure Built-in Tools"), - Some(IconName::Cog), + Some(IconName::Settings), )) .child(ListSeparator) .child(tool_picker.clone()) @@ -1014,7 +1014,7 @@ impl Render for ManageProfilesModal { .pb_1() .child(ProfileModalHeader::new( format!("{profile_name} — Configure Default Model"), - Some(IconName::Ai), + Some(IconName::ZedAgent), )) .child(ListSeparator) .child(v_flex().w(rems(34.)).child(model_picker.clone())) diff --git a/crates/agent_ui/src/agent_connection_store.rs b/crates/agent_ui/src/agent_connection_store.rs index 89b3b0ef16f46753a747b1e06a9b9e4a76e839e8..f19a2aa2626d4cbf1fc1ddd0878c5c029d403818 100644 --- a/crates/agent_ui/src/agent_connection_store.rs +++ b/crates/agent_ui/src/agent_connection_store.rs @@ -5,7 +5,8 @@ use agent_servers::{AgentServer, AgentServerDelegate}; use anyhow::Result; use collections::HashMap; use futures::{FutureExt, future::Shared}; -use gpui::{AppContext, Context, Entity, EventEmitter, SharedString, Subscription, Task}; +use gpui::{App, AppContext, Context, Entity, EventEmitter, SharedString, Subscription, Task}; + use project::{AgentServerStore, AgentServersUpdated, Project}; use watch::Receiver; @@ -27,6 +28,13 @@ pub struct AgentConnectedState { pub history: Option>, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AgentConnectionStatus { + Disconnected, + Connecting, + Connected, +} + impl AgentConnectionEntry { pub fn wait_for_connection(&self) -> Shared>> { match self { @@ -42,6 +50,14 @@ impl AgentConnectionEntry { _ => None, } } + + pub fn status(&self) -> AgentConnectionStatus { + match self { + AgentConnectionEntry::Connecting { .. } => AgentConnectionStatus::Connecting, + AgentConnectionEntry::Connected(_) => AgentConnectionStatus::Connected, + AgentConnectionEntry::Error { .. } => AgentConnectionStatus::Disconnected, + } + } } pub enum AgentConnectionEntryEvent { @@ -67,70 +83,132 @@ impl AgentConnectionStore { } } + pub fn project(&self) -> &Entity { + &self.project + } + pub fn entry(&self, key: &Agent) -> Option<&Entity> { self.entries.get(key) } + pub fn connection_status(&self, key: &Agent, cx: &App) -> AgentConnectionStatus { + self.entries + .get(key) + .map(|entry| entry.read(cx).status()) + .unwrap_or(AgentConnectionStatus::Disconnected) + } + + pub fn restart_connection( + &mut self, + key: Agent, + server: Rc, + cx: &mut Context, + ) -> Entity { + if let Some(entry) = self.entries.get(&key) { + if matches!(entry.read(cx), AgentConnectionEntry::Connecting { .. }) { + return entry.clone(); + } + } + + self.entries.remove(&key); + self.request_connection(key, server, cx) + } + pub fn request_connection( &mut self, key: Agent, server: Rc, cx: &mut Context, ) -> Entity { - self.entries.get(&key).cloned().unwrap_or_else(|| { - let (mut new_version_rx, connect_task) = self.start_connection(server.clone(), cx); - let connect_task = connect_task.shared(); - - let entry = cx.new(|_cx| AgentConnectionEntry::Connecting { - connect_task: connect_task.clone(), - }); - - self.entries.insert(key.clone(), entry.clone()); - - cx.spawn({ - let key = key.clone(); - let entry = entry.clone(); - async move |this, cx| match connect_task.await { - Ok(connected_state) => { - entry.update(cx, |entry, cx| { - if let AgentConnectionEntry::Connecting { .. } = entry { - *entry = AgentConnectionEntry::Connected(connected_state); - cx.notify(); - } - }); - } - Err(error) => { - entry.update(cx, |entry, cx| { - if let AgentConnectionEntry::Connecting { .. } = entry { - *entry = AgentConnectionEntry::Error { error }; - cx.notify(); - } - }); - this.update(cx, |this, _cx| this.entries.remove(&key)).ok(); - } + if let Some(entry) = self.entries.get(&key) { + return entry.clone(); + } + + let (mut new_version_rx, connect_task) = self.start_connection(server, cx); + let connect_task = connect_task.shared(); + + let entry = cx.new(|_cx| AgentConnectionEntry::Connecting { + connect_task: connect_task.clone(), + }); + + self.entries.insert(key.clone(), entry.clone()); + cx.notify(); + + cx.spawn({ + let key = key.clone(); + let entry = entry.downgrade(); + async move |this, cx| match connect_task.await { + Ok(connected_state) => { + this.update(cx, move |this, cx| { + if this.entries.get(&key) != entry.upgrade().as_ref() { + return; + } + + entry + .update(cx, move |entry, cx| { + if let AgentConnectionEntry::Connecting { .. } = entry { + *entry = AgentConnectionEntry::Connected(connected_state); + cx.notify(); + } + }) + .ok(); + }) + .ok(); } - }) - .detach(); - - cx.spawn({ - let entry = entry.clone(); - async move |this, cx| { - while let Ok(version) = new_version_rx.recv().await { - if let Some(version) = version { - entry.update(cx, |_entry, cx| { - cx.emit(AgentConnectionEntryEvent::NewVersionAvailable( - version.clone().into(), - )); - }); - this.update(cx, |this, _cx| this.entries.remove(&key)).ok(); + Err(error) => { + this.update(cx, move |this, cx| { + if this.entries.get(&key) != entry.upgrade().as_ref() { + return; } - } + + entry + .update(cx, move |entry, cx| { + if let AgentConnectionEntry::Connecting { .. } = entry { + *entry = AgentConnectionEntry::Error { error }; + cx.notify(); + } + }) + .ok(); + this.entries.remove(&key); + cx.notify(); + }) + .ok(); } - }) - .detach(); + } + }) + .detach(); + + cx.spawn({ + let entry = entry.downgrade(); + async move |this, cx| { + while let Ok(version) = new_version_rx.recv().await { + let Some(version) = version else { + continue; + }; + + this.update(cx, move |this, cx| { + if this.entries.get(&key) != entry.upgrade().as_ref() { + return; + } - entry + entry + .update(cx, move |_entry, cx| { + cx.emit(AgentConnectionEntryEvent::NewVersionAvailable( + version.into(), + )); + }) + .ok(); + this.entries.remove(&key); + cx.notify(); + }) + .ok(); + break; + } + } }) + .detach(); + + entry } fn handle_agent_servers_updated( diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs index 44b706bbe705ea9368c79fb774bd171f6220c70b..541199028b1becade3b9891114a89e69152fcb02 100644 --- a/crates/agent_ui/src/agent_diff.rs +++ b/crates/agent_ui/src/agent_diff.rs @@ -1806,7 +1806,7 @@ mod tests { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); prompt_store::init(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); language_model::init_settings(cx); }); @@ -1963,7 +1963,7 @@ mod tests { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); prompt_store::init(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); language_model::init_settings(cx); workspace::register_project_item::(cx); }); diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 9fed388cb8596096cf5b6dc64cceef31de6397fd..e1c26bc1a3078d18fa7f085271d9fef69d5f37e9 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -76,15 +76,16 @@ use prompt_store::{PromptBuilder, PromptStore, UserPromptId}; use rules_library::{RulesLibrary, open_rules_library}; use search::{BufferSearchBar, buffer_search}; use settings::{Settings, update_settings_file}; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ Button, Callout, CommonAnimationExt, ContextMenu, ContextMenuEntry, DocumentationSide, KeyBinding, PopoverMenu, PopoverMenuHandle, Tab, Tooltip, prelude::*, utils::WithRemSize, }; use util::{ResultExt as _, debug_panic}; use workspace::{ - CollaboratorId, DraggedSelection, DraggedTab, OpenResult, PathList, SerializedPathList, - ToggleWorkspaceSidebar, ToggleZoom, ToolbarItemView, Workspace, WorkspaceId, + CollaboratorId, DraggedSelection, DraggedTab, OpenMode, OpenResult, PathList, + SerializedPathList, ToggleWorkspaceSidebar, ToggleZoom, ToolbarItemView, Workspace, + WorkspaceId, dock::{DockPosition, Panel, PanelEvent}, }; use zed_actions::{ @@ -131,7 +132,6 @@ fn read_legacy_serialized_panel(kvp: &KeyValueStore) -> Option, selected_agent: Option, #[serde(default)] last_active_thread: Option, @@ -743,8 +743,7 @@ pub struct AgentPanel { agent_navigation_menu_handle: PopoverMenuHandle, agent_navigation_menu: Option>, _extension_subscription: Option, - width: Option, - height: Option, + _project_subscription: Subscription, zoomed: bool, pending_serialization: Option>>, onboarding: Entity, @@ -766,7 +765,6 @@ impl AgentPanel { return; }; - let width = self.width; let selected_agent_type = self.selected_agent_type.clone(); let start_thread_in = Some(self.start_thread_in); @@ -787,7 +785,6 @@ impl AgentPanel { save_serialized_panel( workspace_id, SerializedAgentPanel { - width, selected_agent: Some(selected_agent_type), last_active_thread, start_thread_in, @@ -876,7 +873,6 @@ impl AgentPanel { if let Some(serialized_panel) = &serialized_panel { panel.update(cx, |panel, cx| { - panel.width = serialized_panel.width.map(|w| w.round()); if let Some(selected_agent) = serialized_panel.selected_agent.clone() { panel.selected_agent_type = selected_agent; } @@ -1056,6 +1052,16 @@ impl AgentPanel { ); store }); + let _project_subscription = + cx.subscribe(&project, |this, _project, event, cx| match event { + project::Event::WorktreeAdded(_) + | project::Event::WorktreeRemoved(_) + | project::Event::WorktreeOrderChanged => { + this.update_thread_work_dirs(cx); + } + _ => {} + }); + let mut panel = Self { workspace_id, active_view, @@ -1079,8 +1085,7 @@ impl AgentPanel { agent_navigation_menu_handle: PopoverMenuHandle::default(), agent_navigation_menu: None, _extension_subscription: extension_subscription, - width: None, - height: None, + _project_subscription, zoomed: false, pending_serialization: None, onboarding, @@ -1187,7 +1192,8 @@ impl AgentPanel { } pub fn new_thread(&mut self, _action: &NewThread, window: &mut Window, cx: &mut Context) { - self.new_agent_thread(AgentType::NativeAgent, window, cx); + self.reset_start_thread_in_to_default(cx); + self.external_thread(None, None, None, None, None, true, window, cx); } fn new_native_agent_thread_from_summary( @@ -1631,17 +1637,17 @@ impl AgentPanel { let agent_buffer_font_size = ThemeSettings::get_global(cx).agent_buffer_font_size(cx) + delta; - let _ = settings - .theme - .agent_ui_font_size - .insert(f32::from(theme::clamp_font_size(agent_ui_font_size)).into()); + let _ = settings.theme.agent_ui_font_size.insert( + f32::from(theme_settings::clamp_font_size(agent_ui_font_size)).into(), + ); let _ = settings.theme.agent_buffer_font_size.insert( - f32::from(theme::clamp_font_size(agent_buffer_font_size)).into(), + f32::from(theme_settings::clamp_font_size(agent_buffer_font_size)) + .into(), ); }); } else { - theme::adjust_agent_ui_font_size(cx, |size| size + delta); - theme::adjust_agent_buffer_font_size(cx, |size| size + delta); + theme_settings::adjust_agent_ui_font_size(cx, |size| size + delta); + theme_settings::adjust_agent_buffer_font_size(cx, |size| size + delta); } } WhichFontSize::BufferFont => { @@ -1665,14 +1671,14 @@ impl AgentPanel { settings.theme.agent_buffer_font_size = None; }); } else { - theme::reset_agent_ui_font_size(cx); - theme::reset_agent_buffer_font_size(cx); + theme_settings::reset_agent_ui_font_size(cx); + theme_settings::reset_agent_buffer_font_size(cx); } } pub fn reset_agent_zoom(&mut self, _window: &mut Window, cx: &mut Context) { - theme::reset_agent_ui_font_size(cx); - theme::reset_agent_buffer_font_size(cx); + theme_settings::reset_agent_ui_font_size(cx); + theme_settings::reset_agent_buffer_font_size(cx); } pub fn toggle_zoom(&mut self, _: &ToggleZoom, window: &mut Window, cx: &mut Context) { @@ -1696,6 +1702,7 @@ impl AgentPanel { AgentConfiguration::new( fs, agent_server_store, + self.connection_store.clone(), context_server_store, self.context_server_registry.clone(), self.language_registry.clone(), @@ -1914,6 +1921,14 @@ impl AgentPanel { } } + pub fn workspace_id(&self) -> Option { + self.workspace_id + } + + pub fn background_threads(&self) -> &HashMap> { + &self.background_threads + } + pub fn active_conversation_view(&self) -> Option<&Entity> { match &self.active_view { ActiveView::AgentThread { conversation_view } => Some(conversation_view), @@ -1978,6 +1993,68 @@ impl AgentPanel { views } + fn update_thread_work_dirs(&self, cx: &mut Context) { + let new_work_dirs = self.project.read(cx).default_path_list(cx); + + // Only update the active thread and still-running background threads. + // Idle background threads have finished their work against the old + // worktree set and shouldn't have their metadata rewritten. + let mut root_threads: Vec> = Vec::new(); + + if let Some(conversation_view) = self.active_conversation_view() { + if let Some(connected) = conversation_view.read(cx).as_connected() { + for thread_view in connected.threads.values() { + let thread = &thread_view.read(cx).thread; + if thread.read(cx).parent_session_id().is_none() { + root_threads.push(thread.clone()); + } + } + } + } + + for conversation_view in self.background_threads.values() { + let Some(connected) = conversation_view.read(cx).as_connected() else { + continue; + }; + for thread_view in connected.threads.values() { + let thread = &thread_view.read(cx).thread; + let thread_ref = thread.read(cx); + if thread_ref.parent_session_id().is_some() { + continue; + } + if thread_ref.status() != acp_thread::ThreadStatus::Generating { + continue; + } + root_threads.push(thread.clone()); + } + } + + for thread in &root_threads { + thread.update(cx, |thread, _cx| { + thread.set_work_dirs(new_work_dirs.clone()); + }); + } + + if let Some(metadata_store) = + crate::thread_metadata_store::ThreadMetadataStore::try_global(cx) + { + metadata_store.update(cx, |store, cx| { + for thread in &root_threads { + let is_archived = store + .entry(thread.read(cx).session_id()) + .map(|t| t.archived) + .unwrap_or(false); + let metadata = crate::thread_metadata_store::ThreadMetadata::from_thread( + is_archived, + thread, + cx, + ); + store.save(metadata, cx); + } + }); + } + } + fn retain_running_thread(&mut self, old_view: ActiveView, cx: &mut Context) { let ActiveView::AgentThread { conversation_view } = old_view else { return; @@ -2240,6 +2317,10 @@ impl AgentPanel { AcpThreadViewEvent::FirstSendRequested { content } => { this.handle_first_send_requested(view.clone(), content.clone(), window, cx); } + AcpThreadViewEvent::MessageSentOrQueued => { + let session_id = view.read(cx).thread.read(cx).session_id().clone(); + cx.emit(AgentPanelEvent::MessageSentOrQueued { session_id }); + } }, ) }) @@ -2828,8 +2909,7 @@ impl AgentPanel { None => { this.update_in(cx, |this, window, cx| { this.set_worktree_creation_error( - "Failed to generate a branch name: all typewriter names are taken" - .into(), + "Failed to generate a unique branch name".into(), window, cx, ); @@ -2942,17 +3022,47 @@ impl AgentPanel { .. } = cx .update(|_window, cx| { - Workspace::new_local(all_paths, app_state, window_handle, None, None, false, cx) + Workspace::new_local( + all_paths, + app_state, + window_handle, + None, + None, + OpenMode::Add, + cx, + ) })? .await?; - let panels_task = new_window_handle.update(cx, |_, _, cx| { - new_workspace.update(cx, |workspace, _cx| workspace.take_panels_task()) - })?; + let panels_task = new_workspace.update(cx, |workspace, _cx| workspace.take_panels_task()); + if let Some(task) = panels_task { task.await.log_err(); } + new_workspace + .update(cx, |workspace, cx| { + workspace.project().read(cx).wait_for_initial_scan(cx) + }) + .await; + + new_workspace + .update(cx, |workspace, cx| { + let repos = workspace + .project() + .read(cx) + .repositories(cx) + .values() + .cloned() + .collect::>(); + + let tasks = repos + .into_iter() + .map(|repo| repo.update(cx, |repo, _| repo.barrier())); + futures::future::join_all(tasks) + }) + .await; + let initial_content = AgentInitialContent::ContentBlock { blocks: content, auto_submit: true, @@ -2973,8 +3083,7 @@ impl AgentPanel { } // If we had an active buffer, remap its path and reopen it. - let should_zoom_agent_panel = active_file_path.is_none(); - + let had_active_file = active_file_path.is_some(); let remapped_active_path = active_file_path.and_then(|original_path| { let best_match = path_remapping .iter() @@ -2997,7 +3106,7 @@ impl AgentPanel { None }); - if !should_zoom_agent_panel && remapped_active_path.is_none() { + if had_active_file && remapped_active_path.is_none() { log::warn!( "Active file could not be remapped to the new worktree; it will not be reopened" ); @@ -3026,13 +3135,7 @@ impl AgentPanel { // (equivalent to cmd-esc fullscreen behavior). // This must happen after focus_panel, which activates // and opens the panel in the dock. - if should_zoom_agent_panel { - if let Some(panel) = workspace.panel::(cx) { - panel.update(cx, |_panel, cx| { - cx.emit(PanelEvent::ZoomIn); - }); - } - } + if let Some(panel) = workspace.panel::(cx) { panel.update(cx, |panel, cx| { panel.external_thread( @@ -3050,8 +3153,8 @@ impl AgentPanel { }); })?; - new_window_handle.update(cx, |multi_workspace, _window, cx| { - multi_workspace.activate(new_workspace.clone(), cx); + new_window_handle.update(cx, |multi_workspace, window, cx| { + multi_workspace.activate(new_workspace.clone(), window, cx); })?; this.update_in(cx, |this, window, cx| { @@ -3105,6 +3208,7 @@ pub enum AgentPanelEvent { ActiveViewChanged, ThreadFocused, BackgroundThreadChanged, + MessageSentOrQueued { session_id: acp::SessionId }, } impl EventEmitter for AgentPanel {} @@ -3136,23 +3240,29 @@ impl Panel for AgentPanel { }); } - fn size(&self, window: &Window, cx: &App) -> Pixels { + fn default_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.height.unwrap_or(settings.default_height), + DockPosition::Left | DockPosition::Right => settings.default_width, + DockPosition::Bottom => 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.height = size, - } - self.serialize(cx); - cx.notify(); + fn supports_flexible_size(&self) -> bool { + true + } + + fn has_flexible_size(&self, _window: &Window, cx: &App) -> bool { + AgentSettings::get_global(cx).flexible + } + + fn set_flexible_size(&mut self, flexible: bool, _window: &mut Window, cx: &mut Context) { + settings::update_settings_file(self.fs.clone(), cx, move |settings, _| { + settings + .agent + .get_or_insert_default() + .set_flexible_size(flexible); + }); } fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context) { @@ -3185,13 +3295,17 @@ impl Panel for AgentPanel { } fn activation_priority(&self) -> u32 { - 3 + 0 } fn enabled(&self, cx: &App) -> bool { AgentSettings::get_global(cx).enabled(cx) } + fn is_agent_panel(&self) -> bool { + true + } + fn is_zoomed(&self, _window: &Window, _cx: &App) -> bool { self.zoomed } @@ -3822,8 +3936,6 @@ impl AgentPanel { } }), ) - .separator() - .header("External Agents") .map(|mut menu| { let agent_server_store = agent_server_store.read(cx); let registry_store = project::AgentRegistryStore::try_global(cx); @@ -3854,6 +3966,9 @@ impl AgentPanel { .sorted_unstable_by_key(|e| e.display_name.to_lowercase()) .collect::>(); + if !agent_items.is_empty() { + menu = menu.separator().header("External Agents"); + } for item in &agent_items { let mut entry = ContextMenuEntry::new(item.display_name.clone()); @@ -3984,6 +4099,8 @@ impl AgentPanel { let is_text_thread = matches!(&self.active_view, ActiveView::TextThread { .. }); + let is_full_screen = self.is_zoomed(window, cx); + let use_v2_empty_toolbar = has_v2_flag && is_empty_state && !is_in_history_or_config && !is_text_thread; @@ -4028,7 +4145,7 @@ impl AgentPanel { .trigger_with_tooltip(agent_selector_button, { move |_window, cx| { Tooltip::for_action_in( - "New Thread\u{2026}", + "New Thread…", &ToggleNewThreadMenu, &focus_handle, cx, @@ -4072,6 +4189,20 @@ impl AgentPanel { cx, )) }) + .when(is_full_screen, |this| { + this.child( + IconButton::new("disable-full-screen", IconName::Minimize) + .icon_size(IconSize::Small) + .tooltip(move |_, cx| { + Tooltip::for_action("Disable Full Screen", &ToggleZoom, cx) + }) + .on_click({ + cx.listener(move |_, _, window, cx| { + window.dispatch_action(ToggleZoom.boxed_clone(), cx); + }) + }), + ) + }) .child(self.render_panel_options_menu(window, cx)), ) .into_any_element() @@ -4124,6 +4255,20 @@ impl AgentPanel { cx, )) }) + .when(is_full_screen, |this| { + this.child( + IconButton::new("disable-full-screen", IconName::Minimize) + .icon_size(IconSize::Small) + .tooltip(move |_, cx| { + Tooltip::for_action("Disable Full Screen", &ToggleZoom, cx) + }) + .on_click({ + cx.listener(move |_, _, window, cx| { + window.dispatch_action(ToggleZoom.boxed_clone(), cx); + }) + }), + ) + }) .child(self.render_panel_options_menu(window, cx)), ) .into_any_element() @@ -5011,16 +5156,12 @@ mod tests { let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); - // --- Set up workspace A: width=300, with an active thread --- + // --- Set up workspace A: with an active thread --- let panel_a = workspace_a.update_in(cx, |workspace, window, cx| { let text_thread_store = cx.new(|cx| TextThreadStore::fake(project_a.clone(), cx)); cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx)) }); - panel_a.update(cx, |panel, _cx| { - panel.width = Some(px(300.0)); - }); - panel_a.update_in(cx, |panel, window, cx| { panel.open_external_thread_with_server( Rc::new(StubAgentServer::default_response()), @@ -5040,14 +5181,13 @@ mod tests { let agent_type_a = panel_a.read_with(cx, |panel, _cx| panel.selected_agent_type.clone()); - // --- Set up workspace B: ClaudeCode, width=400, no active thread --- + // --- Set up workspace B: ClaudeCode, no active thread --- let panel_b = workspace_b.update_in(cx, |workspace, window, cx| { let text_thread_store = cx.new(|cx| TextThreadStore::fake(project_b.clone(), cx)); cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx)) }); panel_b.update(cx, |panel, _cx| { - panel.width = Some(px(400.0)); panel.selected_agent_type = AgentType::Custom { id: "claude-acp".into(), }; @@ -5073,13 +5213,8 @@ mod tests { .expect("panel B load should succeed"); cx.run_until_parked(); - // Workspace A should restore its thread, width, and agent type + // Workspace A should restore its thread and agent type loaded_a.read_with(cx, |panel, _cx| { - assert_eq!( - panel.width, - Some(px(300.0)), - "workspace A width should be restored" - ); assert_eq!( panel.selected_agent_type, agent_type_a, "workspace A agent type should be restored" @@ -5090,13 +5225,8 @@ mod tests { ); }); - // Workspace B should restore its own width and agent type, with no thread + // Workspace B should restore its own agent type, with no thread loaded_b.read_with(cx, |panel, _cx| { - assert_eq!( - panel.width, - Some(px(400.0)), - "workspace B width should be restored" - ); assert_eq!( panel.selected_agent_type, AgentType::Custom { @@ -6249,4 +6379,202 @@ mod tests { "the new worktree workspace should use the same agent (Codex) that was selected in the original panel", ); } + + #[gpui::test] + async fn test_work_dirs_update_when_worktrees_change(cx: &mut TestAppContext) { + use crate::thread_metadata_store::ThreadMetadataStore; + + init_test(cx); + cx.update(|cx| { + cx.update_flags(true, vec!["agent-v2".to_string()]); + agent::ThreadStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + }); + + // Set up a project with one worktree. + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project_a", json!({ "file.txt": "" })) + .await; + let project = Project::test(fs.clone(), [Path::new("/project_a")], cx).await; + + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace + .read_with(cx, |mw, _cx| mw.workspace().clone()) + .unwrap(); + let mut cx = VisualTestContext::from_window(multi_workspace.into(), cx); + + let panel = workspace.update_in(&mut cx, |workspace, window, cx| { + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); + cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx)) + }); + + // Open thread A and send a message. With empty next_prompt_updates it + // stays generating, so opening B will move A to background_threads. + let connection_a = StubAgentConnection::new().with_agent_id("agent-a".into()); + open_thread_with_custom_connection(&panel, connection_a.clone(), &mut cx); + send_message(&panel, &mut cx); + let session_id_a = active_session_id(&panel, &cx); + + // Open thread C — thread A (generating) moves to background. + // Thread C completes immediately (idle), then opening B moves C to background too. + let connection_c = StubAgentConnection::new().with_agent_id("agent-c".into()); + connection_c.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("done".into()), + )]); + open_thread_with_custom_connection(&panel, connection_c.clone(), &mut cx); + send_message(&panel, &mut cx); + let session_id_c = active_session_id(&panel, &cx); + + // Snapshot thread C's initial work_dirs before adding worktrees. + let initial_c_paths = panel.read_with(&cx, |panel, cx| { + let thread = panel.active_agent_thread(cx).unwrap(); + thread.read(cx).work_dirs().cloned().unwrap() + }); + + // Open thread B — thread C (idle, non-loadable) is retained in background. + let connection_b = StubAgentConnection::new().with_agent_id("agent-b".into()); + open_thread_with_custom_connection(&panel, connection_b.clone(), &mut cx); + send_message(&panel, &mut cx); + let session_id_b = active_session_id(&panel, &cx); + + let metadata_store = cx.update(|_, cx| ThreadMetadataStore::global(cx)); + + panel.read_with(&cx, |panel, _cx| { + assert!( + panel.background_threads.contains_key(&session_id_a), + "Thread A should be in background_threads" + ); + assert!( + panel.background_threads.contains_key(&session_id_c), + "Thread C should be in background_threads" + ); + }); + + // Verify initial work_dirs for thread B contain only /project_a. + let initial_b_paths = panel.read_with(&cx, |panel, cx| { + let thread = panel.active_agent_thread(cx).unwrap(); + thread.read(cx).work_dirs().cloned().unwrap() + }); + assert_eq!( + initial_b_paths.ordered_paths().collect::>(), + vec![&PathBuf::from("/project_a")], + "Thread B should initially have only /project_a" + ); + + // Now add a second worktree to the project. + fs.insert_tree("/project_b", json!({ "other.txt": "" })) + .await; + let (new_tree, _) = project + .update(&mut cx, |project, cx| { + project.find_or_create_worktree("/project_b", true, cx) + }) + .await + .unwrap(); + cx.read(|cx| new_tree.read(cx).as_local().unwrap().scan_complete()) + .await; + cx.run_until_parked(); + + // Verify thread B's (active) work_dirs now include both worktrees. + let updated_b_paths = panel.read_with(&cx, |panel, cx| { + let thread = panel.active_agent_thread(cx).unwrap(); + thread.read(cx).work_dirs().cloned().unwrap() + }); + let mut b_paths_sorted = updated_b_paths.ordered_paths().cloned().collect::>(); + b_paths_sorted.sort(); + assert_eq!( + b_paths_sorted, + vec![PathBuf::from("/project_a"), PathBuf::from("/project_b")], + "Thread B work_dirs should include both worktrees after adding /project_b" + ); + + // Verify thread A's (background) work_dirs are also updated. + let updated_a_paths = panel.read_with(&cx, |panel, cx| { + let bg_view = panel.background_threads.get(&session_id_a).unwrap(); + let root_thread = bg_view.read(cx).root_thread(cx).unwrap(); + root_thread + .read(cx) + .thread + .read(cx) + .work_dirs() + .cloned() + .unwrap() + }); + let mut a_paths_sorted = updated_a_paths.ordered_paths().cloned().collect::>(); + a_paths_sorted.sort(); + assert_eq!( + a_paths_sorted, + vec![PathBuf::from("/project_a"), PathBuf::from("/project_b")], + "Thread A work_dirs should include both worktrees after adding /project_b" + ); + + // Verify thread C was NOT updated. + let unchanged_c_paths = panel.read_with(&cx, |panel, cx| { + let bg_view = panel.background_threads.get(&session_id_c).unwrap(); + let root_thread = bg_view.read(cx).root_thread(cx).unwrap(); + root_thread + .read(cx) + .thread + .read(cx) + .work_dirs() + .cloned() + .unwrap() + }); + assert_eq!( + unchanged_c_paths, initial_c_paths, + "Thread C (idle background) work_dirs should not change when worktrees change" + ); + + // Verify the metadata store reflects the new paths for running threads only. + cx.run_until_parked(); + for (label, session_id) in [("thread B", &session_id_b), ("thread A", &session_id_a)] { + let metadata_paths = metadata_store.read_with(&cx, |store, _cx| { + let metadata = store + .entry(session_id) + .unwrap_or_else(|| panic!("{label} thread metadata should exist")); + metadata.folder_paths.clone() + }); + let mut sorted = metadata_paths.ordered_paths().cloned().collect::>(); + sorted.sort(); + assert_eq!( + sorted, + vec![PathBuf::from("/project_a"), PathBuf::from("/project_b")], + "{label} thread metadata folder_paths should include both worktrees" + ); + } + + // Now remove a worktree and verify work_dirs shrink. + let worktree_b_id = new_tree.read_with(&cx, |tree, _| tree.id()); + project.update(&mut cx, |project, cx| { + project.remove_worktree(worktree_b_id, cx); + }); + cx.run_until_parked(); + + let after_remove_b = panel.read_with(&cx, |panel, cx| { + let thread = panel.active_agent_thread(cx).unwrap(); + thread.read(cx).work_dirs().cloned().unwrap() + }); + assert_eq!( + after_remove_b.ordered_paths().collect::>(), + vec![&PathBuf::from("/project_a")], + "Thread B work_dirs should revert to only /project_a after removing /project_b" + ); + + let after_remove_a = panel.read_with(&cx, |panel, cx| { + let bg_view = panel.background_threads.get(&session_id_a).unwrap(); + let root_thread = bg_view.read(cx).root_thread(cx).unwrap(); + root_thread + .read(cx) + .thread + .read(cx) + .work_dirs() + .cloned() + .unwrap() + }); + assert_eq!( + after_remove_a.ordered_paths().collect::>(), + vec![&PathBuf::from("/project_a")], + "Thread A work_dirs should revert to only /project_a after removing /project_b" + ); + } } diff --git a/crates/agent_ui/src/agent_registry_ui.rs b/crates/agent_ui/src/agent_registry_ui.rs index 6e8f9ddee30b1a72c1c5daee32fda24042ff7df7..78b4e3a5a3965c72b96d4ec201139b1d8e510fb2 100644 --- a/crates/agent_ui/src/agent_registry_ui.rs +++ b/crates/agent_ui/src/agent_registry_ui.rs @@ -12,7 +12,7 @@ use gpui::{ use project::agent_server_store::{AllAgentServersSettings, CustomAgentServerSettings}; use project::{AgentRegistryStore, RegistryAgent}; use settings::{Settings, SettingsStore, update_settings_file}; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ ButtonStyle, ScrollableHandle, ToggleButtonGroup, ToggleButtonGroupSize, ToggleButtonGroupStyle, ToggleButtonSimple, Tooltip, WithScrollbar, prelude::*, diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index db3b2526e45a4aade6d03a8d8ff87fd26106b76d..175f7e05f5ee824239fc179e12ca56aa9f2e1c74 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -33,6 +33,7 @@ mod text_thread_editor; mod text_thread_history; mod thread_history; mod thread_history_view; +mod thread_import; pub mod thread_metadata_store; pub mod threads_archive_view; mod ui; @@ -47,7 +48,7 @@ use client::Client; use command_palette_hooks::CommandPaletteFilter; use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt as _}; use fs::Fs; -use gpui::{Action, App, Context, Entity, SharedString, Window, actions}; +use gpui::{Action, App, Context, Entity, SharedString, UpdateGlobal as _, Window, actions}; use language::{ LanguageRegistry, language_settings::{AllLanguageSettings, EditPredictionProvider}, @@ -59,7 +60,7 @@ use project::{AgentId, DisableAiSettings}; use prompt_store::PromptBuilder; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use settings::{LanguageModelSelection, Settings as _, SettingsStore}; +use settings::{DockPosition, DockSide, LanguageModelSelection, Settings as _, SettingsStore}; use std::any::TypeId; use workspace::Workspace; @@ -81,6 +82,7 @@ pub(crate) use thread_history_view::*; use zed_actions; pub const DEFAULT_THREAD_TITLE: &str = "New Thread"; +const PARALLEL_AGENT_LAYOUT_BACKFILL_KEY: &str = "parallel_agent_layout_backfilled"; actions!( agent, @@ -252,6 +254,16 @@ pub enum Agent { }, } +impl From for Agent { + fn from(id: AgentId) -> Self { + if id.as_ref() == agent::ZED_AGENT_ID.as_ref() { + Self::NativeAgent + } else { + Self::Custom { id } + } + } +} + impl Agent { pub fn id(&self) -> AgentId { match self { @@ -343,6 +355,7 @@ pub fn init( client: Arc, prompt_builder: Arc, language_registry: Arc, + is_new_install: bool, is_eval: bool, cx: &mut App, ) { @@ -415,6 +428,65 @@ pub fn init( update_command_palette_filter(cx); }) .detach(); + + // TODO: remove this field when we're ready remove the feature flag + maybe_backfill_editor_layout(fs, is_new_install, false, cx); + + cx.observe_flag::(|is_enabled, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_default_settings(cx, |defaults| { + if is_enabled { + defaults.agent.get_or_insert_default().dock = Some(DockPosition::Left); + defaults.project_panel.get_or_insert_default().dock = Some(DockSide::Right); + defaults.outline_panel.get_or_insert_default().dock = Some(DockSide::Right); + defaults.collaboration_panel.get_or_insert_default().dock = + Some(DockPosition::Right); + defaults.git_panel.get_or_insert_default().dock = Some(DockPosition::Right); + defaults.notification_panel.get_or_insert_default().button = Some(false); + } else { + defaults.agent.get_or_insert_default().dock = Some(DockPosition::Right); + defaults.project_panel.get_or_insert_default().dock = Some(DockSide::Left); + defaults.outline_panel.get_or_insert_default().dock = Some(DockSide::Left); + defaults.collaboration_panel.get_or_insert_default().dock = + Some(DockPosition::Left); + defaults.git_panel.get_or_insert_default().dock = Some(DockPosition::Left); + defaults.notification_panel.get_or_insert_default().button = Some(true); + } + }); + }); + }) + .detach(); +} + +fn maybe_backfill_editor_layout( + fs: Arc, + is_new_install: bool, + should_run: bool, + cx: &mut App, +) { + if !should_run { + return; + } + + let kvp = db::kvp::KeyValueStore::global(cx); + let already_backfilled = + util::ResultExt::log_err(kvp.read_kvp(PARALLEL_AGENT_LAYOUT_BACKFILL_KEY)) + .flatten() + .is_some(); + + if !already_backfilled { + if !is_new_install { + AgentSettings::backfill_editor_layout(fs, cx); + } + + db::write_and_log(cx, move || async move { + kvp.write_kvp( + PARALLEL_AGENT_LAYOUT_BACKFILL_KEY.to_string(), + "1".to_string(), + ) + .await + }); + } } fn update_command_palette_filter(cx: &mut App) { @@ -477,7 +549,6 @@ fn update_command_palette_filter(cx: &mut App) { | EditPredictionProvider::Codestral | EditPredictionProvider::Ollama | EditPredictionProvider::OpenAiCompatibleApi - | EditPredictionProvider::Sweep | EditPredictionProvider::Mercury | EditPredictionProvider::Experimental(_) => { filter.show_namespace("edit_prediction"); @@ -589,7 +660,9 @@ mod tests { use super::*; use agent_settings::{AgentProfileId, AgentSettings}; use command_palette_hooks::CommandPaletteFilter; + use db::kvp::KeyValueStore; use editor::actions::AcceptEditPrediction; + use feature_flags::FeatureFlagAppExt; use gpui::{BorrowAppContext, TestAppContext, px}; use project::DisableAiSettings; use settings::{ @@ -612,6 +685,7 @@ mod tests { enabled: true, button: true, dock: DockPosition::Right, + flexible: true, default_width: px(300.), default_height: px(600.), default_model: None, @@ -624,7 +698,6 @@ mod tests { default_profile: AgentProfileId::default(), default_view: DefaultAgentView::Thread, profiles: Default::default(), - notify_when_agent_waiting: NotifyWhenAgentWaiting::default(), play_sound_when_agent_done: false, single_file_review: false, @@ -638,6 +711,8 @@ mod tests { tool_permissions: Default::default(), show_turn_stats: false, new_thread_location: Default::default(), + sidebar_side: Default::default(), + thinking_display: Default::default(), }; cx.update(|cx| { @@ -730,6 +805,100 @@ mod tests { }); } + async fn setup_backfill_test(cx: &mut TestAppContext) -> Arc { + let fs = fs::FakeFs::new(cx.background_executor.clone()); + fs.save( + paths::settings_file().as_path(), + &"{}".into(), + Default::default(), + ) + .await + .unwrap(); + + cx.update(|cx| { + let store = SettingsStore::test(cx); + cx.set_global(store); + AgentSettings::register(cx); + DisableAiSettings::register(cx); + cx.set_staff(true); + }); + + fs + } + + #[gpui::test] + async fn test_backfill_sets_kvp_flag(cx: &mut TestAppContext) { + let fs = setup_backfill_test(cx).await; + + cx.update(|cx| { + let kvp = KeyValueStore::global(cx); + assert!( + kvp.read_kvp(PARALLEL_AGENT_LAYOUT_BACKFILL_KEY) + .unwrap() + .is_none() + ); + + maybe_backfill_editor_layout(fs.clone(), false, true, cx); + }); + + cx.run_until_parked(); + + let kvp = cx.update(|cx| KeyValueStore::global(cx)); + assert!( + kvp.read_kvp(PARALLEL_AGENT_LAYOUT_BACKFILL_KEY) + .unwrap() + .is_some(), + "flag should be set after backfill" + ); + } + + #[gpui::test] + async fn test_backfill_new_install_sets_flag_without_writing_settings(cx: &mut TestAppContext) { + let fs = setup_backfill_test(cx).await; + + cx.update(|cx| { + maybe_backfill_editor_layout(fs.clone(), true, true, cx); + }); + + cx.run_until_parked(); + + let kvp = cx.update(|cx| KeyValueStore::global(cx)); + assert!( + kvp.read_kvp(PARALLEL_AGENT_LAYOUT_BACKFILL_KEY) + .unwrap() + .is_some(), + "flag should be set even for new installs" + ); + + let written = fs.load(paths::settings_file().as_path()).await.unwrap(); + assert_eq!(written.trim(), "{}", "settings file should be unchanged"); + } + + #[gpui::test] + async fn test_backfill_is_idempotent(cx: &mut TestAppContext) { + let fs = setup_backfill_test(cx).await; + + cx.update(|cx| { + maybe_backfill_editor_layout(fs.clone(), false, true, cx); + }); + + cx.run_until_parked(); + + let after_first = fs.load(paths::settings_file().as_path()).await.unwrap(); + + cx.update(|cx| { + maybe_backfill_editor_layout(fs.clone(), false, true, cx); + }); + + cx.run_until_parked(); + + let after_second = fs.load(paths::settings_file().as_path()).await.unwrap(); + assert_eq!( + after_first, after_second, + "second call should not change settings" + ); + } + #[test] fn test_deserialize_external_agent_variants() { assert_eq!( diff --git a/crates/agent_ui/src/branch_names.rs b/crates/agent_ui/src/branch_names.rs index 74e3dbc76b729309403606dfbecc8ea87f271913..74a934e95d6219382312bdb49e7e28501a6a2517 100644 --- a/crates/agent_ui/src/branch_names.rs +++ b/crates/agent_ui/src/branch_names.rs @@ -1,710 +1,77 @@ use collections::HashSet; use rand::Rng; -/// Names of historical typewriter brands, for use in auto-generated branch names. -/// (Hyphens and parens have been dropped so that the branch names are one-word.) -/// -/// Thanks to https://typewriterdatabase.com/alph.0.brands for the names! -const TYPEWRITER_NAMES: &[&str] = &[ - "abeille", - "acme", - "addo", - "adler", - "adlerette", - "adlerita", - "admiral", - "agamli", - "agar", - "agidel", - "agil", - "aguia", - "aguila", - "ahram", - "aigle", - "ajax", - "aktiv", - "ala", - "alba", - "albus", - "alexander", - "alexis", - "alfa", - "allen", - "alonso", - "alpina", - "amata", - "amaya", - "amka", - "anavi", - "anderson", - "andina", - "antares", - "apex", - "apsco", - "aquila", - "archo", - "ardita", - "argyle", - "aristocrat", - "aristokrat", - "arlington", - "armstrong", - "arpha", - "artus", - "astoria", - "atlantia", - "atlantic", - "atlas", - "augusta", - "aurora", - "austro", - "automatic", - "avanti", - "avona", - "azzurra", - "bajnok", - "baldwin", - "balkan", - "baltica", - "baltimore", - "barlock", - "barr", - "barrat", - "bartholomew", - "bashkiriya", - "bavaria", - "beaucourt", - "beko", - "belka", - "bennett", - "bennington", - "berni", - "bianca", - "bijou", - "bing", - "bisei", - "biser", - "bluebird", - "bolida", - "borgo", - "boston", - "boyce", - "bradford", - "brandenburg", - "brigitte", - "briton", - "brooks", - "brosette", - "buddy", - "burns", - "burroughs", - "byron", - "calanda", - "caligraph", - "cappel", - "cardinal", - "carissima", - "carlem", - "carlton", - "carmen", - "cawena", - "cella", - "celtic", - "century", - "champignon", - "cherryland", - "chevron", - "chicago", - "cicero", - "cifra", - "citizen", - "claudia", - "cleveland", - "clover", - "coffman", - "cole", - "columbia", - "commercial", - "companion", - "concentra", - "concord", - "concordia", - "conover", - "constanta", - "consul", - "conta", - "contenta", - "contimat", - "contina", - "continento", - "cornelia", - "coronado", - "cosmopolita", - "courier", - "craftamatic", - "crandall", - "crown", - "culema", - "dactyle", - "dankers", - "dart", - "daugherty", - "davis", - "dayton", - "dea", - "delmar", - "densmore", - "depantio", - "diadema", - "dial", - "diamant", - "diana", - "dictatype", - "diplomat", - "diskret", - "dolfus", - "dollar", - "domus", - "drake", - "draper", - "duplex", - "durabel", - "dynacord", - "eagle", - "eclipse", - "edelmann", - "edelweiss", - "edison", - "edita", - "edland", - "efka", - "eldorado", - "electa", - "electromatic", - "elektro", - "elgin", - "elliot", - "emerson", - "emka", - "emona", - "empire", - "engadine", - "engler", - "erfurt", - "erika", - "esko", - "essex", - "eureka", - "europa", - "everest", - "everlux", - "excelsior", - "express", - "fabers", - "facit", - "fairbanks", - "faktotum", - "famos", - "federal", - "felio", - "fidat", - "filius", - "fips", - "fish", - "fitch", - "fleet", - "florida", - "flott", - "flyer", - "flying", - "fontana", - "ford", - "forto", - "fortuna", - "fox", - "framo", - "franconia", - "franklin", - "friden", - "frolio", - "furstenberg", - "galesburg", - "galiette", - "gallia", - "garbell", - "gardner", - "geka", - "generation", - "genia", - "geniatus", - "gerda", - "gisela", - "glashutte", - "gloria", - "godrej", - "gossen", - "gourland", - "grandjean", - "granta", - "granville", - "graphic", - "gritzner", - "groma", - "guhl", - "guidonia", - "gundka", - "hacabo", - "haddad", - "halberg", - "halda", - "hall", - "hammond", - "hammonia", - "hanford", - "hansa", - "harmony", - "harris", - "hartford", - "hassia", - "hatch", - "heady", - "hebronia", - "hebros", - "hega", - "helios", - "helma", - "herald", - "hercules", - "hermes", - "herold", - "heros", - "hesperia", - "hogar", - "hooven", - "hopkins", - "horton", - "hugin", - "hungaria", - "hurtu", - "iberia", - "idea", - "ideal", - "imperia", - "impo", - "industria", - "industrio", - "ingersoll", - "international", - "invicta", - "irene", - "iris", - "iskra", - "ivitsa", - "ivriah", - "jackson", - "janalif", - "janos", - "jolux", - "juki", - "junior", - "juventa", - "juwel", - "kamkap", - "kamo", - "kanzler", - "kappel", - "karli", - "karstadt", - "keaton", - "kenbar", - "keystone", - "kim", - "klein", - "kneist", - "knoch", - "koh", - "kolibri", - "kolumbus", - "komet", - "kondor", - "koniger", - "konryu", - "kontor", - "kosmopolit", - "krypton", - "lambert", - "lasalle", - "lectra", - "leframa", - "lemair", - "lemco", - "liberty", - "libia", - "liga", - "lignose", - "lilliput", - "lindeteves", - "linowriter", - "listvitsa", - "ludolf", - "lutece", - "luxa", - "lyubava", - "mafra", - "magnavox", - "maher", - "majestic", - "majitouch", - "manhattan", - "mapuua", - "marathon", - "marburger", - "maritsa", - "maruzen", - "maskelyne", - "masspro", - "matous", - "mccall", - "mccool", - "mcloughlin", - "mead", - "mechno", - "mehano", - "meiselbach", - "melbi", - "melior", - "melotyp", - "mentor", - "mepas", - "mercedesia", - "mercurius", - "mercury", - "merkur", - "merritt", - "merz", - "messa", - "meteco", - "meteor", - "micron", - "mignon", - "mikro", - "minerva", - "mirian", - "mirina", - "mitex", - "molle", - "monac", - "monarch", - "mondiale", - "monica", - "monofix", - "monopol", - "monpti", - "monta", - "montana", - "montgomery", - "moon", - "morgan", - "morris", - "morse", - "moya", - "moyer", - "munson", - "musicwriter", - "nadex", - "nakajima", - "neckermann", - "neubert", - "neya", - "ninety", - "nisa", - "noiseless", - "noor", - "nora", - "nord", - "norden", - "norica", - "norma", - "norman", - "north", - "nototyp", - "nova", - "novalevi", - "odell", - "odhner", - "odo", - "odoma", - "ohio", - "ohtani", - "oliva", - "oliver", - "olivetti", - "olympia", - "omega", - "optima", - "orbis", - "orel", - "orga", - "oriette", - "orion", - "orn", - "orplid", - "pacior", - "pagina", - "parisienne", - "passat", - "pearl", - "peerless", - "perfect", - "perfecta", - "perkeo", - "perkins", - "perlita", - "pettypet", - "phoenix", - "piccola", - "picht", - "pinnock", - "pionier", - "plurotyp", - "plutarch", - "pneumatic", - "pocket", - "polyglott", - "polygraph", - "pontiac", - "portable", - "portex", - "pozzi", - "premier", - "presto", - "primavera", - "progress", - "protos", - "pterotype", - "pullman", - "pulsatta", - "quick", - "racer", - "radio", - "rally", - "rand", - "readers", - "reed", - "referent", - "reff", - "regent", - "regia", - "regina", - "rekord", - "reliable", - "reliance", - "remagg", - "rembrandt", - "remer", - "remington", - "remsho", - "remstar", - "remtor", - "reporters", - "resko", - "rex", - "rexpel", - "rheinita", - "rheinmetall", - "rival", - "roberts", - "robotron", - "rocher", - "rochester", - "roebuck", - "rofa", - "roland", - "rooy", - "rover", - "roxy", - "roy", - "royal", - "rundstatler", - "sabaudia", - "sabb", - "saleem", - "salter", - "sampo", - "sarafan", - "saturn", - "saxonia", - "schade", - "schapiro", - "schreibi", - "scripta", - "sears", - "secor", - "selectric", - "selekta", - "senator", - "sense", - "senta", - "serd", - "shilling", - "shimade", - "shimer", - "sholes", - "shuang", - "siegfried", - "siemag", - "silma", - "silver", - "simplex", - "simtype", - "singer", - "smith", - "soemtron", - "sonja", - "speedwriter", - "sphinx", - "starlet", - "stearns", - "steel", - "stella", - "steno", - "sterling", - "stoewer", - "stolzenberg", - "stott", - "strangfeld", - "sture", - "stylotyp", - "sun", - "superba", - "superia", - "supermetall", - "surety", - "swintec", - "swissa", - "talbos", - "talleres", - "tatrapoint", - "taurus", - "taylorix", - "tell", - "tempotype", - "tippco", - "titania", - "tops", - "towa", - "toyo", - "tradition", - "transatlantic", - "traveller", - "trebla", - "triumph", - "turia", - "typatune", - "typen", - "typorium", - "ugro", - "ultima", - "unda", - "underwood", - "unica", - "unitype", - "ursula", - "utax", - "varityper", - "vasanta", - "vendex", - "venus", - "victor", - "victoria", - "video", - "viking", - "vira", - "virotyp", - "visigraph", - "vittoria", - "volcan", - "vornado", - "voss", - "vultur", - "waltons", - "wanamaker", - "wanderer", - "ward", - "warner", - "waterloo", - "waverley", - "wayne", - "webster", - "wedgefield", - "welco", - "wellington", - "wellon", - "weltblick", - "westphalia", - "wiedmer", - "williams", - "wilson", - "winkel", - "winsor", - "wizard", - "woodstock", - "woodwards", - "yatran", - "yost", - "zenit", - "zentronik", - "zeta", - "zeya", +const ADJECTIVES: &[&str] = &[ + "able", "agate", "agile", "alpine", "amber", "ample", "aqua", "arctic", "arid", "astral", + "autumn", "avid", "azure", "balmy", "birch", "bold", "boreal", "brave", "breezy", "brief", + "bright", "brisk", "broad", "bronze", "calm", "cerith", "civil", "clean", "clear", "clever", + "cobalt", "cool", "copper", "coral", "cozy", "crisp", "cubic", "cyan", "deft", "dense", "dewy", + "direct", "dusky", "dusty", "eager", "early", "earnest", "elder", "elfin", "equal", "even", + "exact", "faint", "fair", "fast", "fawn", "ferny", "fiery", "fine", "firm", "fleet", "floral", + "focal", "fond", "frank", "fresh", "frosty", "full", "gentle", "gilded", "glacial", "glad", + "glossy", "golden", "grand", "green", "gusty", "hale", "happy", "hardy", "hazel", "hearty", + "hilly", "humble", "hushed", "icy", "ideal", "inner", "iron", "ivory", "jade", "jovial", + "keen", "kind", "lapis", "leafy", "level", "light", "lilac", "limber", "lively", "local", + "lofty", "lucid", "lunar", "major", "maple", "mellow", "merry", "mild", "milky", "misty", + "modal", "modest", "mossy", "muted", "native", "naval", "neat", "nimble", "noble", "north", + "novel", "oaken", "ochre", "olive", "onyx", "opal", "open", "optic", "outer", "owed", "ozone", + "pale", "pastel", "pearl", "pecan", "peppy", "pilot", "placid", "plain", "plum", "plush", + "poised", "polar", "polished", "poplar", "prime", "proof", "proud", "pure", "quartz", "quick", + "quiet", "rapid", "raspy", "ready", "regal", "rooted", "rosy", "round", "royal", "ruby", + "ruddy", "russet", "rustic", "sage", "salty", "sandy", "satin", "scenic", "sedge", "serene", + "sharp", "sheer", "silky", "silver", "sleek", "smart", "smooth", "snowy", "solar", "solid", + "south", "spry", "stark", "steady", "steel", "steep", "still", "stoic", "stony", "stout", + "sturdy", "suede", "sunny", "supple", "sure", "swift", "tall", "tawny", "teal", "terse", + "thick", "tidal", "tidy", "timber", "topaz", "total", "trim", "tropic", "true", "tulip", + "upper", "urban", "valid", "vast", "velvet", "verde", "vivid", "vocal", "warm", "waxen", + "west", "whole", "wide", "wild", "wise", "witty", "woven", "young", "zealous", "zephyr", + "zesty", "zinc", ]; -/// Picks a typewriter name that isn't already taken by an existing branch. -/// -/// Each entry in `existing_branches` is expected to be a full branch name -/// like `"olivetti-a3f9b2c1"`. The prefix before the last `'-'` is treated -/// as the taken typewriter name. Branches without a `'-'` are ignored. +const NOUNS: &[&str] = &[ + "anchor", "anvil", "arbor", "arch", "arrow", "atlas", "badge", "badger", "basin", "bay", + "beacon", "beam", "bell", "birch", "blade", "bloom", "bluff", "bolt", "bower", "breeze", + "bridge", "brook", "bunting", "cabin", "cairn", "canyon", "cape", "cedar", "chasm", "cliff", + "cloud", "clover", "coast", "cobble", "colt", "comet", "condor", "coral", "cove", "crane", + "crater", "creek", "crest", "curlew", "cypress", "dale", "dawn", "delta", "den", "dove", + "drake", "drift", "drum", "dune", "dusk", "eagle", "echo", "egret", "elk", "elm", "ember", + "falcon", "fawn", "fern", "ferry", "field", "finch", "fjord", "flame", "flint", "flower", + "forge", "fossil", "fox", "frost", "gale", "garnet", "gate", "gazelle", "geyser", "glade", + "glen", "gorge", "granite", "grove", "gull", "harbor", "hare", "haven", "hawk", "hazel", + "heath", "hedge", "heron", "hill", "hollow", "horizon", "ibis", "inlet", "isle", "ivy", + "jackal", "jasper", "juniper", "kestrel", "kinglet", "knoll", "lagoon", "lake", "lantern", + "larch", "lark", "laurel", "lava", "leaf", "ledge", "lily", "linden", "lodge", "loft", "lotus", + "lynx", "mantle", "maple", "marble", "marsh", "marten", "meadow", "merlin", "mesa", "mill", + "mint", "moon", "moose", "moss", "newt", "north", "nutmeg", "oak", "oasis", "obsidian", + "orbit", "orchid", "oriole", "osprey", "otter", "owl", "palm", "panther", "pass", "path", + "peak", "pebble", "pelican", "peony", "perch", "pier", "pine", "plover", "plume", "pond", + "poppy", "prairie", "prism", "puma", "quail", "quarry", "quartz", "rain", "rampart", "range", + "raven", "ravine", "reed", "reef", "ridge", "river", "robin", "rowan", "sage", "salmon", + "sequoia", "shore", "shrike", "sigma", "sky", "slate", "slope", "snow", "spark", "sparrow", + "spider", "spruce", "stag", "star", "stone", "stork", "storm", "stream", "summit", "swift", + "sycamore", "tern", "terrace", "thistle", "thorn", "thrush", "tide", "timber", "torch", + "tower", "trail", "trout", "tulip", "tundra", "vale", "valley", "veranda", "viper", "vista", + "vole", "walrus", "warbler", "willow", "wolf", "wren", "yew", "zenith", +]; + +/// Generates a branch name in `"adjective-noun"` format (e.g. `"swift-falcon"`). /// -/// Returns `None` when every name in the pool is already taken. -pub fn pick_typewriter_name( - existing_branches: &[&str], - rng: &mut impl Rng, -) -> Option<&'static str> { - let disallowed: HashSet<&str> = existing_branches - .iter() - .filter_map(|branch| branch.rsplit_once('-').map(|(prefix, _)| prefix)) - .collect(); +/// Tries up to 100 random combinations, skipping any name that already appears +/// in `existing_branches`. Returns `None` if no unused name is found. +pub fn generate_branch_name(existing_branches: &[&str], rng: &mut impl Rng) -> Option { + let existing: HashSet<&str> = existing_branches.iter().copied().collect(); - let available: Vec<&'static str> = TYPEWRITER_NAMES - .iter() - .copied() - .filter(|name| !disallowed.contains(name)) - .collect(); + for _ in 0..100 { + let adjective = ADJECTIVES[rng.random_range(0..ADJECTIVES.len())]; + let noun = NOUNS[rng.random_range(0..NOUNS.len())]; + let name = format!("{adjective}-{noun}"); - if available.is_empty() { - return None; + if !existing.contains(name.as_str()) { + return Some(name); + } } - let index = rng.random_range(0..available.len()); - Some(available[index]) -} - -/// Generates a branch name like `"olivetti-a3f9b2c1"` by picking a typewriter -/// name that isn't already taken and appending an 8-character alphanumeric hash. -/// -/// Returns `None` when every typewriter name in the pool is already taken. -pub fn generate_branch_name(existing_branches: &[&str], rng: &mut impl Rng) -> Option { - let typewriter_name = pick_typewriter_name(existing_branches, rng)?; - let hash: String = (0..8) - .map(|_| { - let idx: u8 = rng.random_range(0..36); - if idx < 10 { - (b'0' + idx) as char - } else { - (b'a' + idx - 10) as char - } - }) - .collect(); - Some(format!("{typewriter_name}-{hash}")) + None } #[cfg(test)] @@ -713,134 +80,91 @@ mod tests { use rand::rngs::StdRng; #[gpui::test(iterations = 10)] - fn test_pick_typewriter_name_with_no_disallowed(mut rng: StdRng) { - let name = pick_typewriter_name(&[], &mut rng); - assert!(name.is_some()); - assert!(TYPEWRITER_NAMES.contains(&name.unwrap())); - } - - #[gpui::test(iterations = 10)] - fn test_pick_typewriter_name_excludes_taken_names(mut rng: StdRng) { - let branch_names = &["olivetti-abc12345", "selectric-def67890"]; - let name = pick_typewriter_name(branch_names, &mut rng).unwrap(); - assert_ne!(name, "olivetti"); - assert_ne!(name, "selectric"); - } - - #[gpui::test] - fn test_pick_typewriter_name_all_taken(mut rng: StdRng) { - let branch_names: Vec = TYPEWRITER_NAMES - .iter() - .map(|name| format!("{name}-00000000")) - .collect(); - let branch_name_refs: Vec<&str> = branch_names.iter().map(|s| s.as_str()).collect(); - let name = pick_typewriter_name(&branch_name_refs, &mut rng); - assert!(name.is_none()); - } - - #[gpui::test(iterations = 10)] - fn test_pick_typewriter_name_ignores_branches_without_hyphen(mut rng: StdRng) { - let branch_names = &["main", "develop", "feature"]; - let name = pick_typewriter_name(branch_names, &mut rng); - assert!(name.is_some()); - assert!(TYPEWRITER_NAMES.contains(&name.unwrap())); + fn test_generate_branch_name_format(mut rng: StdRng) { + let name = generate_branch_name(&[], &mut rng).unwrap(); + let (adjective, noun) = name.split_once('-').expect("name should contain a hyphen"); + assert!( + ADJECTIVES.contains(&adjective), + "{adjective:?} is not in ADJECTIVES" + ); + assert!(NOUNS.contains(&noun), "{noun:?} is not in NOUNS"); } - #[gpui::test(iterations = 10)] - fn test_generate_branch_name_format(mut rng: StdRng) { - let branch_name = generate_branch_name(&[], &mut rng).unwrap(); - let (prefix, suffix) = branch_name.rsplit_once('-').unwrap(); - assert!(TYPEWRITER_NAMES.contains(&prefix)); - assert_eq!(suffix.len(), 8); - assert!(suffix.chars().all(|c| c.is_ascii_alphanumeric())); + #[gpui::test(iterations = 100)] + fn test_generate_branch_name_avoids_existing(mut rng: StdRng) { + let existing = &["swift-falcon", "calm-river", "bold-cedar"]; + let name = generate_branch_name(existing, &mut rng).unwrap(); + for &branch in existing { + assert_ne!( + name, branch, + "generated name should not match an existing branch" + ); + } } #[gpui::test] - fn test_generate_branch_name_returns_none_when_exhausted(mut rng: StdRng) { - let branch_names: Vec = TYPEWRITER_NAMES + fn test_generate_branch_name_returns_none_when_stuck(mut rng: StdRng) { + let all_names: Vec = ADJECTIVES .iter() - .map(|name| format!("{name}-00000000")) + .flat_map(|adj| NOUNS.iter().map(move |noun| format!("{adj}-{noun}"))) .collect(); - let branch_name_refs: Vec<&str> = branch_names.iter().map(|s| s.as_str()).collect(); - let result = generate_branch_name(&branch_name_refs, &mut rng); + let refs: Vec<&str> = all_names.iter().map(|s| s.as_str()).collect(); + let result = generate_branch_name(&refs, &mut rng); assert!(result.is_none()); } - #[gpui::test(iterations = 100)] - fn test_generate_branch_name_never_reuses_taken_prefix(mut rng: StdRng) { - let existing = &["olivetti-123abc", "selectric-def456"]; - let branch_name = generate_branch_name(existing, &mut rng).unwrap(); - let (prefix, _) = branch_name.rsplit_once('-').unwrap(); - assert_ne!(prefix, "olivetti"); - assert_ne!(prefix, "selectric"); - } + #[test] + fn test_adjectives_are_valid() { + let mut seen = HashSet::default(); + for &word in ADJECTIVES { + assert!(seen.insert(word), "duplicate entry in ADJECTIVES: {word:?}"); + } - #[gpui::test(iterations = 100)] - fn test_generate_branch_name_avoids_multiple_taken_prefixes(mut rng: StdRng) { - let existing = &[ - "olivetti-aaa11111", - "selectric-bbb22222", - "corona-ccc33333", - "remington-ddd44444", - "underwood-eee55555", - ]; - let taken_prefixes: HashSet<&str> = existing - .iter() - .filter_map(|b| b.rsplit_once('-').map(|(prefix, _)| prefix)) - .collect(); - let branch_name = generate_branch_name(existing, &mut rng).unwrap(); - let (prefix, _) = branch_name.rsplit_once('-').unwrap(); - assert!( - !taken_prefixes.contains(prefix), - "generated prefix {prefix:?} collides with an existing branch" - ); - } + for window in ADJECTIVES.windows(2) { + assert!( + window[0] < window[1], + "ADJECTIVES is not sorted: {0:?} should come before {1:?}", + window[0], + window[1], + ); + } - #[gpui::test(iterations = 100)] - fn test_generate_branch_name_with_varied_hash_suffixes(mut rng: StdRng) { - let existing = &[ - "olivetti-aaaaaaaa", - "olivetti-bbbbbbbb", - "olivetti-cccccccc", - ]; - let branch_name = generate_branch_name(existing, &mut rng).unwrap(); - let (prefix, _) = branch_name.rsplit_once('-').unwrap(); - assert_ne!( - prefix, "olivetti", - "should avoid olivetti regardless of how many variants exist" - ); + for &word in ADJECTIVES { + assert!( + !word.contains('-'), + "ADJECTIVES entry contains a hyphen: {word:?}" + ); + assert!( + word.chars().all(|c| c.is_lowercase()), + "ADJECTIVES entry is not all lowercase: {word:?}" + ); + } } #[test] - fn test_typewriter_names_are_valid() { + fn test_nouns_are_valid() { let mut seen = HashSet::default(); - for &name in TYPEWRITER_NAMES { - assert!( - seen.insert(name), - "duplicate entry in TYPEWRITER_NAMES: {name:?}" - ); + for &word in NOUNS { + assert!(seen.insert(word), "duplicate entry in NOUNS: {word:?}"); } - for window in TYPEWRITER_NAMES.windows(2) { + for window in NOUNS.windows(2) { assert!( - window[0] <= window[1], - "TYPEWRITER_NAMES is not sorted: {0:?} should come after {1:?}", - window[1], + window[0] < window[1], + "NOUNS is not sorted: {0:?} should come before {1:?}", window[0], + window[1], ); } - for &name in TYPEWRITER_NAMES { + for &word in NOUNS { assert!( - !name.contains('-'), - "TYPEWRITER_NAMES entry contains a hyphen: {name:?}" + !word.contains('-'), + "NOUNS entry contains a hyphen: {word:?}" ); - } - - for &name in TYPEWRITER_NAMES { assert!( - name.chars().all(|c| c.is_lowercase() || !c.is_alphabetic()), - "TYPEWRITER_NAMES entry is not lowercase: {name:?}" + word.chars().all(|c| c.is_lowercase()), + "NOUNS entry is not all lowercase: {word:?}" ); } } diff --git a/crates/agent_ui/src/buffer_codegen.rs b/crates/agent_ui/src/buffer_codegen.rs index 4f7bf084b7e96a14e6ecaafb04adfdbb6712e574..9857dd8a4752e567b6c22ee0fb5932c79a15d82a 100644 --- a/crates/agent_ui/src/buffer_codegen.rs +++ b/crates/agent_ui/src/buffer_codegen.rs @@ -1,9 +1,6 @@ use crate::{context::LoadedContext, inline_prompt_editor::CodegenStatus}; use agent_settings::AgentSettings; use anyhow::{Context as _, Result}; -use uuid::Uuid; - -use cloud_llm_client::CompletionIntent; use collections::HashSet; use editor::{Anchor, AnchorRangeExt, MultiBuffer, MultiBufferSnapshot, ToOffset as _, ToPoint}; use futures::{ @@ -16,7 +13,7 @@ use futures::{ use gpui::{App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Subscription, Task}; use language::{Buffer, IndentKind, LanguageName, Point, TransactionId, line_diff}; use language_model::{ - LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, + CompletionIntent, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, LanguageModelRequestTool, LanguageModelTextStream, LanguageModelToolChoice, LanguageModelToolUse, Role, TokenUsage, @@ -40,6 +37,7 @@ use std::{ time::Instant, }; use streaming_diff::{CharOperation, LineDiff, LineOperation, StreamingDiff}; +use uuid::Uuid; /// Use this tool when you cannot or should not make a rewrite. This includes: /// - The user's request is unclear, ambiguous, or nonsensical diff --git a/crates/agent_ui/src/completion_provider.rs b/crates/agent_ui/src/completion_provider.rs index 1c81ae85845e2c8a92f9c5dff9eb7d2d35ccd98b..b6be6502b152847822a79bc8c486195345c0a195 100644 --- a/crates/agent_ui/src/completion_provider.rs +++ b/crates/agent_ui/src/completion_provider.rs @@ -874,7 +874,7 @@ impl PromptCompletionProvider { let project = workspace.read(cx).project().clone(); let repo = project.read(cx).active_repository(cx)?; - let default_branch_receiver = repo.update(cx, |repo, _| repo.default_branch(false)); + let default_branch_receiver = repo.update(cx, |repo, _| repo.default_branch(true)); Some(cx.spawn(async move |_cx| { let base_ref = default_branch_receiver @@ -2577,7 +2577,7 @@ mod tests { let app_state = cx.update(|cx| { let state = AppState::test(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); state }); diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index 28c823430c4cf92e24075fb56a526a75244b45c9..02bb80def5784ce522b062f402d017b2455f5ea2 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -1,9 +1,8 @@ use acp_thread::{ AcpThread, AcpThreadEvent, AgentSessionInfo, AgentThreadEntry, AssistantMessage, AssistantMessageChunk, AuthRequired, LoadError, MentionUri, PermissionOptionChoice, - PermissionOptions, PermissionPattern, RetryStatus, SelectedPermissionOutcome, - SelectedPermissionParams, ThreadStatus, ToolCall, ToolCallContent, ToolCallStatus, - UserMessageId, + PermissionOptions, PermissionPattern, RetryStatus, SelectedPermissionOutcome, ThreadStatus, + ToolCall, ToolCallContent, ToolCallStatus, UserMessageId, }; use acp_thread::{AgentConnection, Plan}; use action_log::{ActionLog, ActionLogTelemetry, DiffStats}; @@ -14,6 +13,7 @@ use agent_servers::AgentServerDelegate; use agent_servers::{AgentServer, GEMINI_TERMINAL_AUTH_METHOD_ID}; use agent_settings::{AgentProfileId, AgentSettings}; use anyhow::{Result, anyhow}; +#[cfg(feature = "audio")] use audio::{Audio, Sound}; use buffer_diff::BufferDiff; use client::zed_urls; @@ -43,14 +43,14 @@ use prompt_store::{PromptId, PromptStore}; use crate::DEFAULT_THREAD_TITLE; use crate::message_editor::SessionCapabilities; use rope::Point; -use settings::{NotifyWhenAgentWaiting, Settings as _, SettingsStore}; +use settings::{NotifyWhenAgentWaiting, Settings as _, SettingsStore, ThinkingBlockDisplay}; use std::path::Path; use std::sync::Arc; use std::time::Instant; use std::{collections::BTreeMap, rc::Rc, time::Duration}; use terminal_view::terminal_panel::TerminalPanel; use text::Anchor; -use theme::AgentFontSize; +use theme_settings::AgentFontSize; use ui::{ Callout, CircularProgress, CommonAnimationExt, ContextMenu, ContextMenuEntry, CopyButton, DecoratedIcon, DiffStat, Disclosure, Divider, DividerColor, IconDecoration, IconDecorationKind, @@ -78,7 +78,7 @@ use crate::agent_diff::AgentDiff; use crate::entry_view_state::{EntryViewEvent, ViewEvent}; use crate::message_editor::{MessageEditor, MessageEditorEvent}; use crate::profile_selector::{ProfileProvider, ProfileSelector}; -use crate::thread_metadata_store::SidebarThreadMetadataStore; +use crate::thread_metadata_store::ThreadMetadataStore; use crate::ui::{AgentNotification, AgentNotificationEvent}; use crate::{ Agent, AgentDiffPane, AgentInitialContent, AgentPanel, AllowAlways, AllowOnce, @@ -238,6 +238,20 @@ impl Conversation { )) } + pub fn subagents_awaiting_permission(&self, cx: &App) -> Vec<(acp::SessionId, usize)> { + self.permission_requests + .iter() + .filter_map(|(session_id, tool_call_ids)| { + let thread = self.threads.get(session_id)?; + if thread.read(cx).parent_session_id().is_some() && !tool_call_ids.is_empty() { + Some((session_id.clone(), tool_call_ids.len())) + } else { + None + } + }) + .collect() + } + pub fn authorize_pending_tool_call( &mut self, session_id: &acp::SessionId, @@ -249,8 +263,7 @@ impl Conversation { self.authorize_tool_call( session_id.clone(), tool_call_id, - option.option_id.clone().into(), - option.kind, + SelectedPermissionOutcome::new(option.option_id.clone(), option.kind), cx, ); Some(()) @@ -261,7 +274,6 @@ impl Conversation { session_id: acp::SessionId, tool_call_id: acp::ToolCallId, outcome: SelectedPermissionOutcome, - option_kind: acp::PermissionOptionKind, cx: &mut Context, ) { let Some(thread) = self.threads.get(&session_id) else { @@ -273,11 +285,11 @@ impl Conversation { "Agent Tool Call Authorized", agent = agent_telemetry_id, session = session_id, - option = option_kind + option = outcome.option_kind ); thread.update(cx, |thread, cx| { - thread.authorize_tool_call(tool_call_id, outcome, option_kind, cx); + thread.authorize_tool_call(tool_call_id, outcome, cx); }); cx.notify(); } @@ -405,7 +417,7 @@ enum ServerState { pub struct ConnectedServerState { auth_state: AuthState, active_id: Option, - threads: HashMap>, + pub(crate) threads: HashMap>, connection: Rc, history: Option>, conversation: Entity, @@ -798,7 +810,7 @@ impl ConversationView { }); let count = thread.read(cx).entries().len(); - let list_state = ListState::new(0, gpui::ListAlignment::Bottom, px(2048.0)); + let list_state = ListState::new(0, gpui::ListAlignment::Top, px(2048.0)); entry_view_state.update(cx, |view_state, cx| { for ix in 0..count { view_state.sync_entry(ix, &thread, window, cx); @@ -1176,12 +1188,19 @@ impl ConversationView { &mut self, index: usize, inserted_text: Option<&str>, + cursor_offset: Option, window: &mut Window, cx: &mut Context, ) { if let Some(active) = self.active_thread() { active.update(cx, |active, cx| { - active.move_queued_message_to_main_editor(index, inserted_text, window, cx); + active.move_queued_message_to_main_editor( + index, + inserted_text, + cursor_offset, + window, + cx, + ); }); } } @@ -1211,6 +1230,9 @@ impl ConversationView { .and_then(|entry| entry.focus_handle(cx))], ); }); + active.update(cx, |active, cx| { + active.sync_editor_mode_for_empty_state(cx); + }); } } AcpThreadEvent::EntryUpdated(index) => { @@ -1230,6 +1252,9 @@ impl ConversationView { let list_state = active.read(cx).list_state.clone(); entry_view_state.update(cx, |view_state, _cx| view_state.remove(range.clone())); list_state.splice(range.clone(), 0); + active.update(cx, |active, cx| { + active.sync_editor_mode_for_empty_state(cx); + }); } } AcpThreadEvent::SubagentSpawned(session_id) => self.load_subagent_session( @@ -1251,9 +1276,11 @@ impl ConversationView { } AcpThreadEvent::Stopped(stop_reason) => { if let Some(active) = self.thread_view(&thread_id) { - active.update(cx, |active, _cx| { + active.update(cx, |active, cx| { active.thread_retry_status.take(); active.clear_auto_expand_tracking(); + active.list_state.set_follow_tail(false); + active.sync_generating_indicator(cx); }); } if is_subagent { @@ -1321,8 +1348,10 @@ impl ConversationView { } AcpThreadEvent::Error => { if let Some(active) = self.thread_view(&thread_id) { - active.update(cx, |active, _cx| { + active.update(cx, |active, cx| { active.thread_retry_status.take(); + active.list_state.set_follow_tail(false); + active.sync_generating_indicator(cx); }); } if !is_subagent { @@ -2195,8 +2224,16 @@ impl ConversationView { &editor, window, move |this, _editor, event, window, cx| match event { - MessageEditorEvent::InputAttempted(text) => this - .move_queued_message_to_main_editor(index, Some(text.as_ref()), window, cx), + MessageEditorEvent::InputAttempted { + text, + cursor_offset, + } => this.move_queued_message_to_main_editor( + index, + Some(text.as_ref()), + Some(*cursor_offset), + window, + cx, + ), MessageEditorEvent::LostFocus => { this.save_queued_message_at_index(index, cx); } @@ -2242,6 +2279,7 @@ impl ConversationView { window: &mut Window, cx: &mut Context, ) { + #[cfg(feature = "audio")] self.play_notification_sound(window, cx); self.show_notification(caption, icon, window, cx); } @@ -2271,7 +2309,7 @@ impl ConversationView { fn play_notification_sound(&self, window: &Window, cx: &mut App) { let settings = AgentSettings::get_global(cx); - let visible = window.is_window_active() + let _visible = window.is_window_active() && if let Some(mw) = window.root::().flatten() { self.agent_panel_visible(&mw, cx) } else { @@ -2279,7 +2317,8 @@ impl ConversationView { .upgrade() .is_some_and(|workspace| AgentPanel::is_visible(&workspace, cx)) }; - if settings.play_sound_when_agent_done && !visible { + #[cfg(feature = "audio")] + if settings.play_sound_when_agent_done && !_visible { Audio::play_sound(Sound::AgentDone, cx); } } @@ -2374,7 +2413,7 @@ impl ConversationView { .update(cx, |multi_workspace, window, cx| { window.activate_window(); if let Some(workspace) = workspace_handle.upgrade() { - multi_workspace.activate(workspace.clone(), cx); + multi_workspace.activate(workspace.clone(), window, cx); workspace.update(cx, |workspace, cx| { workspace.focus_panel::(window, cx); }); @@ -2533,10 +2572,8 @@ impl ConversationView { let task = history.update(cx, |history, cx| history.delete_session(&session_id, cx)); task.detach_and_log_err(cx); - if let Some(store) = SidebarThreadMetadataStore::try_global(cx) { - store - .update(cx, |store, cx| store.delete(session_id.clone(), cx)) - .detach_and_log_err(cx); + if let Some(store) = ThreadMetadataStore::try_global(cx) { + store.update(cx, |store, cx| store.delete(session_id.clone(), cx)); } } } @@ -3615,7 +3652,7 @@ pub(crate) mod tests { C: 'static + AgentConnection + Send + Clone, { fn logo(&self) -> ui::IconName { - ui::IconName::Ai + ui::IconName::ZedAgent } fn agent_id(&self) -> AgentId { @@ -4222,8 +4259,8 @@ pub(crate) mod tests { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - SidebarThreadMetadataStore::init_global(cx); - theme::init(theme::LoadThemes::JustBase, cx); + ThreadMetadataStore::init_global(cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); agent_panel::init(cx); release_channel::init(semver::Version::new(0, 0, 0), cx); @@ -6176,13 +6213,13 @@ pub(crate) mod tests { match error { Some(ThreadError::Other { message, .. }) => { assert!( - message.contains("Max tokens reached"), - "Expected 'Max tokens reached' error, got: {}", + message.contains("Maximum tokens reached"), + "Expected 'Maximum tokens reached' error, got: {}", message ); } other => panic!( - "Expected ThreadError::Other with 'Max tokens reached', got: {:?}", + "Expected ThreadError::Other with 'Maximum tokens reached', got: {:?}", other.is_some() ), } @@ -6276,8 +6313,10 @@ pub(crate) mod tests { conversation.authorize_tool_call( acp::SessionId::new("session-1"), acp::ToolCallId::new("tc-1"), - acp::PermissionOptionId::new("allow-1").into(), - acp::PermissionOptionKind::AllowOnce, + SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow-1"), + acp::PermissionOptionKind::AllowOnce, + ), cx, ); }); @@ -6299,8 +6338,10 @@ pub(crate) mod tests { conversation.authorize_tool_call( acp::SessionId::new("session-1"), acp::ToolCallId::new("tc-2"), - acp::PermissionOptionId::new("allow-2").into(), - acp::PermissionOptionKind::AllowOnce, + SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow-2"), + acp::PermissionOptionKind::AllowOnce, + ), cx, ); }); @@ -6438,8 +6479,10 @@ pub(crate) mod tests { conversation.authorize_tool_call( acp::SessionId::new("thread-a"), acp::ToolCallId::new("tc-a"), - acp::PermissionOptionId::new("allow-a").into(), - acp::PermissionOptionKind::AllowOnce, + SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow-a"), + acp::PermissionOptionKind::AllowOnce, + ), cx, ); }); @@ -6477,7 +6520,7 @@ pub(crate) mod tests { // Main editor must be empty for this path — it is by default, but // assert to make the precondition explicit. assert!(thread.message_editor.read(cx).is_empty(cx)); - thread.move_queued_message_to_main_editor(0, None, window, cx); + thread.move_queued_message_to_main_editor(0, None, None, window, cx); }); cx.run_until_parked(); @@ -6522,7 +6565,7 @@ pub(crate) mod tests { vec![], cx, ); - thread.move_queued_message_to_main_editor(0, None, window, cx); + thread.move_queued_message_to_main_editor(0, None, None, window, cx); }); cx.run_until_parked(); diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index cafc2b9f369e6854d887ff63073831e48bff6116..c14636be8c94caf6c588c9e1c8c939a69049b7a5 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -1,7 +1,10 @@ -use crate::{DEFAULT_THREAD_TITLE, SelectPermissionGranularity}; +use crate::{ + DEFAULT_THREAD_TITLE, SelectPermissionGranularity, + agent_configuration::configure_context_server_modal::default_markdown_style, +}; use std::cell::RefCell; -use acp_thread::ContentBlock; +use acp_thread::{ContentBlock, PlanEntry}; use cloud_api_types::{SubmitAgentThreadFeedbackBody, SubmitAgentThreadFeedbackCommentsBody}; use editor::actions::OpenExcerpts; @@ -163,6 +166,7 @@ impl ThreadFeedbackState { pub enum AcpThreadViewEvent { FirstSendRequested { content: Vec }, + MessageSentOrQueued, } impl EventEmitter for ThreadView {} @@ -249,6 +253,7 @@ pub struct ThreadView { pub expanded_tool_call_raw_inputs: HashSet, pub expanded_thinking_blocks: HashSet<(usize, usize)>, auto_expanded_thinking_block: Option<(usize, usize)>, + user_toggled_thinking_blocks: HashSet<(usize, usize)>, pub subagent_scroll_handles: RefCell>, pub edits_expanded: bool, pub plan_expanded: bool, @@ -285,6 +290,7 @@ pub struct ThreadView { pub hovered_recent_history_item: Option, pub show_external_source_prompt_warning: bool, pub show_codex_windows_warning: bool, + pub generating_indicator_in_list: bool, pub history: Option>, pub _history_subscription: Option, } @@ -490,6 +496,7 @@ impl ThreadView { expanded_tool_call_raw_inputs: HashSet::default(), expanded_thinking_blocks: HashSet::default(), auto_expanded_thinking_block: None, + user_toggled_thinking_blocks: HashSet::default(), subagent_scroll_handles: RefCell::new(HashMap::default()), edits_expanded: false, plan_expanded: false, @@ -525,19 +532,40 @@ impl ThreadView { history, _history_subscription: history_subscription, show_codex_windows_warning, + generating_indicator_in_list: false, }; + + this.sync_generating_indicator(cx); + this.sync_editor_mode_for_empty_state(cx); let list_state_for_scroll = this.list_state.clone(); let thread_view = cx.entity().downgrade(); + this.list_state - .set_scroll_handler(move |_event, _window, cx| { + .set_scroll_handler(move |event, _window, cx| { let list_state = list_state_for_scroll.clone(); let thread_view = thread_view.clone(); + let is_following_tail = event.is_following_tail; // N.B. We must defer because the scroll handler is called while the // ListState's RefCell is mutably borrowed. Reading logical_scroll_top() // directly would panic from a double borrow. cx.defer(move |cx| { let scroll_top = list_state.logical_scroll_top(); let _ = thread_view.update(cx, |this, cx| { + if !is_following_tail { + let is_at_bottom = { + let current_offset = + list_state.scroll_px_offset_for_scrollbar().y.abs(); + let max_offset = list_state.max_offset_for_scrollbar().y; + current_offset >= max_offset - px(1.0) + }; + + let is_generating = + matches!(this.thread.read(cx).status(), ThreadStatus::Generating); + + if is_at_bottom && is_generating { + list_state.set_follow_tail(true); + } + } if let Some(thread) = this.as_native_thread(cx) { thread.update(cx, |thread, _cx| { thread.set_ui_scroll_position(Some(scroll_top)); @@ -585,7 +613,7 @@ impl ThreadView { self.cancel_editing(&Default::default(), window, cx); } MessageEditorEvent::LostFocus => {} - MessageEditorEvent::InputAttempted(_) => {} + MessageEditorEvent::InputAttempted { .. } => {} } } @@ -722,7 +750,7 @@ impl ThreadView { ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Cancel) => { self.cancel_editing(&Default::default(), window, cx); } - ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::InputAttempted(_)) => {} + ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::InputAttempted { .. }) => {} ViewEvent::OpenDiffLocation { path, position, @@ -882,6 +910,7 @@ impl ThreadView { }); if intercept_first_send { + cx.emit(AcpThreadViewEvent::MessageSentOrQueued); let content_task = self.resolve_message_contents(&message_editor, cx); cx.spawn(async move |this, cx| match content_task.await { @@ -913,6 +942,7 @@ impl ThreadView { let has_queued = self.has_queued_messages(); if is_editor_empty && self.can_fast_track_queue && has_queued { self.can_fast_track_queue = false; + cx.emit(AcpThreadViewEvent::MessageSentOrQueued); self.send_queued_message_at_index(0, true, window, cx); return; } @@ -922,6 +952,7 @@ impl ThreadView { } if is_generating { + cx.emit(AcpThreadViewEvent::MessageSentOrQueued); self.queue_message(message_editor, window, cx); return; } @@ -963,6 +994,7 @@ impl ThreadView { } } + cx.emit(AcpThreadViewEvent::MessageSentOrQueued); self.send_impl(message_editor, window, cx) } @@ -1043,7 +1075,11 @@ impl ThreadView { this.update_in(cx, |this, _window, cx| { this.set_editor_is_expanded(false, cx); })?; - let _ = this.update(cx, |this, cx| this.scroll_to_bottom(cx)); + + let _ = this.update(cx, |this, cx| { + this.list_state.set_follow_tail(true); + cx.notify(); + }); let _stop_turn = defer({ let this = this.clone(); @@ -1097,6 +1133,12 @@ impl ThreadView { thread.send(contents, cx) })?; + + let _ = this.update(cx, |this, cx| { + this.sync_generating_indicator(cx); + cx.notify(); + }); + let res = send.await; let turn_time_ms = turn_start_time.elapsed().as_millis(); drop(_stop_turn); @@ -1236,13 +1278,13 @@ impl ThreadView { ); } - // generation - pub fn cancel_generation(&mut self, cx: &mut Context) { self.thread_retry_status.take(); self.thread_error.take(); self.user_interrupted_generation = true; self._cancel_task = Some(self.thread.update(cx, |thread, cx| thread.cancel(cx))); + self.sync_generating_indicator(cx); + cx.notify(); } pub fn retry_generation(&mut self, cx: &mut Context) { @@ -1254,6 +1296,8 @@ impl ThreadView { } let task = thread.update(cx, |thread, cx| thread.retry(cx)); + self.sync_generating_indicator(cx); + cx.notify(); cx.spawn(async move |this, cx| { let result = task.await; @@ -1440,6 +1484,7 @@ impl ThreadView { &mut self, index: usize, inserted_text: Option<&str>, + cursor_offset: Option, window: &mut Window, cx: &mut Context, ) -> bool { @@ -1455,6 +1500,9 @@ impl ThreadView { if message_editor.read(cx).is_empty(cx) { message_editor.update(cx, |editor, cx| { editor.set_message(queued_content, window, cx); + if let Some(offset) = cursor_offset { + editor.set_cursor_offset(offset, window, cx); + } if let Some(inserted_text) = inserted_text.as_deref() { editor.insert_text(inserted_text, window, cx); } @@ -1463,8 +1511,16 @@ impl ThreadView { return true; } + // Adjust cursor offset accounting for existing content + let existing_len = message_editor.read(cx).text(cx).len(); + let separator = "\n\n"; + message_editor.update(cx, |editor, cx| { - editor.append_message(queued_content, Some("\n\n"), window, cx); + editor.append_message(queued_content, Some(separator), window, cx); + if let Some(offset) = cursor_offset { + let adjusted_offset = existing_len + separator.len() + offset; + editor.set_cursor_offset(adjusted_offset, window, cx); + } if let Some(inserted_text) = inserted_text.as_deref() { editor.insert_text(inserted_text, window, cx); } @@ -1570,22 +1626,20 @@ impl ThreadView { } }) }; + self.message_editor.focus_handle(cx).focus(window, cx); cx.notify(); } - // tool permissions - pub fn authorize_tool_call( &mut self, session_id: acp::SessionId, tool_call_id: acp::ToolCallId, outcome: SelectedPermissionOutcome, - option_kind: acp::PermissionOptionKind, window: &mut Window, cx: &mut Context, ) { self.conversation.update(cx, |conversation, cx| { - conversation.authorize_tool_call(session_id, tool_call_id, outcome, option_kind, cx); + conversation.authorize_tool_call(session_id, tool_call_id, outcome, cx); }); if self.should_be_following { self.workspace @@ -1629,6 +1683,17 @@ impl ThreadView { Some(()) } + fn is_waiting_for_confirmation(entry: &AgentThreadEntry) -> bool { + if let AgentThreadEntry::ToolCall(tool_call) = entry { + matches!( + tool_call.status, + ToolCallStatus::WaitingForConfirmation { .. } + ) + } else { + false + } + } + fn handle_authorize_tool_call( &mut self, action: &AuthorizeToolCall, @@ -1648,8 +1713,7 @@ impl ThreadView { self.authorize_tool_call( self.id.clone(), tool_call_id, - option_id.into(), - option_kind, + SelectedPermissionOutcome::new(option_id, option_kind), window, cx, ); @@ -1740,16 +1804,9 @@ impl ThreadView { window: &mut Window, cx: &mut Context, ) -> Option<()> { - let (choices, dropdown_with_patterns) = match options { - PermissionOptions::Dropdown(choices) => (choices.as_slice(), None), - PermissionOptions::DropdownWithPatterns { - choices, - patterns, - tool_name, - } => ( - choices.as_slice(), - Some((patterns.as_slice(), tool_name.as_str())), - ), + let choices = match options { + PermissionOptions::Dropdown(choices) => choices.as_slice(), + PermissionOptions::DropdownWithPatterns { choices, .. } => choices.as_slice(), _ => { let kind = if is_allow { acp::PermissionOptionKind::AllowOnce @@ -1763,34 +1820,9 @@ impl ThreadView { let selection = self.permission_selections.get(&tool_call_id); // When in per-command pattern mode, use the checked patterns. - if let Some(PermissionSelection::SelectedPatterns(checked)) = selection - && let Some((patterns, tool_name)) = dropdown_with_patterns - { - let checked_patterns: Vec<_> = patterns - .iter() - .enumerate() - .filter(|(index, _)| checked.contains(index)) - .map(|(_, cp)| cp.pattern.clone()) - .collect(); - - if !checked_patterns.is_empty() { - let (option_id_str, kind) = if is_allow { - ( - format!("always_allow:{}", tool_name), - acp::PermissionOptionKind::AllowAlways, - ) - } else { - ( - format!("always_deny:{}", tool_name), - acp::PermissionOptionKind::RejectAlways, - ) - }; - let outcome = - SelectedPermissionOutcome::new(acp::PermissionOptionId::new(option_id_str)) - .params(Some(SelectedPermissionParams::Terminal { - patterns: checked_patterns, - })); - self.authorize_tool_call(session_id, tool_call_id, outcome, kind, window, cx); + if let Some(PermissionSelection::SelectedPatterns(checked)) = selection { + if let Some(outcome) = options.build_outcome_for_checked_patterns(checked, is_allow) { + self.authorize_tool_call(session_id, tool_call_id, outcome, window, cx); return Some(()); } } @@ -1801,32 +1833,9 @@ impl ThreadView { .unwrap_or_else(|| choices.len().saturating_sub(1)); let selected_choice = choices.get(selected_index).or(choices.last())?; + let outcome = selected_choice.build_outcome(is_allow); - let selected_option = if is_allow { - &selected_choice.allow - } else { - &selected_choice.deny - }; - - let params = if !selected_choice.sub_patterns.is_empty() { - Some(SelectedPermissionParams::Terminal { - patterns: selected_choice.sub_patterns.clone(), - }) - } else { - None - }; - - let outcome = - SelectedPermissionOutcome::new(selected_option.option_id.clone()).params(params); - - self.authorize_tool_call( - session_id, - tool_call_id, - outcome, - selected_option.kind, - window, - cx, - ); + self.authorize_tool_call(session_id, tool_call_id, outcome, window, cx); Some(()) } @@ -2157,7 +2166,14 @@ impl ThreadView { let plan = thread.plan(); let queue_is_empty = !self.has_queued_messages(); - if changed_buffers.is_empty() && plan.is_empty() && queue_is_empty { + let subagents_awaiting_permission = self.render_subagents_awaiting_permission(cx); + let has_subagents_awaiting = subagents_awaiting_permission.is_some(); + + if changed_buffers.is_empty() + && plan.is_empty() + && queue_is_empty + && !has_subagents_awaiting + { return None; } @@ -2173,7 +2189,6 @@ impl ThreadView { let queue_expanded = self.queue_expanded; v_flex() - .mt_1() .mx_2() .bg(self.activity_bar_bg(cx)) .border_1() @@ -2181,11 +2196,19 @@ impl ThreadView { .border_color(cx.theme().colors().border) .rounded_t_md() .shadow(vec![gpui::BoxShadow { - color: gpui::black().opacity(0.15), + color: gpui::black().opacity(0.12), offset: point(px(1.), px(-1.)), - blur_radius: px(3.), + blur_radius: px(2.), spread_radius: px(0.), }]) + .when_some(subagents_awaiting_permission, |this, element| { + this.child(element) + }) + .when( + has_subagents_awaiting + && (!plan.is_empty() || !changed_buffers.is_empty() || !queue_is_empty), + |this| this.child(Divider::horizontal().color(DividerColor::Border)), + ) .when(!plan.is_empty(), |this| { this.child(self.render_plan_summary(plan, window, cx)) .when(plan_expanded, |parent| { @@ -2445,6 +2468,119 @@ impl ThreadView { ) } + fn render_subagents_awaiting_permission(&self, cx: &Context) -> Option { + let awaiting = self.conversation.read(cx).subagents_awaiting_permission(cx); + + if awaiting.is_empty() { + return None; + } + + let thread = self.thread.read(cx); + let entries = thread.entries(); + let mut subagent_items: Vec<(SharedString, usize)> = Vec::new(); + + for (session_id, _) in &awaiting { + for (entry_ix, entry) in entries.iter().enumerate() { + if let AgentThreadEntry::ToolCall(tool_call) = entry { + if let Some(info) = &tool_call.subagent_session_info { + if &info.session_id == session_id { + let subagent_summary: SharedString = { + let summary_text = tool_call.label.read(cx).source().to_string(); + if !summary_text.is_empty() { + summary_text.into() + } else { + "Subagent".into() + } + }; + subagent_items.push((subagent_summary, entry_ix)); + break; + } + } + } + } + } + + if subagent_items.is_empty() { + return None; + } + + let item_count = subagent_items.len(); + + Some( + v_flex() + .child( + h_flex() + .py_1() + .px_2() + .w_full() + .gap_1() + .border_b_1() + .border_color(cx.theme().colors().border) + .child( + Label::new("Subagents Awaiting Permission:") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child(Label::new(item_count.to_string()).size(LabelSize::Small)), + ) + .child( + v_flex().children(subagent_items.into_iter().enumerate().map( + |(ix, (label, entry_ix))| { + let is_last = ix == item_count - 1; + let group = format!("group-{}", entry_ix); + + h_flex() + .cursor_pointer() + .id(format!("subagent-permission-{}", entry_ix)) + .group(&group) + .p_1() + .pl_2() + .min_w_0() + .w_full() + .gap_1() + .justify_between() + .bg(cx.theme().colors().editor_background) + .hover(|s| s.bg(cx.theme().colors().element_hover)) + .when(!is_last, |this| { + this.border_b_1().border_color(cx.theme().colors().border) + }) + .child( + h_flex() + .gap_1p5() + .child( + Icon::new(IconName::Circle) + .size(IconSize::XSmall) + .color(Color::Warning), + ) + .child( + Label::new(label) + .size(LabelSize::Small) + .color(Color::Muted) + .truncate(), + ), + ) + .child( + div().visible_on_hover(&group).child( + Label::new("Scroll to Subagent") + .size(LabelSize::Small) + .color(Color::Muted) + .truncate(), + ), + ) + .on_click(cx.listener(move |this, _, _, cx| { + this.list_state.scroll_to(ListOffset { + item_ix: entry_ix, + offset_in_item: px(0.0), + }); + cx.notify(); + })) + }, + )), + ) + .into_any(), + ) + } + fn render_message_queue_summary( &self, _window: &mut Window, @@ -2479,7 +2615,10 @@ impl ThreadView { .child( Button::new("clear_queue", "Clear All") .label_size(LabelSize::Small) - .key_binding(KeyBinding::for_action(&ClearMessageQueue, cx)) + .key_binding( + KeyBinding::for_action(&ClearMessageQueue, cx) + .map(|kb| kb.size(rems_from_px(12.))), + ) .on_click(cx.listener(|this, _, _, cx| { this.clear_queue(cx); this.can_fast_track_queue = false; @@ -2583,7 +2722,17 @@ impl ThreadView { this.border_b_1().border_color(cx.theme().colors().border) }) .child(Disclosure::new("plan_disclosure", plan_expanded)) - .child(title) + .child(title.flex_1()) + .child( + IconButton::new("dismiss-plan", IconName::Close) + .icon_size(IconSize::XSmall) + .shape(ui::IconButtonShape::Square) + .tooltip(Tooltip::text("Clear plan")) + .on_click(cx.listener(|this, _, _, cx| { + this.thread.update(cx, |thread, cx| thread.clear_plan(cx)); + cx.stop_propagation(); + })), + ) .on_click(cx.listener(|this, _, _, cx| { this.plan_expanded = !this.plan_expanded; cx.notify(); @@ -2651,6 +2800,76 @@ impl ThreadView { .into_any_element() } + fn render_completed_plan( + &self, + entries: &[PlanEntry], + window: &Window, + cx: &Context, + ) -> AnyElement { + v_flex() + .px_5() + .py_1p5() + .w_full() + .child( + v_flex() + .w_full() + .rounded_md() + .border_1() + .border_color(self.tool_card_border_color(cx)) + .child( + h_flex() + .px_2() + .py_1() + .gap_1() + .bg(self.tool_card_header_bg(cx)) + .border_b_1() + .border_color(self.tool_card_border_color(cx)) + .child( + Label::new("Completed Plan") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child( + Label::new(format!( + "— {} {}", + entries.len(), + if entries.len() == 1 { "step" } else { "steps" } + )) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .child( + v_flex().children(entries.iter().enumerate().map(|(index, entry)| { + h_flex() + .py_1() + .px_2() + .gap_1p5() + .when(index < entries.len() - 1, |this| { + this.border_b_1().border_color(cx.theme().colors().border) + }) + .child( + Icon::new(IconName::TodoComplete) + .size(IconSize::Small) + .color(Color::Success), + ) + .child( + div() + .max_w_full() + .overflow_x_hidden() + .text_xs() + .text_color(cx.theme().colors().text_muted) + .child(MarkdownElement::new( + entry.content.clone(), + default_markdown_style(window, cx), + )), + ) + })), + ), + ) + .into_any() + } + fn render_edits_summary( &self, changed_buffers: &BTreeMap, Entity>, @@ -2768,7 +2987,7 @@ impl ThreadView { }) .key_binding( KeyBinding::for_action_in(&RejectAll, &focus_handle.clone(), cx) - .map(|kb| kb.size(rems_from_px(10.))), + .map(|kb| kb.size(rems_from_px(12.))), ) .on_click(cx.listener(move |this, _, window, cx| { this.reject_all(&RejectAll, window, cx); @@ -2783,7 +3002,7 @@ impl ThreadView { }) .key_binding( KeyBinding::for_action_in(&KeepAll, &focus_handle, cx) - .map(|kb| kb.size(rems_from_px(10.))), + .map(|kb| kb.size(rems_from_px(12.))), ) .on_click(cx.listener(move |this, _, window, cx| { this.keep_all(&KeepAll, window, cx); @@ -2914,31 +3133,6 @@ impl ThreadView { (IconName::Maximize, "Expand Message Editor") }; - if v2_empty_state { - self.message_editor.update(cx, |editor, cx| { - editor.set_mode( - EditorMode::Full { - scale_ui_elements_with_buffer_font_size: false, - show_active_line_background: false, - sizing_behavior: SizingBehavior::Default, - }, - cx, - ); - }); - } else { - self.message_editor.update(cx, |editor, cx| { - editor.set_mode( - EditorMode::AutoHeight { - min_lines: AgentSettings::get_global(cx).message_editor_min_lines, - max_lines: Some( - AgentSettings::get_global(cx).set_message_editor_max_lines(), - ), - }, - cx, - ); - }); - } - v_flex() .on_action(cx.listener(Self::expand_message_editor)) .p_2() @@ -3095,7 +3289,7 @@ impl ThreadView { }) .on_click(cx.listener(move |this, _, window, cx| { this.move_queued_message_to_main_editor( - index, None, window, cx, + index, None, None, window, cx, ); })), ) @@ -3169,7 +3363,7 @@ impl ThreadView { }) .on_click(cx.listener(move |this, _, window, cx| { this.move_queued_message_to_main_editor( - index, None, window, cx, + index, None, None, window, cx, ); })), ) @@ -3215,50 +3409,122 @@ impl ThreadView { fn render_token_usage(&self, cx: &mut Context) -> Option { let thread = self.thread.read(cx); let usage = thread.token_usage()?; - let is_generating = thread.status() != ThreadStatus::Idle; let show_split = self.supports_split_token_display(cx); - let separator_color = Color::Custom(cx.theme().colors().text_muted.opacity(0.5)); - let token_label = |text: String, animation_id: &'static str| { - Label::new(text) - .size(LabelSize::Small) - .color(Color::Muted) - .map(|label| { - if is_generating { - label - .with_animation( - animation_id, - Animation::new(Duration::from_secs(2)) - .repeat() - .with_easing(pulsating_between(0.3, 0.8)), - |label, delta| label.alpha(delta), - ) - .into_any() - } else { - label.into_any_element() - } + let progress_color = |ratio: f32| -> Hsla { + if ratio >= 0.85 { + cx.theme().status().warning + } else { + cx.theme().colors().text_muted + } + }; + + let used = crate::text_thread_editor::humanize_token_count(usage.used_tokens); + let max = crate::text_thread_editor::humanize_token_count(usage.max_tokens); + let input_tokens_label = + crate::text_thread_editor::humanize_token_count(usage.input_tokens); + let output_tokens_label = + crate::text_thread_editor::humanize_token_count(usage.output_tokens); + + let progress_ratio = if usage.max_tokens > 0 { + usage.used_tokens as f32 / usage.max_tokens as f32 + } else { + 0.0 + }; + + let ring_size = px(16.0); + let stroke_width = px(2.); + + let percentage = format!("{}%", (progress_ratio * 100.0).round() as u32); + + let tooltip_separator_color = Color::Custom(cx.theme().colors().text_disabled.opacity(0.6)); + + let (user_rules_count, first_user_rules_id, project_rules_count, project_entry_ids) = self + .as_native_thread(cx) + .map(|thread| { + let project_context = thread.read(cx).project_context().read(cx); + let user_rules_count = project_context.user_rules.len(); + let first_user_rules_id = project_context.user_rules.first().map(|r| r.uuid.0); + let project_entry_ids = project_context + .worktrees + .iter() + .filter_map(|wt| wt.rules_file.as_ref()) + .map(|rf| ProjectEntryId::from_usize(rf.project_entry_id)) + .collect::>(); + let project_rules_count = project_entry_ids.len(); + ( + user_rules_count, + first_user_rules_id, + project_rules_count, + project_entry_ids, + ) + }) + .unwrap_or_default(); + + let workspace = self.workspace.clone(); + + let max_output_tokens = self + .as_native_thread(cx) + .and_then(|thread| thread.read(cx).model()) + .and_then(|model| model.max_output_tokens()) + .unwrap_or(0); + let input_max_label = crate::text_thread_editor::humanize_token_count( + usage.max_tokens.saturating_sub(max_output_tokens), + ); + let output_max_label = crate::text_thread_editor::humanize_token_count(max_output_tokens); + + let build_tooltip = { + move |_window: &mut Window, cx: &mut App| { + let percentage = percentage.clone(); + let used = used.clone(); + let max = max.clone(); + let input_tokens_label = input_tokens_label.clone(); + let output_tokens_label = output_tokens_label.clone(); + let input_max_label = input_max_label.clone(); + let output_max_label = output_max_label.clone(); + let project_entry_ids = project_entry_ids.clone(); + let workspace = workspace.clone(); + cx.new(move |_cx| TokenUsageTooltip { + percentage, + used, + max, + input_tokens: input_tokens_label, + output_tokens: output_tokens_label, + input_max: input_max_label, + output_max: output_max_label, + show_split, + separator_color: tooltip_separator_color, + user_rules_count, + first_user_rules_id, + project_rules_count, + project_entry_ids, + workspace, }) + .into() + } }; if show_split { - let max_output_tokens = self - .as_native_thread(cx) - .and_then(|thread| thread.read(cx).model()) - .and_then(|model| model.max_output_tokens()) - .unwrap_or(0); - - let input = crate::text_thread_editor::humanize_token_count(usage.input_tokens); - let input_max = crate::text_thread_editor::humanize_token_count( - usage.max_tokens.saturating_sub(max_output_tokens), - ); - let output = crate::text_thread_editor::humanize_token_count(usage.output_tokens); - let output_max = crate::text_thread_editor::humanize_token_count(max_output_tokens); + let input_max_raw = usage.max_tokens.saturating_sub(max_output_tokens); + let output_max_raw = max_output_tokens; + + let input_ratio = if input_max_raw > 0 { + usage.input_tokens as f32 / input_max_raw as f32 + } else { + 0.0 + }; + let output_ratio = if output_max_raw > 0 { + usage.output_tokens as f32 / output_max_raw as f32 + } else { + 0.0 + }; Some( h_flex() + .id("split_token_usage") .flex_shrink_0() - .gap_1() - .mr_1p5() + .gap_1p5() + .mr_1() .child( h_flex() .gap_0p5() @@ -3267,16 +3533,15 @@ impl ThreadView { .size(IconSize::XSmall) .color(Color::Muted), ) - .child(token_label(input, "input-tokens-label")) .child( - Label::new("/") - .size(LabelSize::Small) - .color(separator_color), - ) - .child( - Label::new(input_max) - .size(LabelSize::Small) - .color(Color::Muted), + CircularProgress::new( + usage.input_tokens as f32, + input_max_raw as f32, + ring_size, + cx, + ) + .stroke_width(stroke_width) + .progress_color(progress_color(input_ratio)), ), ) .child( @@ -3287,52 +3552,21 @@ impl ThreadView { .size(IconSize::XSmall) .color(Color::Muted), ) - .child(token_label(output, "output-tokens-label")) .child( - Label::new("/") - .size(LabelSize::Small) - .color(separator_color), - ) - .child( - Label::new(output_max) - .size(LabelSize::Small) - .color(Color::Muted), + CircularProgress::new( + usage.output_tokens as f32, + output_max_raw as f32, + ring_size, + cx, + ) + .stroke_width(stroke_width) + .progress_color(progress_color(output_ratio)), ), ) + .hoverable_tooltip(build_tooltip) .into_any_element(), ) } else { - let used = crate::text_thread_editor::humanize_token_count(usage.used_tokens); - let max = crate::text_thread_editor::humanize_token_count(usage.max_tokens); - let progress_ratio = if usage.max_tokens > 0 { - usage.used_tokens as f32 / usage.max_tokens as f32 - } else { - 0.0 - }; - - let progress_color = if progress_ratio >= 0.85 { - cx.theme().status().warning - } else { - cx.theme().colors().text_muted - }; - let separator_color = Color::Custom(cx.theme().colors().text_disabled.opacity(0.6)); - - let percentage = format!("{}%", (progress_ratio * 100.0).round() as u32); - - let (user_rules_count, project_rules_count) = self - .as_native_thread(cx) - .map(|thread| { - let project_context = thread.read(cx).project_context().read(cx); - let user_rules = project_context.user_rules.len(); - let project_rules = project_context - .worktrees - .iter() - .filter(|wt| wt.rules_file.is_some()) - .count(); - (user_rules, project_rules) - }) - .unwrap_or((0, 0)); - Some( h_flex() .id("circular_progress_tokens") @@ -3342,59 +3576,13 @@ impl ThreadView { CircularProgress::new( usage.used_tokens as f32, usage.max_tokens as f32, - px(16.0), + ring_size, cx, ) - .stroke_width(px(2.)) - .progress_color(progress_color), + .stroke_width(stroke_width) + .progress_color(progress_color(progress_ratio)), ) - .tooltip(Tooltip::element({ - move |_, cx| { - v_flex() - .min_w_40() - .child( - Label::new("Context") - .color(Color::Muted) - .size(LabelSize::Small), - ) - .child( - h_flex() - .gap_0p5() - .child(Label::new(percentage.clone())) - .child(Label::new("•").color(separator_color).mx_1()) - .child(Label::new(used.clone())) - .child(Label::new("/").color(separator_color)) - .child(Label::new(max.clone()).color(Color::Muted)), - ) - .when(user_rules_count > 0 || project_rules_count > 0, |this| { - this.child( - v_flex() - .mt_1p5() - .pt_1p5() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .child( - Label::new("Rules") - .color(Color::Muted) - .size(LabelSize::Small), - ) - .when(user_rules_count > 0, |this| { - this.child(Label::new(format!( - "{} user rules", - user_rules_count - ))) - }) - .when(project_rules_count > 0, |this| { - this.child(Label::new(format!( - "{} project rules", - project_rules_count - ))) - }), - ) - }) - .into_any_element() - } - })) + .hoverable_tooltip(build_tooltip) .into_any_element(), ) } @@ -3943,16 +4131,184 @@ impl ThreadView { } } +struct TokenUsageTooltip { + percentage: String, + used: String, + max: String, + input_tokens: String, + output_tokens: String, + input_max: String, + output_max: String, + show_split: bool, + separator_color: Color, + user_rules_count: usize, + first_user_rules_id: Option, + project_rules_count: usize, + project_entry_ids: Vec, + workspace: WeakEntity, +} + +impl Render for TokenUsageTooltip { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let separator_color = self.separator_color; + let percentage = self.percentage.clone(); + let used = self.used.clone(); + let max = self.max.clone(); + let input_tokens = self.input_tokens.clone(); + let output_tokens = self.output_tokens.clone(); + let input_max = self.input_max.clone(); + let output_max = self.output_max.clone(); + let show_split = self.show_split; + let user_rules_count = self.user_rules_count; + let first_user_rules_id = self.first_user_rules_id; + let project_rules_count = self.project_rules_count; + let project_entry_ids = self.project_entry_ids.clone(); + let workspace = self.workspace.clone(); + + ui::tooltip_container(cx, move |container, cx| { + container + .min_w_40() + .child( + Label::new("Context") + .color(Color::Muted) + .size(LabelSize::Small), + ) + .when(!show_split, |this| { + this.child( + h_flex() + .gap_0p5() + .child(Label::new(percentage.clone())) + .child(Label::new("\u{2022}").color(separator_color).mx_1()) + .child(Label::new(used.clone())) + .child(Label::new("/").color(separator_color)) + .child(Label::new(max.clone()).color(Color::Muted)), + ) + }) + .when(show_split, |this| { + this.child( + v_flex() + .gap_0p5() + .child( + h_flex() + .gap_0p5() + .child(Label::new("Input:").color(Color::Muted).mr_0p5()) + .child(Label::new(input_tokens)) + .child(Label::new("/").color(separator_color)) + .child(Label::new(input_max).color(Color::Muted)), + ) + .child( + h_flex() + .gap_0p5() + .child(Label::new("Output:").color(Color::Muted).mr_0p5()) + .child(Label::new(output_tokens)) + .child(Label::new("/").color(separator_color)) + .child(Label::new(output_max).color(Color::Muted)), + ), + ) + }) + .when( + user_rules_count > 0 || project_rules_count > 0, + move |this| { + this.child( + v_flex() + .mt_1p5() + .pt_1p5() + .pb_0p5() + .gap_0p5() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child( + Label::new("Rules") + .color(Color::Muted) + .size(LabelSize::Small), + ) + .child( + v_flex() + .mx_neg_1() + .when(user_rules_count > 0, move |this| { + this.child( + Button::new( + "open-user-rules", + format!("{} user rules", user_rules_count), + ) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .color(Color::Muted) + .size(IconSize::XSmall), + ) + .on_click(move |_, window, cx| { + window.dispatch_action( + Box::new(OpenRulesLibrary { + prompt_to_select: first_user_rules_id, + }), + cx, + ); + }), + ) + }) + .when(project_rules_count > 0, move |this| { + let workspace = workspace.clone(); + let project_entry_ids = project_entry_ids.clone(); + this.child( + Button::new( + "open-project-rules", + format!( + "{} project rules", + project_rules_count + ), + ) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .color(Color::Muted) + .size(IconSize::XSmall), + ) + .on_click(move |_, window, cx| { + let _ = + workspace.update(cx, |workspace, cx| { + let project = + workspace.project().read(cx); + let paths = project_entry_ids + .iter() + .flat_map(|id| { + project.path_for_entry(*id, cx) + }) + .collect::>(); + for path in paths { + workspace + .open_path( + path, None, true, window, + cx, + ) + .detach_and_log_err(cx); + } + }); + }), + ) + }), + ), + ) + }, + ) + }) + } +} + impl ThreadView { pub(crate) fn render_entries(&mut self, cx: &mut Context) -> List { list( self.list_state.clone(), cx.processor(|this, index: usize, window, cx| { let entries = this.thread.read(cx).entries(); - let Some(entry) = entries.get(index) else { - return Empty.into_any(); - }; - this.render_entry(index, entries.len(), entry, window, cx) + if let Some(entry) = entries.get(index) { + this.render_entry(index, entries.len(), entry, window, cx) + } else if this.generating_indicator_in_list { + let confirmation = entries + .last() + .is_some_and(|entry| Self::is_waiting_for_confirmation(entry)); + this.render_generating(confirmation, cx).into_any_element() + } else { + Empty.into_any() + } }), ) .with_sizing_behavior(gpui::ListSizingBehavior::Auto) @@ -3992,12 +4348,6 @@ impl ThreadView { let editor_focus = editor.focus_handle(cx).is_focused(window); let focus_border = cx.theme().colors().border_focused; - let rules_item = if entry_ix == 0 { - self.render_rules_item(cx) - } else { - None - }; - let has_checkpoint_button = message .checkpoint .as_ref() @@ -4016,10 +4366,6 @@ impl ThreadView { .map(|this| { 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() } else { this.pt_2() } @@ -4028,7 +4374,6 @@ impl ThreadView { .px_2() .gap_1p5() .w_full() - .children(rules_item) .when(is_editable && has_checkpoint_button, |this| { this.children(message.id.clone().map(|message_id| { h_flex() @@ -4245,6 +4590,9 @@ impl ThreadView { cx, ) .into_any(), + AgentThreadEntry::CompletedPlan(entries) => { + self.render_completed_plan(entries, window, cx) + } }; let is_subagent_output = self.is_subagent() @@ -4283,6 +4631,8 @@ impl ThreadView { primary }; + let thread = self.thread.clone(); + let primary = if is_indented { let line_top = if is_first_indented { rems_from_px(-12.0) @@ -4310,28 +4660,16 @@ impl ThreadView { primary }; - let needs_confirmation = if let AgentThreadEntry::ToolCall(tool_call) = entry { - matches!( - tool_call.status, - ToolCallStatus::WaitingForConfirmation { .. } - ) - } else { - false - }; + let needs_confirmation = Self::is_waiting_for_confirmation(entry); - let thread = self.thread.clone(); let comments_editor = self.thread_feedback.comments_editor.clone(); let primary = if entry_ix + 1 == total_entries { v_flex() .w_full() .child(primary) - .map(|this| { - if needs_confirmation { - this.child(self.render_generating(true, cx)) - } else { - this.child(self.render_thread_controls(&thread, cx)) - } + .when(!needs_confirmation, |this| { + this.child(self.render_thread_controls(&thread, cx)) }) .when_some(comments_editor, |this, editor| { this.child(Self::render_feedback_feedback_editor(editor, cx)) @@ -4415,7 +4753,7 @@ impl ThreadView { ) -> impl IntoElement { let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating); if is_generating { - return self.render_generating(false, cx).into_any_element(); + return Empty.into_any_element(); } let open_as_markdown = IconButton::new("open-as-markdown", IconName::FileMarkdown) @@ -4615,13 +4953,12 @@ impl ThreadView { }); cx.notify(); } else { - self.scroll_to_bottom(cx); + self.scroll_to_end(cx); } } - pub fn scroll_to_bottom(&mut self, cx: &mut Context) { - let entry_count = self.thread.read(cx).entries().len(); - self.list_state.reset(entry_count); + pub fn scroll_to_end(&mut self, cx: &mut Context) { + self.list_state.scroll_to_end(); cx.notify(); } @@ -4702,6 +5039,42 @@ impl ThreadView { }) } + pub(crate) fn sync_editor_mode_for_empty_state(&mut self, cx: &mut Context) { + let has_messages = self.list_state.item_count() > 0; + let v2_empty_state = cx.has_flag::() && !has_messages; + + let mode = if v2_empty_state { + EditorMode::Full { + scale_ui_elements_with_buffer_font_size: false, + show_active_line_background: false, + sizing_behavior: SizingBehavior::Default, + } + } else { + EditorMode::AutoHeight { + min_lines: AgentSettings::get_global(cx).message_editor_min_lines, + max_lines: Some(AgentSettings::get_global(cx).set_message_editor_max_lines()), + } + }; + self.message_editor.update(cx, |editor, cx| { + editor.set_mode(mode, cx); + }); + } + + /// Ensures the list item count includes (or excludes) an extra item for the generating indicator + pub(crate) fn sync_generating_indicator(&mut self, cx: &App) { + let is_generating = matches!(self.thread.read(cx).status(), ThreadStatus::Generating); + + if is_generating && !self.generating_indicator_in_list { + let entries_count = self.thread.read(cx).entries().len(); + self.list_state.splice(entries_count..entries_count, 1); + self.generating_indicator_in_list = true; + } else if !is_generating && self.generating_indicator_in_list { + let entries_count = self.thread.read(cx).entries().len(); + self.list_state.splice(entries_count..entries_count + 1, 0); + self.generating_indicator_in_list = false; + } + } + fn render_generating(&self, confirmation: bool, cx: &App) -> impl IntoElement { let show_stats = AgentSettings::get_global(cx).show_turn_stats; let elapsed_label = show_stats @@ -4784,9 +5157,13 @@ impl ThreadView { .into_any_element() } - /// If the last entry's last chunk is a streaming thought block, auto-expand it. - /// Also collapses the previously auto-expanded block when a new one starts. pub(crate) fn auto_expand_streaming_thought(&mut self, cx: &mut Context) { + // Only auto-expand thinking blocks in Automatic mode. + // AlwaysExpanded shows them open by default; AlwaysCollapsed keeps them closed. + if AgentSettings::get_global(cx).thinking_display != ThinkingBlockDisplay::Automatic { + return; + } + let key = { let thread = self.thread.read(cx); if thread.status() != ThreadStatus::Generating { @@ -4807,30 +5184,59 @@ impl ThreadView { if let Some(key) = key { if self.auto_expanded_thinking_block != Some(key) { - if let Some(old_key) = self.auto_expanded_thinking_block.replace(key) { - self.expanded_thinking_blocks.remove(&old_key); - } + self.auto_expanded_thinking_block = Some(key); self.expanded_thinking_blocks.insert(key); cx.notify(); } } else if self.auto_expanded_thinking_block.is_some() { - // The last chunk is no longer a thought (model transitioned to responding), - // so collapse the previously auto-expanded block. - self.collapse_auto_expanded_thinking_block(); + self.auto_expanded_thinking_block = None; cx.notify(); } } - fn collapse_auto_expanded_thinking_block(&mut self) { - if let Some(key) = self.auto_expanded_thinking_block.take() { - self.expanded_thinking_blocks.remove(&key); - } - } - pub(crate) fn clear_auto_expand_tracking(&mut self) { self.auto_expanded_thinking_block = None; } + fn toggle_thinking_block_expansion(&mut self, key: (usize, usize), cx: &mut Context) { + let thinking_display = AgentSettings::get_global(cx).thinking_display; + + match thinking_display { + ThinkingBlockDisplay::Automatic => { + let is_user_expanded = self.user_toggled_thinking_blocks.contains(&key); + let is_in_expanded_set = self.expanded_thinking_blocks.contains(&key); + + if is_user_expanded { + self.user_toggled_thinking_blocks.remove(&key); + self.expanded_thinking_blocks.remove(&key); + } else if is_in_expanded_set { + self.user_toggled_thinking_blocks.insert(key); + } else { + self.expanded_thinking_blocks.insert(key); + self.user_toggled_thinking_blocks.insert(key); + } + } + ThinkingBlockDisplay::AlwaysExpanded => { + if self.user_toggled_thinking_blocks.contains(&key) { + self.user_toggled_thinking_blocks.remove(&key); + } else { + self.user_toggled_thinking_blocks.insert(key); + } + } + ThinkingBlockDisplay::AlwaysCollapsed => { + if self.user_toggled_thinking_blocks.contains(&key) { + self.user_toggled_thinking_blocks.remove(&key); + self.expanded_thinking_blocks.remove(&key); + } else { + self.expanded_thinking_blocks.insert(key); + self.user_toggled_thinking_blocks.insert(key); + } + } + } + + cx.notify(); + } + fn render_thinking_block( &self, entry_ix: usize, @@ -4844,7 +5250,21 @@ impl ThreadView { let key = (entry_ix, chunk_ix); - let is_open = self.expanded_thinking_blocks.contains(&key); + let thinking_display = AgentSettings::get_global(cx).thinking_display; + let is_user_toggled = self.user_toggled_thinking_blocks.contains(&key); + let is_in_expanded_set = self.expanded_thinking_blocks.contains(&key); + + let (is_open, is_constrained) = match thinking_display { + ThinkingBlockDisplay::Automatic => { + let is_open = is_user_toggled || is_in_expanded_set; + let is_constrained = is_in_expanded_set && !is_user_toggled; + (is_open, is_constrained) + } + ThinkingBlockDisplay::AlwaysExpanded => (!is_user_toggled, false), + ThinkingBlockDisplay::AlwaysCollapsed => (is_user_toggled, false), + }; + + let should_auto_scroll = self.auto_expanded_thinking_block == Some(key); let scroll_handle = self .entry_view_state @@ -4852,6 +5272,14 @@ impl ThreadView { .entry(entry_ix) .and_then(|entry| entry.scroll_handle_for_assistant_message_chunk(chunk_ix)); + if should_auto_scroll { + if let Some(ref handle) = scroll_handle { + handle.scroll_to_bottom(); + } + } + + let panel_bg = cx.theme().colors().panel_background; + v_flex() .gap_1() .child( @@ -4884,42 +5312,51 @@ impl ThreadView { .opened_icon(IconName::ChevronUp) .closed_icon(IconName::ChevronDown) .visible_on_hover(&card_header_id) - .on_click(cx.listener({ - move |this, _event, _window, cx| { - if is_open { - this.expanded_thinking_blocks.remove(&key); - } else { - this.expanded_thinking_blocks.insert(key); - } - cx.notify(); - } - })), + .on_click(cx.listener( + move |this, _event: &ClickEvent, _window, cx| { + this.toggle_thinking_block_expansion(key, cx); + }, + )), ) - .on_click(cx.listener(move |this, _event, _window, cx| { - if is_open { - this.expanded_thinking_blocks.remove(&key); - } else { - this.expanded_thinking_blocks.insert(key); - } - cx.notify(); + .on_click(cx.listener(move |this, _event: &ClickEvent, _window, cx| { + this.toggle_thinking_block_expansion(key, cx); })), ) .when(is_open, |this| { this.child( div() - .id(("thinking-content", chunk_ix)) - .ml_1p5() - .pl_3p5() - .border_l_1() - .border_color(self.tool_card_border_color(cx)) - .when_some(scroll_handle, |this, scroll_handle| { - this.track_scroll(&scroll_handle) - }) - .overflow_hidden() - .child(self.render_markdown( - chunk, - MarkdownStyle::themed(MarkdownFont::Agent, window, cx), - )), + .when(is_constrained, |this| this.relative()) + .child( + div() + .id(("thinking-content", chunk_ix)) + .ml_1p5() + .pl_3p5() + .border_l_1() + .border_color(self.tool_card_border_color(cx)) + .when(is_constrained, |this| this.max_h_64()) + .when_some(scroll_handle, |this, scroll_handle| { + this.track_scroll(&scroll_handle) + }) + .overflow_hidden() + .child(self.render_markdown( + chunk, + MarkdownStyle::themed(MarkdownFont::Agent, window, cx), + )), + ) + .when(is_constrained, |this| { + this.child( + div() + .absolute() + .inset_0() + .size_full() + .bg(linear_gradient( + 180., + linear_color_stop(panel_bg.opacity(0.8), 0.), + linear_color_stop(panel_bg.opacity(0.), 0.1), + )) + .block_mouse_except_scroll(), + ) + }), ) }) .into_any_element() @@ -4985,7 +5422,7 @@ impl ThreadView { let entity = entity.clone(); move |_, cx| { entity.update(cx, |this, cx| { - this.scroll_to_bottom(cx); + this.scroll_to_end(cx); }); } }) @@ -5106,7 +5543,9 @@ impl ThreadView { return false; } } - AgentThreadEntry::ToolCall(_) | AgentThreadEntry::AssistantMessage(_) => {} + AgentThreadEntry::ToolCall(_) + | AgentThreadEntry::AssistantMessage(_) + | AgentThreadEntry::CompletedPlan(_) => {} } } @@ -5545,7 +5984,7 @@ impl ThreadView { matches!(tool_call.kind, acp::ToolKind::Edit) || tool_call.diffs().next().is_some(); let is_cancelled_edit = is_edit && matches!(tool_call.status, ToolCallStatus::Canceled); - let (has_revealed_diff, tool_call_output_focus) = tool_call + let (has_revealed_diff, tool_call_output_focus, tool_call_output_focus_handle) = tool_call .diffs() .next() .and_then(|diff| { @@ -5556,9 +5995,10 @@ impl ThreadView { .and_then(|entry| entry.editor_for_diff(diff))?; let has_revealed_diff = diff.read(cx).has_revealed_range(cx); let has_focus = editor.read(cx).is_focused(window); - Some((has_revealed_diff, has_focus)) + let focus_handle = editor.focus_handle(cx); + Some((has_revealed_diff, has_focus, focus_handle)) }) - .unwrap_or((false, false)); + .unwrap_or_else(|| (false, false, focus_handle.clone())); let use_card_layout = needs_confirmation || is_edit || is_terminal_tool; @@ -5783,7 +6223,6 @@ impl ThreadView { .group(&card_header_id) .relative() .w_full() - .gap_1() .justify_between() .when(use_card_layout, |this| { this.p_0p5() @@ -5802,7 +6241,6 @@ impl ThreadView { )) .child( h_flex() - .gap_0p5() .when(is_collapsible || failed_or_canceled, |this| { let diff_for_discard = if has_revealed_diff && is_cancelled_edit @@ -5815,10 +6253,7 @@ impl ThreadView { this.child( h_flex() - .px_1() - .when_some(diff_for_discard.clone(), |this, _| { - this.pr_0p5() - }) + .pr_0p5() .gap_1() .when(is_collapsible, |this| { this.child( @@ -5926,10 +6361,10 @@ impl ThreadView { .when(tool_call_output_focus, |this| { this.child( Button::new("open-file-button", "Open File") + .style(ButtonStyle::Outlined) .label_size(LabelSize::Small) - .style(ButtonStyle::OutlinedGhost) .key_binding( - KeyBinding::for_action(&OpenExcerpts, cx) + KeyBinding::for_action_in(&OpenExcerpts, &tool_call_output_focus_handle, cx) .map(|s| s.size(rems_from_px(12.))), ) .on_click(|_, window, cx| { @@ -6068,7 +6503,7 @@ impl ThreadView { focus_handle, cx, ) - .map(|kb| kb.size(rems_from_px(10.))), + .map(|kb| kb.size(rems_from_px(12.))), ) }) .on_click(cx.listener({ @@ -6092,7 +6527,7 @@ impl ThreadView { focus_handle, cx, ) - .map(|kb| kb.size(rems_from_px(10.))), + .map(|kb| kb.size(rems_from_px(12.))), ) }) .on_click(cx.listener({ @@ -6140,7 +6575,7 @@ impl ThreadView { &self.focus_handle(cx), cx, ) - .map(|kb| kb.size(rems_from_px(10.))), + .map(|kb| kb.size(rems_from_px(12.))), ) }), ) @@ -6230,7 +6665,7 @@ impl ThreadView { &self.focus_handle(cx), cx, ) - .map(|kb| kb.size(rems_from_px(10.))), + .map(|kb| kb.size(rems_from_px(12.))), ) }), ) @@ -6429,7 +6864,7 @@ impl ThreadView { this.key_binding( KeyBinding::for_action_in(action, focus_handle, cx) - .map(|kb| kb.size(rems_from_px(10.))), + .map(|kb| kb.size(rems_from_px(12.))), ) }) .label_size(LabelSize::Small) @@ -6442,8 +6877,7 @@ impl ThreadView { this.authorize_tool_call( session_id.clone(), tool_call_id.clone(), - option_id.clone().into(), - option_kind, + SelectedPermissionOutcome::new(option_id.clone(), option_kind), window, cx, ); @@ -6510,10 +6944,10 @@ impl ThreadView { let file_icon = if has_location { FileIcons::get_icon(&tool_call.locations[0].path, cx) - .map(Icon::from_path) - .unwrap_or(Icon::new(IconName::ToolPencil)) + .map(|from_path| Icon::from_path(from_path).color(Color::Muted)) + .unwrap_or(Icon::new(IconName::ToolPencil).color(Color::Muted)) } else { - Icon::new(IconName::ToolPencil) + Icon::new(IconName::ToolPencil).color(Color::Muted) }; let tool_icon = if is_file && has_failed && has_revealed_diff { @@ -7325,7 +7759,6 @@ impl ThreadView { this.when(is_expanded, |this| { this.child(self.render_subagent_expanded_content( thread_view, - is_running, tool_call, window, cx, @@ -7348,7 +7781,6 @@ impl ThreadView { fn render_subagent_expanded_content( &self, thread_view: &Entity, - is_running: bool, tool_call: &ToolCall, window: &Window, cx: &Context, @@ -7400,9 +7832,8 @@ impl ThreadView { .entry(session_id.clone()) .or_default() .clone(); - if is_running { - scroll_handle.scroll_to_bottom(); - } + + scroll_handle.scroll_to_bottom(); let rendered_entries: Vec = entries .get(entry_range) @@ -7461,113 +7892,6 @@ impl ThreadView { } } - fn render_rules_item(&self, cx: &Context) -> Option { - let project_context = self - .as_native_thread(cx)? - .read(cx) - .project_context() - .read(cx); - - let user_rules_text = if project_context.user_rules.is_empty() { - None - } else if project_context.user_rules.len() == 1 { - let user_rules = &project_context.user_rules[0]; - - match user_rules.title.as_ref() { - Some(title) => Some(format!("Using \"{title}\" user rule")), - None => Some("Using user rule".into()), - } - } else { - Some(format!( - "Using {} user rules", - project_context.user_rules.len() - )) - }; - - let first_user_rules_id = project_context - .user_rules - .first() - .map(|user_rules| user_rules.uuid.0); - - let rules_files = project_context - .worktrees - .iter() - .filter_map(|worktree| worktree.rules_file.as_ref()) - .collect::>(); - - let rules_file_text = match rules_files.as_slice() { - &[] => None, - &[rules_file] => Some(format!( - "Using project {:?} file", - rules_file.path_in_worktree - )), - rules_files => Some(format!("Using {} project rules files", rules_files.len())), - }; - - if user_rules_text.is_none() && rules_file_text.is_none() { - return None; - } - - let has_both = user_rules_text.is_some() && rules_file_text.is_some(); - - Some( - h_flex() - .px_2p5() - .child( - Icon::new(IconName::Attach) - .size(IconSize::XSmall) - .color(Color::Disabled), - ) - .when_some(user_rules_text, |parent, user_rules_text| { - parent.child( - h_flex() - .id("user-rules") - .ml_1() - .mr_1p5() - .child( - Label::new(user_rules_text) - .size(LabelSize::XSmall) - .color(Color::Muted) - .truncate(), - ) - .hover(|s| s.bg(cx.theme().colors().element_hover)) - .tooltip(Tooltip::text("View User Rules")) - .on_click(move |_event, window, cx| { - window.dispatch_action( - Box::new(OpenRulesLibrary { - prompt_to_select: first_user_rules_id, - }), - cx, - ) - }), - ) - }) - .when(has_both, |this| { - this.child( - Label::new("•") - .size(LabelSize::XSmall) - .color(Color::Disabled), - ) - }) - .when_some(rules_file_text, |parent, rules_file_text| { - parent.child( - h_flex() - .id("project-rules") - .ml_1p5() - .child( - Label::new(rules_file_text) - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - .hover(|s| s.bg(cx.theme().colors().element_hover)) - .tooltip(Tooltip::text("View Project Rules")) - .on_click(cx.listener(Self::handle_open_rules)), - ) - }) - .into_any(), - ) - } - fn tool_card_header_bg(&self, cx: &Context) -> Hsla { cx.theme() .colors() @@ -8227,7 +8551,7 @@ impl Render for ThreadView { cx.notify(); })) .on_action(cx.listener(|this, _: &EditFirstQueuedMessage, window, cx| { - this.move_queued_message_to_main_editor(0, None, window, cx); + this.move_queued_message_to_main_editor(0, None, None, window, cx); })) .on_action(cx.listener(|this, _: &ClearMessageQueue, _, cx| { this.local_queued_messages.clear(); diff --git a/crates/agent_ui/src/entry_view_state.rs b/crates/agent_ui/src/entry_view_state.rs index ef5e8a9812e8266566f027365e4b270177aab71c..eeaf8f6935a2294d8d9a1fe71b8d8acd62ee43a2 100644 --- a/crates/agent_ui/src/entry_view_state.rs +++ b/crates/agent_ui/src/entry_view_state.rs @@ -16,7 +16,7 @@ use prompt_store::PromptStore; use rope::Point; use settings::Settings as _; use terminal_view::TerminalView; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{Context, TextSize}; use workspace::Workspace; @@ -235,6 +235,11 @@ impl EntryViewState { }; entry.sync(message); } + AgentThreadEntry::CompletedPlan(_) => { + if !matches!(self.entries.get(index), Some(Entry::CompletedPlan)) { + self.set_entry(index, Entry::CompletedPlan); + } + } }; } @@ -253,7 +258,9 @@ impl EntryViewState { pub fn agent_ui_font_size_changed(&mut self, cx: &mut App) { for entry in self.entries.iter() { match entry { - Entry::UserMessage { .. } | Entry::AssistantMessage { .. } => {} + Entry::UserMessage { .. } + | Entry::AssistantMessage { .. } + | Entry::CompletedPlan => {} Entry::ToolCall(ToolCallEntry { content }) => { for view in content.values() { if let Ok(diff_editor) = view.clone().downcast::() { @@ -320,6 +327,7 @@ pub enum Entry { UserMessage(Entity), AssistantMessage(AssistantMessageEntry), ToolCall(ToolCallEntry), + CompletedPlan, } impl Entry { @@ -327,14 +335,14 @@ impl Entry { match self { Self::UserMessage(editor) => Some(editor.read(cx).focus_handle(cx)), Self::AssistantMessage(message) => Some(message.focus_handle.clone()), - Self::ToolCall(_) => None, + Self::ToolCall(_) | Self::CompletedPlan => None, } } pub fn message_editor(&self) -> Option<&Entity> { match self { Self::UserMessage(editor) => Some(editor), - Self::AssistantMessage(_) | Self::ToolCall(_) => None, + Self::AssistantMessage(_) | Self::ToolCall(_) | Self::CompletedPlan => None, } } @@ -361,7 +369,7 @@ impl Entry { ) -> Option { match self { Self::AssistantMessage(message) => message.scroll_handle_for_chunk(chunk_ix), - Self::UserMessage(_) | Self::ToolCall(_) => None, + Self::UserMessage(_) | Self::ToolCall(_) | Self::CompletedPlan => None, } } @@ -376,7 +384,7 @@ impl Entry { pub fn has_content(&self) -> bool { match self { Self::ToolCall(ToolCallEntry { content }) => !content.is_empty(), - Self::UserMessage(_) | Self::AssistantMessage(_) => false, + Self::UserMessage(_) | Self::AssistantMessage(_) | Self::CompletedPlan => false, } } } @@ -586,7 +594,7 @@ mod tests { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); release_channel::init(semver::Version::new(0, 0, 0), cx); }); } diff --git a/crates/agent_ui/src/inline_assistant.rs b/crates/agent_ui/src/inline_assistant.rs index 74f48c204957a76cc79bc71aac0526fde6f3ae5c..9a2b95519b5977cb9937d2a37c8ef8c133e57976 100644 --- a/crates/agent_ui/src/inline_assistant.rs +++ b/crates/agent_ui/src/inline_assistant.rs @@ -2061,29 +2061,28 @@ fn merge_ranges(ranges: &mut Vec>, buffer: &MultiBufferSnapshot) { } } -#[cfg(any(test, feature = "unit-eval"))] -#[cfg_attr(not(test), allow(dead_code))] -pub mod test { - - use std::sync::Arc; - +#[cfg(all(test, feature = "unit-eval"))] +pub mod evals { + use crate::InlineAssistant; use agent::ThreadStore; use client::{Client, UserStore}; use editor::{Editor, MultiBuffer, MultiBufferOffset}; + use eval_utils::{EvalOutput, NoProcessor}; use fs::FakeFs; use futures::channel::mpsc; use gpui::{AppContext, TestAppContext, UpdateGlobal as _}; use language::Buffer; + use language_model::{LanguageModelRegistry, SelectedModel}; use project::Project; use prompt_store::PromptBuilder; use smol::stream::StreamExt as _; + use std::str::FromStr; + use std::sync::Arc; use util::test::marked_text_ranges; use workspace::Workspace; - use crate::InlineAssistant; - #[derive(Debug)] - pub enum InlineAssistantOutput { + enum InlineAssistantOutput { Success { completion: Option, description: Option, @@ -2101,7 +2100,7 @@ pub mod test { }, } - pub fn run_inline_assistant_test( + fn run_inline_assistant_test( base_buffer: String, prompt: String, setup: SetupF, @@ -2232,18 +2231,6 @@ pub mod test { } } } -} - -#[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 crate::inline_assistant::test::{InlineAssistantOutput, run_inline_assistant_test}; #[test] #[cfg_attr(not(feature = "unit-eval"), ignore)] diff --git a/crates/agent_ui/src/inline_prompt_editor.rs b/crates/agent_ui/src/inline_prompt_editor.rs index 43e6b1ad393a8ca1d568bc8dc8df6b0fa9d977db..5d168d410476b1a367042d886715c2d57d50477e 100644 --- a/crates/agent_ui/src/inline_prompt_editor.rs +++ b/crates/agent_ui/src/inline_prompt_editor.rs @@ -24,7 +24,7 @@ use std::cmp; use std::ops::Range; use std::rc::Rc; use std::sync::Arc; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::utils::WithRemSize; use ui::{IconButtonShape, KeyBinding, PopoverMenuHandle, Tooltip, prelude::*}; use uuid::Uuid; diff --git a/crates/agent_ui/src/language_model_selector.rs b/crates/agent_ui/src/language_model_selector.rs index e6e72b3197b4108d7b423470bf8bb4b75cd055b7..899542245ab8f3618f6d70d807363cc91af3a257 100644 --- a/crates/agent_ui/src/language_model_selector.rs +++ b/crates/agent_ui/src/language_model_selector.rs @@ -724,7 +724,7 @@ mod tests { .any(|(fav_provider, fav_name)| *fav_provider == provider && *fav_name == name); ModelInfo { model: Arc::new(TestLanguageModel::new(name, provider)), - icon: IconOrSvg::Icon(IconName::Ai), + icon: IconOrSvg::Icon(IconName::ZedAgent), is_favorite, } }) diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs index c0aee0fc323977d9aa2822b592db0621c7061bba..b8e16de99f13d9eb6925e5618ccca81c742f8d12 100644 --- a/crates/agent_ui/src/mention_set.rs +++ b/crates/agent_ui/src/mention_set.rs @@ -667,7 +667,7 @@ mod tests { let settings_store = cx.update(SettingsStore::test); cx.set_global(settings_store); cx.update(|cx| { - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); release_channel::init(Version::new(0, 0, 0), cx); prompt_store::init(cx); }); @@ -835,6 +835,36 @@ pub(crate) async fn insert_images_as_context( } } +fn image_format_from_external_content(format: image::ImageFormat) -> Option { + match format { + image::ImageFormat::Png => Some(ImageFormat::Png), + image::ImageFormat::Jpeg => Some(ImageFormat::Jpeg), + image::ImageFormat::WebP => Some(ImageFormat::Webp), + image::ImageFormat::Gif => Some(ImageFormat::Gif), + image::ImageFormat::Bmp => Some(ImageFormat::Bmp), + image::ImageFormat::Tiff => Some(ImageFormat::Tiff), + image::ImageFormat::Ico => Some(ImageFormat::Ico), + _ => None, + } +} + +pub(crate) fn load_external_image_from_path( + path: &Path, + default_name: &SharedString, +) -> Option<(Image, SharedString)> { + let content = std::fs::read(path).ok()?; + let format = image::guess_format(&content) + .ok() + .and_then(image_format_from_external_content)?; + let name = path + .file_name() + .and_then(|name| name.to_str()) + .map(|name| SharedString::from(name.to_owned())) + .unwrap_or_else(|| default_name.clone()); + + Some((Image::from_bytes(format, content), name)) +} + pub(crate) fn paste_images_as_context( editor: Entity, mention_set: Entity, @@ -869,37 +899,11 @@ pub(crate) fn paste_images_as_context( if !paths.is_empty() { images.extend( cx.background_spawn(async move { - let mut images = vec![]; - for path in paths.into_iter().flat_map(|paths| paths.paths().to_owned()) { - let Ok(content) = async_fs::read(&path).await else { - continue; - }; - let Ok(format) = image::guess_format(&content) else { - continue; - }; - let name: SharedString = path - .file_name() - .and_then(|n| n.to_str()) - .map(|s| SharedString::from(s.to_owned())) - .unwrap_or_else(|| default_name.clone()); - images.push(( - gpui::Image::from_bytes( - match format { - image::ImageFormat::Png => gpui::ImageFormat::Png, - image::ImageFormat::Jpeg => gpui::ImageFormat::Jpeg, - image::ImageFormat::WebP => gpui::ImageFormat::Webp, - image::ImageFormat::Gif => gpui::ImageFormat::Gif, - image::ImageFormat::Bmp => gpui::ImageFormat::Bmp, - image::ImageFormat::Tiff => gpui::ImageFormat::Tiff, - image::ImageFormat::Ico => gpui::ImageFormat::Ico, - _ => continue, - }, - content, - ), - name, - )); - } - images + paths + .into_iter() + .flat_map(|paths| paths.paths().to_owned()) + .filter_map(|path| load_external_image_from_path(&path, &default_name)) + .collect::>() }) .await, ); diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index a4f444cfe7364dad64098eeb33b40078055a66d6..44a816f894f791f8b9f3b4753deef7028fae20ab 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -7,9 +7,7 @@ use crate::{ PromptCompletionProvider, PromptCompletionProviderDelegate, PromptContextAction, PromptContextType, SlashCommandCompletion, }, - mention_set::{ - Mention, MentionImage, MentionSet, insert_crease_for_mention, paste_images_as_context, - }, + mention_set::{Mention, MentionImage, MentionSet, insert_crease_for_mention}, }; use acp_thread::MentionUri; use agent::ThreadStore; @@ -28,12 +26,14 @@ use gpui::{ use language::{Buffer, language_settings::InlayHintKind}; use parking_lot::RwLock; use project::AgentId; -use project::{CompletionIntent, InlayHint, InlayHintLabel, InlayId, Project, Worktree}; +use project::{ + CompletionIntent, InlayHint, InlayHintLabel, InlayId, Project, ProjectPath, Worktree, +}; use prompt_store::PromptStore; use rope::Point; use settings::Settings; use std::{fmt::Write, ops::Range, rc::Rc, sync::Arc}; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ContextMenu, Disclosure, ElevationIndex, prelude::*}; use util::paths::PathStyle; use util::{ResultExt, debug_panic}; @@ -151,13 +151,246 @@ pub enum MessageEditorEvent { Cancel, Focus, LostFocus, - InputAttempted(Arc), + InputAttempted { + text: Arc, + cursor_offset: usize, + }, } impl EventEmitter for MessageEditor {} const COMMAND_HINT_INLAY_ID: InlayId = InlayId::Hint(0); +enum MentionInsertPosition { + AtCursor, + EndOfBuffer, +} + +fn insert_mention_for_project_path( + project_path: &ProjectPath, + position: MentionInsertPosition, + editor: &Entity, + mention_set: &Entity, + project: &Entity, + workspace: &Entity, + supports_images: bool, + window: &mut Window, + cx: &mut App, +) -> Option> { + let (file_name, mention_uri) = { + let project = project.read(cx); + let path_style = project.path_style(cx); + let entry = project.entry_for_path(project_path, cx)?; + let worktree = project.worktree_for_id(project_path.worktree_id, cx)?; + let abs_path = worktree.read(cx).absolutize(&project_path.path); + let (file_name, _) = crate::completion_provider::extract_file_name_and_directory( + &project_path.path, + worktree.read(cx).root_name(), + path_style, + ); + let mention_uri = if entry.is_dir() { + MentionUri::Directory { abs_path } + } else { + MentionUri::File { abs_path } + }; + (file_name, mention_uri) + }; + + let mention_text = mention_uri.as_link().to_string(); + let content_len = mention_text.len(); + + let text_anchor = match position { + MentionInsertPosition::AtCursor => editor.update(cx, |editor, cx| { + let buffer = editor.buffer().read(cx); + let snapshot = buffer.snapshot(cx); + let (_, _, buffer_snapshot) = snapshot.as_singleton()?; + let text_anchor = editor + .selections + .newest_anchor() + .start + .text_anchor + .bias_left(&buffer_snapshot); + + editor.insert(&mention_text, window, cx); + editor.insert(" ", window, cx); + + Some(text_anchor) + }), + MentionInsertPosition::EndOfBuffer => { + let multi_buffer = editor.read(cx).buffer().clone(); + let buffer = multi_buffer.read(cx).as_singleton()?; + let anchor = buffer.update(cx, |buffer, _cx| buffer.anchor_before(buffer.len())); + let new_text = format!("{mention_text} "); + editor.update(cx, |editor, cx| { + editor.edit( + [( + multi_buffer::Anchor::max()..multi_buffer::Anchor::max(), + new_text, + )], + cx, + ); + }); + Some(anchor) + } + }?; + + Some(mention_set.update(cx, |mention_set, cx| { + mention_set.confirm_mention_completion( + file_name, + text_anchor, + content_len, + mention_uri, + supports_images, + editor.clone(), + workspace, + window, + cx, + ) + })) +} + +enum ResolvedPastedContextItem { + Image(gpui::Image, gpui::SharedString), + ProjectPath(ProjectPath), +} + +async fn resolve_pasted_context_items( + project: Entity, + project_is_local: bool, + supports_images: bool, + entries: Vec, + cx: &mut gpui::AsyncWindowContext, +) -> (Vec, Vec>) { + let mut items = Vec::new(); + let mut added_worktrees = Vec::new(); + let default_image_name: SharedString = MentionUri::PastedImage.name().into(); + + for entry in entries { + match entry { + ClipboardEntry::String(_) => {} + ClipboardEntry::Image(image) => { + if supports_images { + items.push(ResolvedPastedContextItem::Image( + image, + default_image_name.clone(), + )); + } + } + ClipboardEntry::ExternalPaths(paths) => { + for path in paths.paths().iter() { + if let Some((image, name)) = cx + .background_spawn({ + let path = path.clone(); + let default_image_name = default_image_name.clone(); + async move { + crate::mention_set::load_external_image_from_path( + &path, + &default_image_name, + ) + } + }) + .await + { + if supports_images { + items.push(ResolvedPastedContextItem::Image(image, name)); + } + continue; + } + + if !project_is_local { + continue; + } + + let path = path.clone(); + let Ok(resolve_task) = cx.update({ + let project = project.clone(); + move |_, cx| Workspace::project_path_for_path(project, &path, false, cx) + }) else { + continue; + }; + + if let Some((worktree, project_path)) = resolve_task.await.log_err() { + added_worktrees.push(worktree); + items.push(ResolvedPastedContextItem::ProjectPath(project_path)); + } + } + } + } + } + + (items, added_worktrees) +} + +fn insert_project_path_as_context( + project_path: ProjectPath, + editor: Entity, + mention_set: Entity, + workspace: WeakEntity, + supports_images: bool, + cx: &mut gpui::AsyncWindowContext, +) -> Option> { + let workspace = workspace.upgrade()?; + + cx.update(move |window, cx| { + let project = workspace.read(cx).project().clone(); + insert_mention_for_project_path( + &project_path, + MentionInsertPosition::AtCursor, + &editor, + &mention_set, + &project, + &workspace, + supports_images, + window, + cx, + ) + }) + .ok() + .flatten() +} + +async fn insert_resolved_pasted_context_items( + items: Vec, + added_worktrees: Vec>, + editor: Entity, + mention_set: Entity, + workspace: WeakEntity, + supports_images: bool, + cx: &mut gpui::AsyncWindowContext, +) { + let mut path_mention_tasks = Vec::new(); + + for item in items { + match item { + ResolvedPastedContextItem::Image(image, name) => { + crate::mention_set::insert_images_as_context( + vec![(image, name)], + editor.clone(), + mention_set.clone(), + workspace.clone(), + cx, + ) + .await; + } + ResolvedPastedContextItem::ProjectPath(project_path) => { + if let Some(task) = insert_project_path_as_context( + project_path, + editor.clone(), + mention_set.clone(), + workspace.clone(), + supports_images, + cx, + ) { + path_mention_tasks.push(task); + } + } + } + } + + join_all(path_mention_tasks).await; + drop(added_worktrees); +} + impl MessageEditor { pub fn new( workspace: WeakEntity, @@ -257,7 +490,15 @@ impl MessageEditor { && editor.read(cx).read_only(cx) && !text.is_empty() { - cx.emit(MessageEditorEvent::InputAttempted(text.clone())); + let editor = editor.read(cx); + let cursor_anchor = editor.selections.newest_anchor().head(); + let cursor_offset = cursor_anchor + .to_offset(&editor.buffer().read(cx).snapshot(cx)) + .0; + cx.emit(MessageEditorEvent::InputAttempted { + text: text.clone(), + cursor_offset, + }); } if let EditorEvent::Edited { .. } = event @@ -848,9 +1089,8 @@ impl MessageEditor { } return; } - // Handle text paste with potential markdown mention links. - // This must be checked BEFORE paste_images_as_context because that function - // returns a task even when there are no images in the clipboard. + // Handle text paste with potential markdown mention links before + // clipboard context entries so markdown text still pastes as text. if let Some(clipboard_text) = cx.read_from_clipboard().and_then(|item| { item.entries().iter().find_map(|entry| match entry { ClipboardEntry::String(text) => Some(text.text().to_string()), @@ -947,30 +1187,7 @@ impl MessageEditor { } } - let has_non_text_content = cx - .read_from_clipboard() - .map(|item| { - item.entries().iter().any(|entry| { - matches!( - entry, - ClipboardEntry::Image(_) | ClipboardEntry::ExternalPaths(_) - ) - }) - }) - .unwrap_or(false); - - if self.session_capabilities.read().supports_images() - && has_non_text_content - && let Some(task) = paste_images_as_context( - self.editor.clone(), - self.mention_set.clone(), - self.workspace.clone(), - window, - cx, - ) - { - cx.stop_propagation(); - task.detach(); + if self.handle_pasted_context(window, cx) { return; } @@ -985,6 +1202,61 @@ impl MessageEditor { }); } + fn handle_pasted_context(&mut self, window: &mut Window, cx: &mut Context) -> bool { + let Some(clipboard) = cx.read_from_clipboard() else { + return false; + }; + + if matches!( + clipboard.entries().first(), + Some(ClipboardEntry::String(_)) | None + ) { + return false; + } + + let Some(workspace) = self.workspace.upgrade() else { + return false; + }; + let project = workspace.read(cx).project().clone(); + let project_is_local = project.read(cx).is_local(); + let supports_images = self.session_capabilities.read().supports_images(); + if !project_is_local && !supports_images { + return false; + } + let editor = self.editor.clone(); + let mention_set = self.mention_set.clone(); + let workspace = self.workspace.clone(); + let entries = clipboard.into_entries().collect::>(); + + cx.stop_propagation(); + + window + .spawn(cx, async move |mut cx| { + let (items, added_worktrees) = resolve_pasted_context_items( + project, + project_is_local, + supports_images, + entries, + &mut cx, + ) + .await; + insert_resolved_pasted_context_items( + items, + added_worktrees, + editor, + mention_set, + workspace, + supports_images, + &mut cx, + ) + .await; + Ok::<(), anyhow::Error>(()) + }) + .detach_and_log_err(cx); + + true + } + pub fn insert_dragged_files( &mut self, paths: Vec, @@ -996,60 +1268,22 @@ impl MessageEditor { return; }; let project = workspace.read(cx).project().clone(); - let path_style = project.read(cx).path_style(cx); - let buffer = self.editor.read(cx).buffer().clone(); - let Some(buffer) = buffer.read(cx).as_singleton() else { - return; - }; + let supports_images = self.session_capabilities.read().supports_images(); let mut tasks = Vec::new(); for path in paths { - let Some(entry) = project.read(cx).entry_for_path(&path, cx) else { - continue; - }; - let Some(worktree) = project.read(cx).worktree_for_id(path.worktree_id, cx) else { - continue; - }; - let abs_path = worktree.read(cx).absolutize(&path.path); - let (file_name, _) = crate::completion_provider::extract_file_name_and_directory( - &path.path, - worktree.read(cx).root_name(), - path_style, - ); - - let uri = if entry.is_dir() { - MentionUri::Directory { abs_path } - } else { - MentionUri::File { abs_path } - }; - - let new_text = format!("{} ", uri.as_link()); - let content_len = new_text.len() - 1; - - let anchor = buffer.update(cx, |buffer, _cx| buffer.anchor_before(buffer.len())); - - self.editor.update(cx, |message_editor, cx| { - message_editor.edit( - [( - multi_buffer::Anchor::max()..multi_buffer::Anchor::max(), - new_text, - )], - cx, - ); - }); - let supports_images = self.session_capabilities.read().supports_images(); - tasks.push(self.mention_set.update(cx, |mention_set, cx| { - mention_set.confirm_mention_completion( - file_name, - anchor, - content_len, - uri, - supports_images, - self.editor.clone(), - &workspace, - window, - cx, - ) - })); + if let Some(task) = insert_mention_for_project_path( + &path, + MentionInsertPosition::EndOfBuffer, + &self.editor, + &self.mention_set, + &project, + &workspace, + supports_images, + window, + cx, + ) { + tasks.push(task); + } } cx.spawn(async move |_, _| { join_all(tasks).await; @@ -1335,45 +1569,20 @@ impl MessageEditor { _ => return Ok::<(), anyhow::Error>(()), }; - let supported_formats = [ - ("png", gpui::ImageFormat::Png), - ("jpg", gpui::ImageFormat::Jpeg), - ("jpeg", gpui::ImageFormat::Jpeg), - ("webp", gpui::ImageFormat::Webp), - ("gif", gpui::ImageFormat::Gif), - ("bmp", gpui::ImageFormat::Bmp), - ("tiff", gpui::ImageFormat::Tiff), - ("tif", gpui::ImageFormat::Tiff), - ("ico", gpui::ImageFormat::Ico), - ]; - - let mut images = Vec::new(); - for path in paths { - let extension = path - .extension() - .and_then(|ext| ext.to_str()) - .map(|s| s.to_lowercase()); - - let Some(format) = extension.and_then(|ext| { - supported_formats - .iter() - .find(|(e, _)| *e == ext) - .map(|(_, f)| *f) - }) else { - continue; - }; - - let Ok(content) = async_fs::read(&path).await else { - continue; - }; - - let name: gpui::SharedString = path - .file_name() - .and_then(|n| n.to_str()) - .map(|s| gpui::SharedString::from(s.to_owned())) - .unwrap_or_else(|| "Image".into()); - images.push((gpui::Image::from_bytes(format, content), name)); - } + let default_image_name: SharedString = "Image".into(); + let images = cx + .background_spawn(async move { + paths + .into_iter() + .filter_map(|path| { + crate::mention_set::load_external_image_from_path( + &path, + &default_image_name, + ) + }) + .collect::>() + }) + .await; crate::mention_set::insert_images_as_context( images, @@ -1580,6 +1789,21 @@ impl MessageEditor { self.editor.read(cx).text(cx) } + pub fn set_cursor_offset( + &mut self, + offset: usize, + window: &mut Window, + cx: &mut Context, + ) { + self.editor.update(cx, |editor, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + let offset = snapshot.clip_offset(MultiBufferOffset(offset), text::Bias::Left); + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([offset..offset]); + }); + }); + } + pub fn insert_text(&mut self, text: &str, window: &mut Window, cx: &mut Context) { if text.is_empty() { return; @@ -1745,11 +1969,12 @@ fn find_matching_bracket(text: &str, open: char, close: char) -> Option { #[cfg(test)] mod tests { - use std::{ops::Range, path::Path, sync::Arc}; + use std::{ops::Range, path::Path, path::PathBuf, sync::Arc}; use acp_thread::MentionUri; use agent::{ThreadStore, outline}; use agent_client_protocol as acp; + use base64::Engine as _; use editor::{ AnchorRangeExt as _, Editor, EditorMode, MultiBufferOffset, SelectionEffects, actions::Paste, @@ -1758,14 +1983,14 @@ mod tests { use fs::FakeFs; use futures::StreamExt as _; use gpui::{ - AppContext, ClipboardItem, Entity, EventEmitter, FocusHandle, Focusable, TestAppContext, - VisualTestContext, + AppContext, ClipboardEntry, ClipboardItem, Entity, EventEmitter, ExternalPaths, + FocusHandle, Focusable, TestAppContext, VisualTestContext, }; use language_model::LanguageModelRegistry; use lsp::{CompletionContext, CompletionTriggerKind}; use parking_lot::RwLock; use project::{CompletionIntent, Project, ProjectPath}; - use serde_json::json; + use serde_json::{Value, json}; use text::Point; use ui::{App, Context, IntoElement, Render, SharedString, Window}; @@ -3793,6 +4018,285 @@ mod tests { }); } + #[gpui::test] + async fn test_paste_external_file_path_inserts_file_mention(cx: &mut TestAppContext) { + init_test(cx); + let (message_editor, editor, mut cx) = + setup_paste_test_message_editor(json!({"file.txt": "content"}), cx).await; + paste_external_paths( + &message_editor, + vec![PathBuf::from(path!("/project/file.txt"))], + &mut cx, + ); + + let expected_uri = MentionUri::File { + abs_path: path!("/project/file.txt").into(), + } + .to_uri() + .to_string(); + + editor.update(&mut cx, |editor, cx| { + assert_eq!(editor.text(cx), format!("[@file.txt]({expected_uri}) ")); + }); + + let contents = mention_contents(&message_editor, &mut cx).await; + + let [(uri, Mention::Text { content, .. })] = contents.as_slice() else { + panic!("Unexpected mentions"); + }; + assert_eq!(content, "content"); + assert_eq!( + uri, + &MentionUri::File { + abs_path: path!("/project/file.txt").into(), + } + ); + } + + #[gpui::test] + async fn test_paste_external_directory_path_inserts_directory_mention(cx: &mut TestAppContext) { + init_test(cx); + let (message_editor, editor, mut cx) = setup_paste_test_message_editor( + json!({ + "src": { + "main.rs": "fn main() {}\n", + } + }), + cx, + ) + .await; + paste_external_paths( + &message_editor, + vec![PathBuf::from(path!("/project/src"))], + &mut cx, + ); + + let expected_uri = MentionUri::Directory { + abs_path: path!("/project/src").into(), + } + .to_uri() + .to_string(); + + editor.update(&mut cx, |editor, cx| { + assert_eq!(editor.text(cx), format!("[@src]({expected_uri}) ")); + }); + + let contents = mention_contents(&message_editor, &mut cx).await; + + let [(uri, Mention::Link)] = contents.as_slice() else { + panic!("Unexpected mentions"); + }; + assert_eq!( + uri, + &MentionUri::Directory { + abs_path: path!("/project/src").into(), + } + ); + } + + #[gpui::test] + async fn test_paste_external_file_path_inserts_at_cursor(cx: &mut TestAppContext) { + init_test(cx); + let (message_editor, editor, mut cx) = + setup_paste_test_message_editor(json!({"file.txt": "content"}), cx).await; + + editor.update_in(&mut cx, |editor, window, cx| { + editor.set_text("Hello world", window, cx); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.select_ranges([MultiBufferOffset(6)..MultiBufferOffset(6)]); + }); + }); + + paste_external_paths( + &message_editor, + vec![PathBuf::from(path!("/project/file.txt"))], + &mut cx, + ); + + let expected_uri = MentionUri::File { + abs_path: path!("/project/file.txt").into(), + } + .to_uri() + .to_string(); + + editor.update(&mut cx, |editor, cx| { + assert_eq!( + editor.text(cx), + format!("Hello [@file.txt]({expected_uri}) world") + ); + }); + } + + #[gpui::test] + async fn test_paste_mixed_external_image_without_extension_and_file_path( + cx: &mut TestAppContext, + ) { + init_test(cx); + let (message_editor, editor, mut cx) = + setup_paste_test_message_editor(json!({"file.txt": "content"}), cx).await; + + message_editor.update(&mut cx, |message_editor, _cx| { + message_editor + .session_capabilities + .write() + .set_prompt_capabilities(acp::PromptCapabilities::new().image(true)); + }); + + let temporary_image_path = write_test_png_file(None); + paste_external_paths( + &message_editor, + vec![ + temporary_image_path.clone(), + PathBuf::from(path!("/project/file.txt")), + ], + &mut cx, + ); + + std::fs::remove_file(&temporary_image_path).expect("remove temp png"); + + let expected_file_uri = MentionUri::File { + abs_path: path!("/project/file.txt").into(), + } + .to_uri() + .to_string(); + let expected_image_uri = MentionUri::PastedImage.to_uri().to_string(); + + editor.update(&mut cx, |editor, cx| { + assert_eq!( + editor.text(cx), + format!("[@Image]({expected_image_uri}) [@file.txt]({expected_file_uri}) ") + ); + }); + + let contents = mention_contents(&message_editor, &mut cx).await; + + assert_eq!(contents.len(), 2); + assert!(contents.iter().any(|(uri, mention)| { + *uri == MentionUri::PastedImage && matches!(mention, Mention::Image(_)) + })); + assert!(contents.iter().any(|(uri, mention)| { + *uri == MentionUri::File { + abs_path: path!("/project/file.txt").into(), + } && matches!( + mention, + Mention::Text { + content, + tracked_buffers: _, + } if content == "content" + ) + })); + } + + async fn setup_paste_test_message_editor( + project_tree: Value, + cx: &mut TestAppContext, + ) -> (Entity, Entity, VisualTestContext) { + let app_state = cx.update(AppState::test); + + cx.update(|cx| { + editor::init(cx); + workspace::init(app_state.clone(), cx); + }); + + app_state + .fs + .as_fake() + .insert_tree(path!("/project"), project_tree) + .await; + + let project = Project::test(app_state.fs.clone(), [path!("/project").as_ref()], cx).await; + let window = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + + let mut cx = VisualTestContext::from_window(window.into(), cx); + + let thread_store = cx.new(|cx| ThreadStore::new(cx)); + + let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| { + let workspace_handle = cx.weak_entity(); + let message_editor = cx.new(|cx| { + MessageEditor::new( + workspace_handle, + project.downgrade(), + Some(thread_store), + None, + None, + Default::default(), + "Test Agent".into(), + "Test", + EditorMode::AutoHeight { + max_lines: None, + min_lines: 1, + }, + window, + cx, + ) + }); + workspace.active_pane().update(cx, |pane, cx| { + pane.add_item( + Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))), + true, + true, + None, + window, + cx, + ); + }); + message_editor.read(cx).focus_handle(cx).focus(window, cx); + let editor = message_editor.read(cx).editor().clone(); + (message_editor, editor) + }); + + (message_editor, editor, cx) + } + + fn paste_external_paths( + message_editor: &Entity, + paths: Vec, + cx: &mut VisualTestContext, + ) { + cx.write_to_clipboard(ClipboardItem { + entries: vec![ClipboardEntry::ExternalPaths(ExternalPaths(paths.into()))], + }); + + message_editor.update_in(cx, |message_editor, window, cx| { + message_editor.paste(&Paste, window, cx); + }); + cx.run_until_parked(); + } + + async fn mention_contents( + message_editor: &Entity, + cx: &mut VisualTestContext, + ) -> Vec<(MentionUri, Mention)> { + message_editor + .update(cx, |message_editor, cx| { + message_editor + .mention_set() + .update(cx, |mention_set, cx| mention_set.contents(false, cx)) + }) + .await + .unwrap() + .into_values() + .collect::>() + } + + fn write_test_png_file(extension: Option<&str>) -> PathBuf { + let bytes = base64::prelude::BASE64_STANDARD + .decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==") + .expect("decode png"); + let file_name = match extension { + Some(extension) => format!("zed-agent-ui-test-{}.{}", uuid::Uuid::new_v4(), extension), + None => format!("zed-agent-ui-test-{}", uuid::Uuid::new_v4()), + }; + let path = std::env::temp_dir().join(file_name); + std::fs::write(&path, bytes).expect("write temp png"); + path + } + // Helper that creates a minimal MessageEditor inside a window, returning both // the entity and the underlying VisualTestContext so callers can drive updates. async fn setup_message_editor( diff --git a/crates/agent_ui/src/terminal_inline_assistant.rs b/crates/agent_ui/src/terminal_inline_assistant.rs index 281edc838c959ee47216811812ebb53f0467f3c0..d8bcabf276e76c4701894d2830af88171072fe49 100644 --- a/crates/agent_ui/src/terminal_inline_assistant.rs +++ b/crates/agent_ui/src/terminal_inline_assistant.rs @@ -10,15 +10,14 @@ use agent::ThreadStore; use agent_settings::AgentSettings; use anyhow::{Context as _, Result}; -use cloud_llm_client::CompletionIntent; use collections::{HashMap, VecDeque}; use editor::{MultiBuffer, actions::SelectAll}; use fs::Fs; use gpui::{App, Entity, Focusable, Global, Subscription, Task, UpdateGlobal, WeakEntity}; use language::Buffer; use language_model::{ - ConfiguredModel, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, - Role, report_anthropic_event, + CompletionIntent, ConfiguredModel, LanguageModelRegistry, LanguageModelRequest, + LanguageModelRequestMessage, Role, report_anthropic_event, }; use project::Project; use prompt_store::{PromptBuilder, PromptStore}; diff --git a/crates/agent_ui/src/test_support.rs b/crates/agent_ui/src/test_support.rs index 43efc85f02f581fe2d2b9d6b3efb7f332b1944e9..94502485b1f3a2bb6a6d88ccd897de56c5a566f5 100644 --- a/crates/agent_ui/src/test_support.rs +++ b/crates/agent_ui/src/test_support.rs @@ -48,7 +48,7 @@ where C: 'static + AgentConnection + Send + Clone, { fn logo(&self) -> ui::IconName { - ui::IconName::Ai + ui::IconName::ZedAgent } fn agent_id(&self) -> AgentId { @@ -73,7 +73,7 @@ pub fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); release_channel::init("0.0.0".parse().unwrap(), cx); agent_panel::init(cx); diff --git a/crates/agent_ui/src/text_thread_editor.rs b/crates/agent_ui/src/text_thread_editor.rs index 5cac22e0a069f94ed9d1138cc16cf14b3f10ffff..180a31edde29b7ef78ee263a437458abd5affafc 100644 --- a/crates/agent_ui/src/text_thread_editor.rs +++ b/crates/agent_ui/src/text_thread_editor.rs @@ -1,5 +1,6 @@ use crate::{ language_model_selector::{LanguageModelSelector, language_model_selector}, + mention_set::load_external_image_from_path, ui::ModelSelectorTooltip, }; use anyhow::Result; @@ -894,7 +895,7 @@ impl TextThreadEditor { |_, _, _, _| Empty.into_any_element(), ) .with_metadata(CreaseMetadata { - icon_path: SharedString::from(IconName::Ai.path()), + icon_path: SharedString::from(IconName::ZedAgent.path()), label: "Thinking Process".into(), }), ); @@ -1029,7 +1030,11 @@ impl TextThreadEditor { h_flex() .items_center() .gap_1() - .font(theme::ThemeSettings::get_global(cx).buffer_font.clone()) + .font( + theme_settings::ThemeSettings::get_global(cx) + .buffer_font + .clone(), + ) .text_size(TextSize::XSmall.rems(cx)) .text_color(colors.text_muted) .child("Press") @@ -1900,26 +1905,12 @@ impl TextThreadEditor { } } + let default_image_name: SharedString = "Image".into(); for path in paths { - let Ok(content) = std::fs::read(path) else { - continue; - }; - let Ok(format) = image::guess_format(&content) else { + let Some((image, _)) = load_external_image_from_path(&path, &default_image_name) else { continue; }; - images.push(gpui::Image::from_bytes( - match format { - image::ImageFormat::Png => gpui::ImageFormat::Png, - image::ImageFormat::Jpeg => gpui::ImageFormat::Jpeg, - image::ImageFormat::WebP => gpui::ImageFormat::Webp, - image::ImageFormat::Gif => gpui::ImageFormat::Gif, - image::ImageFormat::Bmp => gpui::ImageFormat::Bmp, - image::ImageFormat::Tiff => gpui::ImageFormat::Tiff, - image::ImageFormat::Ico => gpui::ImageFormat::Ico, - _ => continue, - }, - content, - )); + images.push(image); } // Respect entry priority order — if the first entry is text, the source @@ -2256,7 +2247,7 @@ impl TextThreadEditor { let provider_icon = active_provider .as_ref() .map(|p| p.icon()) - .unwrap_or(IconOrSvg::Icon(IconName::Ai)); + .unwrap_or(IconOrSvg::Icon(IconName::ZedAgent)); let (color, icon) = if self.language_model_selector_menu_handle.is_deployed() { (Color::Accent, IconName::ChevronUp) @@ -3453,7 +3444,7 @@ mod tests { LanguageModelRegistry::test(cx); cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); } #[gpui::test] diff --git a/crates/agent_ui/src/thread_history.rs b/crates/agent_ui/src/thread_history.rs index d2aa38e13ceae1d088e8b078ce741c42f4c31206..7b7a3e60211896bf717fb3dfb2670d92b7409281 100644 --- a/crates/agent_ui/src/thread_history.rs +++ b/crates/agent_ui/src/thread_history.rs @@ -232,7 +232,7 @@ mod tests { cx.update(|cx| { let settings_store = settings::SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); }); } diff --git a/crates/agent_ui/src/thread_import.rs b/crates/agent_ui/src/thread_import.rs new file mode 100644 index 0000000000000000000000000000000000000000..c01403d795d9f377fa1dce0a5171f37d214b9f33 --- /dev/null +++ b/crates/agent_ui/src/thread_import.rs @@ -0,0 +1,660 @@ +use acp_thread::AgentSessionListRequest; +use agent::ThreadStore; +use agent_client_protocol as acp; +use chrono::Utc; +use collections::HashSet; +use fs::Fs; +use futures::FutureExt as _; +use gpui::{ + App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseDownEvent, + Render, SharedString, Task, WeakEntity, Window, +}; +use notifications::status_toast::{StatusToast, ToastIcon}; +use project::{AgentId, AgentRegistryStore, AgentServerStore}; +use ui::{Checkbox, CommonAnimationExt as _, KeyBinding, ListItem, ListItemSpacing, prelude::*}; +use util::ResultExt; +use workspace::{ModalView, MultiWorkspace, Workspace}; + +use crate::{ + Agent, AgentPanel, + agent_connection_store::AgentConnectionStore, + thread_metadata_store::{ThreadMetadata, ThreadMetadataStore}, +}; + +#[derive(Clone)] +struct AgentEntry { + agent_id: AgentId, + display_name: SharedString, + icon_path: Option, +} + +pub struct ThreadImportModal { + focus_handle: FocusHandle, + workspace: WeakEntity, + multi_workspace: WeakEntity, + agent_entries: Vec, + unchecked_agents: HashSet, + is_importing: bool, + last_error: Option, +} + +impl ThreadImportModal { + pub fn new( + agent_server_store: Entity, + agent_registry_store: Entity, + workspace: WeakEntity, + multi_workspace: WeakEntity, + _window: &mut Window, + cx: &mut Context, + ) -> Self { + let agent_entries = agent_server_store + .read(cx) + .external_agents() + .map(|agent_id| { + let display_name = agent_server_store + .read(cx) + .agent_display_name(agent_id) + .or_else(|| { + agent_registry_store + .read(cx) + .agent(agent_id) + .map(|agent| agent.name().clone()) + }) + .unwrap_or_else(|| agent_id.0.clone()); + let icon_path = agent_server_store + .read(cx) + .agent_icon(agent_id) + .or_else(|| { + agent_registry_store + .read(cx) + .agent(agent_id) + .and_then(|agent| agent.icon_path().cloned()) + }); + + AgentEntry { + agent_id: agent_id.clone(), + display_name, + icon_path, + } + }) + .collect::>(); + + Self { + focus_handle: cx.focus_handle(), + workspace, + multi_workspace, + agent_entries, + unchecked_agents: HashSet::default(), + is_importing: false, + last_error: None, + } + } + + fn agent_ids(&self) -> Vec { + self.agent_entries + .iter() + .map(|entry| entry.agent_id.clone()) + .collect() + } + + fn set_agent_checked(&mut self, agent_id: AgentId, state: ToggleState, cx: &mut Context) { + match state { + ToggleState::Selected => { + self.unchecked_agents.remove(&agent_id); + } + ToggleState::Unselected | ToggleState::Indeterminate => { + self.unchecked_agents.insert(agent_id); + } + } + cx.notify(); + } + + fn toggle_agent_checked(&mut self, agent_id: AgentId, cx: &mut Context) { + if self.unchecked_agents.contains(&agent_id) { + self.unchecked_agents.remove(&agent_id); + } else { + self.unchecked_agents.insert(agent_id); + } + cx.notify(); + } + + fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context) { + cx.emit(DismissEvent); + } + + fn import_threads(&mut self, _: &menu::Confirm, _: &mut Window, cx: &mut Context) { + if self.is_importing { + return; + } + + let Some(multi_workspace) = self.multi_workspace.upgrade() else { + self.is_importing = false; + cx.notify(); + return; + }; + + let stores = resolve_agent_connection_stores(&multi_workspace, cx); + if stores.is_empty() { + log::error!("Did not find any workspaces to import from"); + self.is_importing = false; + cx.notify(); + return; + } + + self.is_importing = true; + self.last_error = None; + cx.notify(); + + let agent_ids = self + .agent_ids() + .into_iter() + .filter(|agent_id| !self.unchecked_agents.contains(agent_id)) + .collect::>(); + + let existing_sessions = ThreadMetadataStore::global(cx) + .read(cx) + .entry_ids() + .collect::>(); + + let task = find_threads_to_import(agent_ids, existing_sessions, stores, cx); + cx.spawn(async move |this, cx| { + let result = task.await; + this.update(cx, |this, cx| match result { + Ok(threads) => { + let imported_count = threads.len(); + ThreadMetadataStore::global(cx) + .update(cx, |store, cx| store.save_all(threads, cx)); + this.is_importing = false; + this.last_error = None; + this.show_imported_threads_toast(imported_count, cx); + cx.emit(DismissEvent); + } + Err(error) => { + this.is_importing = false; + this.last_error = Some(error.to_string().into()); + cx.notify(); + } + }) + }) + .detach_and_log_err(cx); + } + + fn show_imported_threads_toast(&self, imported_count: usize, cx: &mut App) { + let status_toast = if imported_count == 0 { + StatusToast::new("No threads found to import.", cx, |this, _cx| { + this.icon(ToastIcon::new(IconName::Info).color(Color::Info)) + }) + } else { + let message = if imported_count == 1 { + "Imported 1 thread.".to_string() + } else { + format!("Imported {imported_count} threads.") + }; + StatusToast::new(message, cx, |this, _cx| { + this.icon(ToastIcon::new(IconName::Check).color(Color::Success)) + }) + }; + + self.workspace + .update(cx, |workspace, cx| { + workspace.toggle_status_toast(status_toast, cx); + }) + .log_err(); + } +} + +impl EventEmitter for ThreadImportModal {} + +impl Focusable for ThreadImportModal { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl ModalView for ThreadImportModal {} + +impl Render for ThreadImportModal { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let agent_rows = self + .agent_entries + .iter() + .enumerate() + .map(|(ix, entry)| { + let is_checked = !self.unchecked_agents.contains(&entry.agent_id); + + ListItem::new(("thread-import-agent", ix)) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .start_slot( + Checkbox::new( + ("thread-import-agent-checkbox", ix), + if is_checked { + ToggleState::Selected + } else { + ToggleState::Unselected + }, + ) + .on_click({ + let agent_id = entry.agent_id.clone(); + cx.listener(move |this, state: &ToggleState, _window, cx| { + this.set_agent_checked(agent_id.clone(), *state, cx); + }) + }), + ) + .on_click({ + let agent_id = entry.agent_id.clone(); + cx.listener(move |this, _event, _window, cx| { + this.toggle_agent_checked(agent_id.clone(), cx); + }) + }) + .child( + h_flex() + .w_full() + .gap_2() + .child(if let Some(icon_path) = entry.icon_path.clone() { + Icon::from_external_svg(icon_path) + .color(Color::Muted) + .size(IconSize::Small) + } else { + Icon::new(IconName::Sparkle) + .color(Color::Muted) + .size(IconSize::Small) + }) + .child(Label::new(entry.display_name.clone())), + ) + }) + .collect::>(); + + let has_agents = !self.agent_entries.is_empty(); + + v_flex() + .id("thread-import-modal") + .key_context("ThreadImportModal") + .w(rems(34.)) + .elevation_3(cx) + .overflow_hidden() + .track_focus(&self.focus_handle) + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::import_threads)) + .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| { + this.focus_handle.focus(window, cx); + })) + // Header + .child( + v_flex() + .p_4() + .pb_2() + .gap_1() + .child(Headline::new("Import Threads").size(HeadlineSize::Small)) + .child( + Label::new( + "Select the agents whose threads you'd like to import. \ + Imported threads will appear in your thread archive.", + ) + .color(Color::Muted) + .size(LabelSize::Small), + ), + ) + // Agent list + .child( + v_flex() + .id("thread-import-agent-list") + .px_2() + .max_h(rems(20.)) + .overflow_y_scroll() + .when(has_agents, |this| this.children(agent_rows)) + .when(!has_agents, |this| { + this.child( + div().p_4().child( + Label::new("No ACP agents available.") + .color(Color::Muted) + .size(LabelSize::Small), + ), + ) + }), + ) + // Footer + .child( + h_flex() + .w_full() + .p_3() + .gap_2() + .items_center() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child(div().flex_1().min_w_0().when_some( + self.last_error.clone(), + |this, error| { + this.child( + Label::new(error) + .size(LabelSize::Small) + .color(Color::Error) + .truncate(), + ) + }, + )) + .child( + h_flex() + .gap_2() + .items_center() + .when(self.is_importing, |this| { + this.child( + Icon::new(IconName::ArrowCircle) + .size(IconSize::Small) + .color(Color::Muted) + .with_rotate_animation(2), + ) + }) + .child( + Button::new("import-threads", "Import Threads") + .disabled(self.is_importing || !has_agents) + .key_binding( + KeyBinding::for_action(&menu::Confirm, cx) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(cx.listener(|this, _, window, cx| { + this.import_threads(&menu::Confirm, window, cx); + })), + ), + ), + ) + } +} + +fn resolve_agent_connection_stores( + multi_workspace: &Entity, + cx: &App, +) -> Vec> { + let mut stores = Vec::new(); + let mut included_local_store = false; + + for workspace in multi_workspace.read(cx).workspaces() { + let workspace = workspace.read(cx); + let project = workspace.project().read(cx); + + // We only want to include scores from one local workspace, since we + // know that they live on the same machine + let include_store = if project.is_remote() { + true + } else if project.is_local() && !included_local_store { + included_local_store = true; + true + } else { + false + }; + + if !include_store { + continue; + } + + if let Some(panel) = workspace.panel::(cx) { + stores.push(panel.read(cx).connection_store().clone()); + } + } + + stores +} + +fn find_threads_to_import( + agent_ids: Vec, + existing_sessions: HashSet, + stores: Vec>, + cx: &mut App, +) -> Task>> { + let mut wait_for_connection_tasks = Vec::new(); + + for store in stores { + for agent_id in agent_ids.clone() { + let agent = Agent::from(agent_id.clone()); + let server = agent.server(::global(cx), ThreadStore::global(cx)); + let entry = store.update(cx, |store, cx| store.request_connection(agent, server, cx)); + wait_for_connection_tasks + .push(entry.read(cx).wait_for_connection().map(|s| (agent_id, s))); + } + } + + let mut session_list_tasks = Vec::new(); + cx.spawn(async move |cx| { + let results = futures::future::join_all(wait_for_connection_tasks).await; + for (agent, result) in results { + let Some(state) = result.log_err() else { + continue; + }; + let Some(list) = cx.update(|cx| state.connection.session_list(cx)) else { + continue; + }; + let task = cx.update(|cx| { + list.list_sessions(AgentSessionListRequest::default(), cx) + .map(|r| (agent, r)) + }); + session_list_tasks.push(task); + } + + let mut sessions_by_agent = Vec::new(); + let results = futures::future::join_all(session_list_tasks).await; + for (agent_id, result) in results { + let Some(response) = result.log_err() else { + continue; + }; + sessions_by_agent.push((agent_id, response.sessions)); + } + + Ok(collect_importable_threads( + sessions_by_agent, + existing_sessions, + )) + }) +} + +fn collect_importable_threads( + sessions_by_agent: Vec<(AgentId, Vec)>, + mut existing_sessions: HashSet, +) -> Vec { + let mut to_insert = Vec::new(); + for (agent_id, sessions) in sessions_by_agent { + for session in sessions { + if !existing_sessions.insert(session.session_id.clone()) { + continue; + } + let Some(folder_paths) = session.work_dirs else { + continue; + }; + to_insert.push(ThreadMetadata { + session_id: session.session_id, + agent_id: agent_id.clone(), + title: session + .title + .unwrap_or_else(|| crate::DEFAULT_THREAD_TITLE.into()), + updated_at: session.updated_at.unwrap_or_else(|| Utc::now()), + created_at: session.created_at, + folder_paths, + archived: true, + }); + } + } + to_insert +} + +#[cfg(test)] +mod tests { + use super::*; + use acp_thread::AgentSessionInfo; + use chrono::Utc; + use std::path::Path; + use workspace::PathList; + + fn make_session( + session_id: &str, + title: Option<&str>, + work_dirs: Option, + updated_at: Option>, + created_at: Option>, + ) -> AgentSessionInfo { + AgentSessionInfo { + session_id: acp::SessionId::new(session_id), + title: title.map(|t| SharedString::from(t.to_string())), + work_dirs, + updated_at, + created_at, + meta: None, + } + } + + #[test] + fn test_collect_skips_sessions_already_in_existing_set() { + let existing = HashSet::from_iter(vec![acp::SessionId::new("existing-1")]); + let paths = PathList::new(&[Path::new("/project")]); + + let sessions_by_agent = vec![( + AgentId::new("agent-a"), + vec![ + make_session( + "existing-1", + Some("Already There"), + Some(paths.clone()), + None, + None, + ), + make_session("new-1", Some("Brand New"), Some(paths), None, None), + ], + )]; + + let result = collect_importable_threads(sessions_by_agent, existing); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].session_id.0.as_ref(), "new-1"); + assert_eq!(result[0].title.as_ref(), "Brand New"); + } + + #[test] + fn test_collect_skips_sessions_without_work_dirs() { + let existing = HashSet::default(); + let paths = PathList::new(&[Path::new("/project")]); + + let sessions_by_agent = vec![( + AgentId::new("agent-a"), + vec![ + make_session("has-dirs", Some("With Dirs"), Some(paths), None, None), + make_session("no-dirs", Some("No Dirs"), None, None, None), + ], + )]; + + let result = collect_importable_threads(sessions_by_agent, existing); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].session_id.0.as_ref(), "has-dirs"); + } + + #[test] + fn test_collect_marks_all_imported_threads_as_archived() { + let existing = HashSet::default(); + let paths = PathList::new(&[Path::new("/project")]); + + let sessions_by_agent = vec![( + AgentId::new("agent-a"), + vec![ + make_session("s1", Some("Thread 1"), Some(paths.clone()), None, None), + make_session("s2", Some("Thread 2"), Some(paths), None, None), + ], + )]; + + let result = collect_importable_threads(sessions_by_agent, existing); + + assert_eq!(result.len(), 2); + assert!(result.iter().all(|t| t.archived)); + } + + #[test] + fn test_collect_assigns_correct_agent_id_per_session() { + let existing = HashSet::default(); + let paths = PathList::new(&[Path::new("/project")]); + + let sessions_by_agent = vec![ + ( + AgentId::new("agent-a"), + vec![make_session( + "s1", + Some("From A"), + Some(paths.clone()), + None, + None, + )], + ), + ( + AgentId::new("agent-b"), + vec![make_session("s2", Some("From B"), Some(paths), None, None)], + ), + ]; + + let result = collect_importable_threads(sessions_by_agent, existing); + + assert_eq!(result.len(), 2); + let s1 = result + .iter() + .find(|t| t.session_id.0.as_ref() == "s1") + .unwrap(); + let s2 = result + .iter() + .find(|t| t.session_id.0.as_ref() == "s2") + .unwrap(); + assert_eq!(s1.agent_id.as_ref(), "agent-a"); + assert_eq!(s2.agent_id.as_ref(), "agent-b"); + } + + #[test] + fn test_collect_deduplicates_across_agents() { + let existing = HashSet::default(); + let paths = PathList::new(&[Path::new("/project")]); + + let sessions_by_agent = vec![ + ( + AgentId::new("agent-a"), + vec![make_session( + "shared-session", + Some("From A"), + Some(paths.clone()), + None, + None, + )], + ), + ( + AgentId::new("agent-b"), + vec![make_session( + "shared-session", + Some("From B"), + Some(paths), + None, + None, + )], + ), + ]; + + let result = collect_importable_threads(sessions_by_agent, existing); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].session_id.0.as_ref(), "shared-session"); + assert_eq!( + result[0].agent_id.as_ref(), + "agent-a", + "first agent encountered should win" + ); + } + + #[test] + fn test_collect_all_existing_returns_empty() { + let paths = PathList::new(&[Path::new("/project")]); + let existing = + HashSet::from_iter(vec![acp::SessionId::new("s1"), acp::SessionId::new("s2")]); + + let sessions_by_agent = vec![( + AgentId::new("agent-a"), + vec![ + make_session("s1", Some("T1"), Some(paths.clone()), None, None), + make_session("s2", Some("T2"), Some(paths), None, None), + ], + )]; + + let result = collect_importable_threads(sessions_by_agent, existing); + assert!(result.is_empty()); + } +} diff --git a/crates/agent_ui/src/thread_metadata_store.rs b/crates/agent_ui/src/thread_metadata_store.rs index ee34305e946284629b72f979a3967956ffe0af96..e0404470c9f7cab94907299c00bbf41bc8c904da 100644 --- a/crates/agent_ui/src/thread_metadata_store.rs +++ b/crates/agent_ui/src/thread_metadata_store.rs @@ -1,11 +1,10 @@ use std::{path::Path, sync::Arc}; -use acp_thread::AgentSessionInfo; use agent::{ThreadStore, ZED_AGENT_ID}; use agent_client_protocol as acp; -use anyhow::{Context as _, Result}; +use anyhow::Context as _; use chrono::{DateTime, Utc}; -use collections::HashMap; +use collections::{HashMap, HashSet}; use db::{ sqlez::{ bindable::Column, domain::Domain, statement::Statement, @@ -24,7 +23,7 @@ use workspace::PathList; use crate::DEFAULT_THREAD_TITLE; pub fn init(cx: &mut App) { - SidebarThreadMetadataStore::init_global(cx); + ThreadMetadataStore::init_global(cx); if cx.has_flag::() { migrate_thread_metadata(cx); @@ -38,102 +37,93 @@ pub fn init(cx: &mut App) { } /// Migrate existing thread metadata from native agent thread store to the new metadata storage. -/// We migrate the last 10 threads per project and skip threads that do not have a project. +/// We skip migrating threads that do not have a project. /// /// TODO: Remove this after N weeks of shipping the sidebar fn migrate_thread_metadata(cx: &mut App) { - const MAX_MIGRATED_THREADS_PER_PROJECT: usize = 10; - - let store = SidebarThreadMetadataStore::global(cx); + let store = ThreadMetadataStore::global(cx); let db = store.read(cx).db.clone(); cx.spawn(async move |cx| { - if !db.is_empty()? { - return Ok::<(), anyhow::Error>(()); - } + let existing_entries = db.list_ids()?.into_iter().collect::>(); - let metadata = store.read_with(cx, |_store, app| { - let mut migrated_threads_per_project = HashMap::default(); - - ThreadStore::global(app) - .read(app) + let to_migrate = store.read_with(cx, |_store, cx| { + ThreadStore::global(cx) + .read(cx) .entries() .filter_map(|entry| { - if entry.folder_paths.is_empty() { + if existing_entries.contains(&entry.id.0) || entry.folder_paths.is_empty() { return None; } - let migrated_thread_count = migrated_threads_per_project - .entry(entry.folder_paths.clone()) - .or_insert(0); - if *migrated_thread_count >= MAX_MIGRATED_THREADS_PER_PROJECT { - return None; - } - *migrated_thread_count += 1; - Some(ThreadMetadata { session_id: entry.id, - agent_id: None, + agent_id: ZED_AGENT_ID.clone(), title: entry.title, updated_at: entry.updated_at, created_at: entry.created_at, folder_paths: entry.folder_paths, + archived: true, }) }) .collect::>() }); + if to_migrate.is_empty() { + return anyhow::Ok(()); + } + + log::info!("Migrating {} thread store entries", to_migrate.len()); + // Manually save each entry to the database and call reload, otherwise // we'll end up triggering lots of reloads after each save - for entry in metadata { + for entry in to_migrate { db.save(entry).await?; } + log::info!("Finished migrating thread store entries"); + let _ = store.update(cx, |store, cx| store.reload(cx)); - Ok(()) + anyhow::Ok(()) }) .detach_and_log_err(cx); } -struct GlobalThreadMetadataStore(Entity); +struct GlobalThreadMetadataStore(Entity); impl Global for GlobalThreadMetadataStore {} /// Lightweight metadata for any thread (native or ACP), enough to populate /// the sidebar list and route to the correct load path when clicked. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct ThreadMetadata { pub session_id: acp::SessionId, - /// `None` for native Zed threads, `Some("claude-code")` etc. for ACP agents. - pub agent_id: Option, + pub agent_id: AgentId, pub title: SharedString, pub updated_at: DateTime, pub created_at: Option>, pub folder_paths: PathList, + pub archived: bool, } -impl ThreadMetadata { - pub fn from_session_info(agent_id: AgentId, session: &AgentSessionInfo) -> Self { - let session_id = session.session_id.clone(); - let title = session.title.clone().unwrap_or_default(); - let updated_at = session.updated_at.unwrap_or_else(|| Utc::now()); - let created_at = session.created_at.unwrap_or(updated_at); - let folder_paths = session.work_dirs.clone().unwrap_or_default(); - let agent_id = if agent_id.as_ref() == ZED_AGENT_ID.as_ref() { - None - } else { - Some(agent_id) - }; +impl From<&ThreadMetadata> for acp_thread::AgentSessionInfo { + fn from(meta: &ThreadMetadata) -> Self { Self { - session_id, - agent_id, - title, - updated_at, - created_at: Some(created_at), - folder_paths, + session_id: meta.session_id.clone(), + work_dirs: Some(meta.folder_paths.clone()), + title: Some(meta.title.clone()), + updated_at: Some(meta.updated_at), + created_at: meta.created_at, + meta: None, } } +} - pub fn from_thread(thread: &Entity, cx: &App) -> Self { +impl ThreadMetadata { + pub fn from_thread( + is_archived: bool, + thread: &Entity, + cx: &App, + ) -> Self { let thread_ref = thread.read(cx); let session_id = thread_ref.session_id().clone(); let title = thread_ref @@ -143,12 +133,6 @@ impl ThreadMetadata { let agent_id = thread_ref.connection().agent_id(); - let agent_id = if agent_id.as_ref() == ZED_AGENT_ID.as_ref() { - None - } else { - Some(agent_id) - }; - let folder_paths = { let project = thread_ref.project().read(cx); let paths: Vec> = project @@ -165,23 +149,40 @@ impl ThreadMetadata { created_at: Some(updated_at), // handled by db `ON CONFLICT` updated_at, folder_paths, + archived: is_archived, } } } -/// The store holds all metadata needed to show threads in the sidebar. -/// Effectively, all threads stored in here are "non-archived". +/// The store holds all metadata needed to show threads in the sidebar/the archive. /// /// Automatically listens to AcpThread events and updates metadata if it has changed. -pub struct SidebarThreadMetadataStore { +pub struct ThreadMetadataStore { db: ThreadMetadataDb, - threads: Vec, + threads: HashMap, threads_by_paths: HashMap>, reload_task: Option>>, session_subscriptions: HashMap, + pending_thread_ops_tx: smol::channel::Sender, + _db_operations_task: Task<()>, +} + +#[derive(Debug, PartialEq)] +enum DbOperation { + Insert(ThreadMetadata), + Delete(acp::SessionId), } -impl SidebarThreadMetadataStore { +impl DbOperation { + fn id(&self) -> &acp::SessionId { + match self { + DbOperation::Insert(thread) => &thread.session_id, + DbOperation::Delete(session_id) => session_id, + } + } +} + +impl ThreadMetadataStore { #[cfg(not(any(test, feature = "test-support")))] pub fn init_global(cx: &mut App) { if cx.has_global::() { @@ -216,14 +217,27 @@ impl SidebarThreadMetadataStore { self.threads.is_empty() } + /// Returns all thread IDs. + pub fn entry_ids(&self) -> impl Iterator + '_ { + self.threads.keys().cloned() + } + + /// Returns the metadata for a specific thread, if it exists. + pub fn entry(&self, session_id: &acp::SessionId) -> Option<&ThreadMetadata> { + self.threads.get(session_id) + } + + /// Returns all threads. pub fn entries(&self) -> impl Iterator + '_ { - self.threads.iter().cloned() + self.threads.values().cloned() } - pub fn entry_ids(&self) -> impl Iterator + '_ { - self.threads.iter().map(|thread| thread.session_id.clone()) + /// Returns all archived threads. + pub fn archived_entries(&self) -> impl Iterator + '_ { + self.entries().filter(|t| t.archived) } + /// Returns all threads for the given path list, excluding archived threads. pub fn entries_for_path( &self, path_list: &PathList, @@ -232,6 +246,7 @@ impl SidebarThreadMetadataStore { .get(path_list) .into_iter() .flatten() + .filter(|s| !s.archived) .cloned() } @@ -257,7 +272,7 @@ impl SidebarThreadMetadataStore { .entry(row.folder_paths.clone()) .or_default() .push(row.clone()); - this.threads.push(row); + this.threads.insert(row.session_id.clone(), row); } cx.notify(); @@ -269,36 +284,66 @@ impl SidebarThreadMetadataStore { reload_task } - pub fn save(&mut self, metadata: ThreadMetadata, cx: &mut Context) -> Task> { + pub fn save_all(&mut self, metadata: Vec, cx: &mut Context) { if !cx.has_flag::() { - return Task::ready(Ok(())); + return; } - let db = self.db.clone(); - cx.spawn(async move |this, cx| { - db.save(metadata).await?; - let reload_task = this.update(cx, |this, cx| this.reload(cx))?; - reload_task.await; - Ok(()) - }) + for metadata in metadata { + self.pending_thread_ops_tx + .try_send(DbOperation::Insert(metadata)) + .log_err(); + } } - pub fn delete( + pub fn save(&mut self, metadata: ThreadMetadata, cx: &mut Context) { + if !cx.has_flag::() { + return; + } + + self.pending_thread_ops_tx + .try_send(DbOperation::Insert(metadata)) + .log_err(); + } + + pub fn archive(&mut self, session_id: &acp::SessionId, cx: &mut Context) { + self.update_archived(session_id, true, cx); + } + + pub fn unarchive(&mut self, session_id: &acp::SessionId, cx: &mut Context) { + self.update_archived(session_id, false, cx); + } + + fn update_archived( &mut self, - session_id: acp::SessionId, + session_id: &acp::SessionId, + archived: bool, cx: &mut Context, - ) -> Task> { + ) { if !cx.has_flag::() { - return Task::ready(Ok(())); + return; } - let db = self.db.clone(); - cx.spawn(async move |this, cx| { - db.delete(session_id).await?; - let reload_task = this.update(cx, |this, cx| this.reload(cx))?; - reload_task.await; - Ok(()) - }) + if let Some(thread) = self.threads.get(session_id) { + self.save( + ThreadMetadata { + archived, + ..thread.clone() + }, + cx, + ); + cx.notify(); + } + } + + pub fn delete(&mut self, session_id: acp::SessionId, cx: &mut Context) { + if !cx.has_flag::() { + return; + } + + self.pending_thread_ops_tx + .try_send(DbOperation::Delete(session_id)) + .log_err(); } fn new(db: ThreadMetadataDb, cx: &mut Context) -> Self { @@ -316,8 +361,14 @@ impl SidebarThreadMetadataStore { let weak_store = weak_store.clone(); move |thread, cx| { weak_store - .update(cx, |store, _cx| { - store.session_subscriptions.remove(thread.session_id()); + .update(cx, |store, cx| { + let session_id = thread.session_id().clone(); + store.session_subscriptions.remove(&session_id); + if thread.entries().is_empty() { + // Empty threads can be unloaded without ever being + // durably persisted by the underlying agent. + store.delete(session_id, cx); + } }) .ok(); } @@ -334,17 +385,56 @@ impl SidebarThreadMetadataStore { }) .detach(); + let (tx, rx) = smol::channel::unbounded(); + let _db_operations_task = cx.spawn({ + let db = db.clone(); + async move |this, cx| { + while let Ok(first_update) = rx.recv().await { + let mut updates = vec![first_update]; + while let Ok(update) = rx.try_recv() { + updates.push(update); + } + let updates = Self::dedup_db_operations(updates); + for operation in updates { + match operation { + DbOperation::Insert(metadata) => { + db.save(metadata).await.log_err(); + } + DbOperation::Delete(session_id) => { + db.delete(session_id).await.log_err(); + } + } + } + + this.update(cx, |this, cx| this.reload(cx)).ok(); + } + } + }); + let mut this = Self { db, - threads: Vec::new(), + threads: HashMap::default(), threads_by_paths: HashMap::default(), reload_task: None, session_subscriptions: HashMap::default(), + pending_thread_ops_tx: tx, + _db_operations_task, }; let _ = this.reload(cx); this } + fn dedup_db_operations(operations: Vec) -> Vec { + let mut ops = HashMap::default(); + for operation in operations.into_iter().rev() { + if ops.contains_key(operation.id()) { + continue; + } + ops.insert(operation.id().clone(), operation); + } + ops.into_values().collect() + } + fn handle_thread_update( &mut self, thread: Entity, @@ -368,46 +458,56 @@ impl SidebarThreadMetadataStore { | acp_thread::AcpThreadEvent::Error | acp_thread::AcpThreadEvent::LoadError(_) | acp_thread::AcpThreadEvent::Refusal => { - let metadata = ThreadMetadata::from_thread(&thread, cx); - self.save(metadata, cx).detach_and_log_err(cx); + let is_archived = self + .threads + .get(thread.read(cx).session_id()) + .map(|t| t.archived) + .unwrap_or(false); + let metadata = ThreadMetadata::from_thread(is_archived, &thread, cx); + self.save(metadata, cx); } _ => {} } } } -impl Global for SidebarThreadMetadataStore {} +impl Global for ThreadMetadataStore {} struct ThreadMetadataDb(ThreadSafeConnection); impl Domain for ThreadMetadataDb { const NAME: &str = stringify!(ThreadMetadataDb); - const MIGRATIONS: &[&str] = &[sql!( - CREATE TABLE IF NOT EXISTS sidebar_threads( - session_id TEXT PRIMARY KEY, - agent_id TEXT, - title TEXT NOT NULL, - updated_at TEXT NOT NULL, - created_at TEXT, - folder_paths TEXT, - folder_paths_order TEXT - ) STRICT; - )]; + const MIGRATIONS: &[&str] = &[ + sql!( + CREATE TABLE IF NOT EXISTS sidebar_threads( + session_id TEXT PRIMARY KEY, + agent_id TEXT, + title TEXT NOT NULL, + updated_at TEXT NOT NULL, + created_at TEXT, + folder_paths TEXT, + folder_paths_order TEXT + ) STRICT; + ), + sql!(ALTER TABLE sidebar_threads ADD COLUMN archived INTEGER DEFAULT 0), + ]; } db::static_connection!(ThreadMetadataDb, []); impl ThreadMetadataDb { - pub fn is_empty(&self) -> anyhow::Result { - self.select::("SELECT COUNT(*) FROM sidebar_threads")?() - .map(|counts| counts.into_iter().next().unwrap_or_default() == 0) + pub fn list_ids(&self) -> anyhow::Result>> { + self.select::>( + "SELECT session_id FROM sidebar_threads \ + ORDER BY updated_at DESC", + )?() } /// List all sidebar thread metadata, ordered by updated_at descending. pub fn list(&self) -> anyhow::Result> { self.select::( - "SELECT session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order \ + "SELECT session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order, archived \ FROM sidebar_threads \ ORDER BY updated_at DESC" )?() @@ -416,7 +516,11 @@ impl ThreadMetadataDb { /// Upsert metadata for a thread. pub async fn save(&self, row: ThreadMetadata) -> anyhow::Result<()> { let id = row.session_id.0.clone(); - let agent_id = row.agent_id.as_ref().map(|id| id.0.to_string()); + let agent_id = if row.agent_id.as_ref() == ZED_AGENT_ID.as_ref() { + None + } else { + Some(row.agent_id.to_string()) + }; let title = row.title.to_string(); let updated_at = row.updated_at.to_rfc3339(); let created_at = row.created_at.map(|dt| dt.to_rfc3339()); @@ -426,16 +530,18 @@ impl ThreadMetadataDb { } else { (Some(serialized.paths), Some(serialized.order)) }; + let archived = row.archived; self.write(move |conn| { - let sql = "INSERT INTO sidebar_threads(session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \ + let sql = "INSERT INTO sidebar_threads(session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order, archived) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) \ ON CONFLICT(session_id) DO UPDATE SET \ agent_id = excluded.agent_id, \ title = excluded.title, \ updated_at = excluded.updated_at, \ folder_paths = excluded.folder_paths, \ - folder_paths_order = excluded.folder_paths_order"; + folder_paths_order = excluded.folder_paths_order, \ + archived = excluded.archived"; let mut stmt = Statement::prepare(conn, sql)?; let mut i = stmt.bind(&id, 1)?; i = stmt.bind(&agent_id, i)?; @@ -443,7 +549,8 @@ impl ThreadMetadataDb { i = stmt.bind(&updated_at, i)?; i = stmt.bind(&created_at, i)?; i = stmt.bind(&folder_paths, i)?; - stmt.bind(&folder_paths_order, i)?; + i = stmt.bind(&folder_paths_order, i)?; + stmt.bind(&archived, i)?; stmt.exec() }) .await @@ -472,6 +579,11 @@ impl Column for ThreadMetadata { let (folder_paths_str, next): (Option, i32) = Column::column(statement, next)?; let (folder_paths_order_str, next): (Option, i32) = Column::column(statement, next)?; + let (archived, next): (bool, i32) = Column::column(statement, next)?; + + let agent_id = agent_id + .map(|id| AgentId::new(id)) + .unwrap_or(ZED_AGENT_ID.clone()); let updated_at = DateTime::parse_from_rfc3339(&updated_at_str)?.with_timezone(&Utc); let created_at = created_at_str @@ -492,11 +604,12 @@ impl Column for ThreadMetadata { Ok(( ThreadMetadata { session_id: acp::SessionId::new(id), - agent_id: agent_id.map(|id| AgentId::new(id)), + agent_id, title: title.into(), updated_at, created_at, folder_paths, + archived, }, next, )) @@ -545,8 +658,9 @@ mod tests { folder_paths: PathList, ) -> ThreadMetadata { ThreadMetadata { + archived: false, session_id: acp::SessionId::new(session_id), - agent_id: None, + agent_id: agent::ZED_AGENT_ID.clone(), title: title.to_string().into(), updated_at, created_at: Some(updated_at), @@ -589,20 +703,22 @@ mod tests { let settings_store = settings::SettingsStore::test(cx); cx.set_global(settings_store); cx.update_flags(true, vec!["agent-v2".to_string()]); - SidebarThreadMetadataStore::init_global(cx); + ThreadMetadataStore::init_global(cx); }); cx.run_until_parked(); cx.update(|cx| { - let store = SidebarThreadMetadataStore::global(cx); + let store = ThreadMetadataStore::global(cx); let store = store.read(cx); let entry_ids = store .entry_ids() .map(|session_id| session_id.0.to_string()) .collect::>(); - assert_eq!(entry_ids, vec!["session-1", "session-2"]); + assert_eq!(entry_ids.len(), 2); + assert!(entry_ids.contains(&"session-1".to_string())); + assert!(entry_ids.contains(&"session-2".to_string())); let first_path_entries = store .entries_for_path(&first_paths) @@ -624,7 +740,7 @@ mod tests { let settings_store = settings::SettingsStore::test(cx); cx.set_global(settings_store); cx.update_flags(true, vec!["agent-v2".to_string()]); - SidebarThreadMetadataStore::init_global(cx); + ThreadMetadataStore::init_global(cx); }); let first_paths = PathList::new(&[Path::new("/project-a")]); @@ -647,17 +763,17 @@ mod tests { ); cx.update(|cx| { - let store = SidebarThreadMetadataStore::global(cx); + let store = ThreadMetadataStore::global(cx); store.update(cx, |store, cx| { - store.save(initial_metadata, cx).detach(); - store.save(second_metadata, cx).detach(); + store.save(initial_metadata, cx); + store.save(second_metadata, cx); }); }); cx.run_until_parked(); cx.update(|cx| { - let store = SidebarThreadMetadataStore::global(cx); + let store = ThreadMetadataStore::global(cx); let store = store.read(cx); let first_path_entries = store @@ -681,23 +797,25 @@ mod tests { ); cx.update(|cx| { - let store = SidebarThreadMetadataStore::global(cx); + let store = ThreadMetadataStore::global(cx); store.update(cx, |store, cx| { - store.save(moved_metadata, cx).detach(); + store.save(moved_metadata, cx); }); }); cx.run_until_parked(); cx.update(|cx| { - let store = SidebarThreadMetadataStore::global(cx); + let store = ThreadMetadataStore::global(cx); let store = store.read(cx); let entry_ids = store .entry_ids() .map(|session_id| session_id.0.to_string()) .collect::>(); - assert_eq!(entry_ids, vec!["session-1", "session-2"]); + assert_eq!(entry_ids.len(), 2); + assert!(entry_ids.contains(&"session-1".to_string())); + assert!(entry_ids.contains(&"session-2".to_string())); let first_path_entries = store .entries_for_path(&first_paths) @@ -709,20 +827,22 @@ mod tests { .entries_for_path(&second_paths) .map(|entry| entry.session_id.0.to_string()) .collect::>(); - assert_eq!(second_path_entries, vec!["session-1", "session-2"]); + assert_eq!(second_path_entries.len(), 2); + assert!(second_path_entries.contains(&"session-1".to_string())); + assert!(second_path_entries.contains(&"session-2".to_string())); }); cx.update(|cx| { - let store = SidebarThreadMetadataStore::global(cx); + let store = ThreadMetadataStore::global(cx); store.update(cx, |store, cx| { - store.delete(acp::SessionId::new("session-2"), cx).detach(); + store.delete(acp::SessionId::new("session-2"), cx); }); }); cx.run_until_parked(); cx.update(|cx| { - let store = SidebarThreadMetadataStore::global(cx); + let store = ThreadMetadataStore::global(cx); let store = store.read(cx); let entry_ids = store @@ -740,61 +860,72 @@ mod tests { } #[gpui::test] - async fn test_migrate_thread_metadata(cx: &mut TestAppContext) { + async fn test_migrate_thread_metadata_migrates_only_missing_threads(cx: &mut TestAppContext) { cx.update(|cx| { ThreadStore::init_global(cx); - SidebarThreadMetadataStore::init_global(cx); + ThreadMetadataStore::init_global(cx); }); - // Verify the cache is empty before migration - let list = cx.update(|cx| { - let store = SidebarThreadMetadataStore::global(cx); - store.read(cx).entries().collect::>() - }); - assert_eq!(list.len(), 0); - let project_a_paths = PathList::new(&[Path::new("/project-a")]); let project_b_paths = PathList::new(&[Path::new("/project-b")]); let now = Utc::now(); - for index in 0..12 { - let updated_at = now + chrono::Duration::seconds(index as i64); - let session_id = format!("project-a-session-{index}"); - let title = format!("Project A Thread {index}"); + let existing_metadata = ThreadMetadata { + session_id: acp::SessionId::new("a-session-0"), + agent_id: agent::ZED_AGENT_ID.clone(), + title: "Existing Metadata".into(), + updated_at: now - chrono::Duration::seconds(10), + created_at: Some(now - chrono::Duration::seconds(10)), + folder_paths: project_a_paths.clone(), + archived: false, + }; - let save_task = cx.update(|cx| { - let thread_store = ThreadStore::global(cx); - let session_id = session_id.clone(); - let title = title.clone(); - let project_a_paths = project_a_paths.clone(); - thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(session_id), - make_db_thread(&title, updated_at), - project_a_paths, - cx, - ) - }) + cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + store.update(cx, |store, cx| { + store.save(existing_metadata, cx); }); - save_task.await.unwrap(); - cx.run_until_parked(); - } - - for index in 0..3 { - let updated_at = now + chrono::Duration::seconds(100 + index as i64); - let session_id = format!("project-b-session-{index}"); - let title = format!("Project B Thread {index}"); + }); + cx.run_until_parked(); + let threads_to_save = vec![ + ( + "a-session-0", + "Thread A0 From Native Store", + project_a_paths.clone(), + now, + ), + ( + "a-session-1", + "Thread A1", + project_a_paths.clone(), + now + chrono::Duration::seconds(1), + ), + ( + "b-session-0", + "Thread B0", + project_b_paths.clone(), + now + chrono::Duration::seconds(2), + ), + ( + "projectless", + "Projectless", + PathList::default(), + now + chrono::Duration::seconds(3), + ), + ]; + + for (session_id, title, paths, updated_at) in &threads_to_save { let save_task = cx.update(|cx| { let thread_store = ThreadStore::global(cx); - let session_id = session_id.clone(); - let title = title.clone(); - let project_b_paths = project_b_paths.clone(); + let session_id = session_id.to_string(); + let title = title.to_string(); + let paths = paths.clone(); thread_store.update(cx, |store, cx| { store.save_thread( acp::SessionId::new(session_id), - make_db_thread(&title, updated_at), - project_b_paths, + make_db_thread(&title, *updated_at), + paths, cx, ) }) @@ -803,130 +934,87 @@ mod tests { cx.run_until_parked(); } - let save_projectless = cx.update(|cx| { - let thread_store = ThreadStore::global(cx); - thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new("projectless-session"), - make_db_thread("Projectless Thread", now + chrono::Duration::seconds(200)), - PathList::default(), - cx, - ) - }) - }); - save_projectless.await.unwrap(); - cx.run_until_parked(); - - // Run migration - cx.update(|cx| { - migrate_thread_metadata(cx); - }); - + cx.update(|cx| migrate_thread_metadata(cx)); cx.run_until_parked(); - // Verify the metadata was migrated, limited to 10 per project, and - // projectless threads were skipped. let list = cx.update(|cx| { - let store = SidebarThreadMetadataStore::global(cx); + let store = ThreadMetadataStore::global(cx); store.read(cx).entries().collect::>() }); - assert_eq!(list.len(), 13); + assert_eq!(list.len(), 3); assert!( list.iter() - .all(|metadata| !metadata.folder_paths.is_empty()) - ); - assert!( - list.iter() - .all(|metadata| metadata.session_id.0.as_ref() != "projectless-session") + .all(|metadata| metadata.agent_id.as_ref() == agent::ZED_AGENT_ID.as_ref()) ); - let project_a_entries = list + let existing_metadata = list .iter() - .filter(|metadata| metadata.folder_paths == project_a_paths) + .find(|metadata| metadata.session_id.0.as_ref() == "a-session-0") + .unwrap(); + assert_eq!(existing_metadata.title.as_ref(), "Existing Metadata"); + assert!(!existing_metadata.archived); + + let migrated_session_ids = list + .iter() + .map(|metadata| metadata.session_id.0.as_ref()) .collect::>(); - assert_eq!(project_a_entries.len(), 10); - assert_eq!( - project_a_entries - .iter() - .map(|metadata| metadata.session_id.0.as_ref()) - .collect::>(), - vec![ - "project-a-session-11", - "project-a-session-10", - "project-a-session-9", - "project-a-session-8", - "project-a-session-7", - "project-a-session-6", - "project-a-session-5", - "project-a-session-4", - "project-a-session-3", - "project-a-session-2", - ] - ); - assert!( - project_a_entries - .iter() - .all(|metadata| metadata.agent_id.is_none()) - ); + assert!(migrated_session_ids.contains(&"a-session-1")); + assert!(migrated_session_ids.contains(&"b-session-0")); + assert!(!migrated_session_ids.contains(&"projectless")); - let project_b_entries = list + let migrated_entries = list .iter() - .filter(|metadata| metadata.folder_paths == project_b_paths) + .filter(|metadata| metadata.session_id.0.as_ref() != "a-session-0") .collect::>(); - assert_eq!(project_b_entries.len(), 3); - assert_eq!( - project_b_entries - .iter() - .map(|metadata| metadata.session_id.0.as_ref()) - .collect::>(), - vec![ - "project-b-session-2", - "project-b-session-1", - "project-b-session-0", - ] - ); assert!( - project_b_entries + migrated_entries .iter() - .all(|metadata| metadata.agent_id.is_none()) + .all(|metadata| !metadata.folder_paths.is_empty()) ); + assert!(migrated_entries.iter().all(|metadata| metadata.archived)); } #[gpui::test] - async fn test_migrate_thread_metadata_skips_when_data_exists(cx: &mut TestAppContext) { + async fn test_migrate_thread_metadata_noops_when_all_threads_already_exist( + cx: &mut TestAppContext, + ) { cx.update(|cx| { ThreadStore::init_global(cx); - SidebarThreadMetadataStore::init_global(cx); + ThreadMetadataStore::init_global(cx); }); - // Pre-populate the metadata store with existing data + let project_paths = PathList::new(&[Path::new("/project-a")]); + let existing_updated_at = Utc::now(); + let existing_metadata = ThreadMetadata { session_id: acp::SessionId::new("existing-session"), - agent_id: None, - title: "Existing Thread".into(), - updated_at: Utc::now(), - created_at: Some(Utc::now()), - folder_paths: PathList::default(), + agent_id: agent::ZED_AGENT_ID.clone(), + title: "Existing Metadata".into(), + updated_at: existing_updated_at, + created_at: Some(existing_updated_at), + folder_paths: project_paths.clone(), + archived: false, }; cx.update(|cx| { - let store = SidebarThreadMetadataStore::global(cx); + let store = ThreadMetadataStore::global(cx); store.update(cx, |store, cx| { - store.save(existing_metadata, cx).detach(); + store.save(existing_metadata, cx); }); }); - cx.run_until_parked(); - // Add an entry to native thread store that should NOT be migrated let save_task = cx.update(|cx| { let thread_store = ThreadStore::global(cx); thread_store.update(cx, |store, cx| { store.save_thread( - acp::SessionId::new("native-session"), - make_db_thread("Native Thread", Utc::now()), - PathList::default(), + acp::SessionId::new("existing-session"), + make_db_thread( + "Updated Native Thread Title", + existing_updated_at + chrono::Duration::seconds(1), + ), + project_paths.clone(), cx, ) }) @@ -934,21 +1022,124 @@ mod tests { save_task.await.unwrap(); cx.run_until_parked(); - // Run migration - should skip because metadata store is not empty - cx.update(|cx| { - migrate_thread_metadata(cx); - }); - + cx.update(|cx| migrate_thread_metadata(cx)); cx.run_until_parked(); - // Verify only the existing metadata is present (migration was skipped) let list = cx.update(|cx| { - let store = SidebarThreadMetadataStore::global(cx); + let store = ThreadMetadataStore::global(cx); store.read(cx).entries().collect::>() }); + assert_eq!(list.len(), 1); assert_eq!(list[0].session_id.0.as_ref(), "existing-session"); } + #[gpui::test] + async fn test_empty_thread_metadata_deleted_when_thread_released(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = settings::SettingsStore::test(cx); + cx.set_global(settings_store); + cx.update_flags(true, vec!["agent-v2".to_string()]); + ThreadStore::init_global(cx); + ThreadMetadataStore::init_global(cx); + }); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, None::<&Path>, cx).await; + let connection = Rc::new(StubAgentConnection::new()); + + let thread = cx + .update(|cx| { + connection + .clone() + .new_session(project.clone(), PathList::default(), cx) + }) + .await + .unwrap(); + let session_id = cx.read(|cx| thread.read(cx).session_id().clone()); + + cx.update(|cx| { + thread.update(cx, |thread, cx| { + thread.set_title("Draft Thread".into(), cx).detach(); + }); + }); + cx.run_until_parked(); + + let metadata_ids = cx.update(|cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entry_ids() + .collect::>() + }); + assert_eq!(metadata_ids, vec![session_id]); + + drop(thread); + cx.update(|_| {}); + cx.run_until_parked(); + cx.run_until_parked(); + + let metadata_ids = cx.update(|cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entry_ids() + .collect::>() + }); + assert!( + metadata_ids.is_empty(), + "expected empty draft thread metadata to be deleted on release" + ); + } + + #[gpui::test] + async fn test_nonempty_thread_metadata_preserved_when_thread_released(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = settings::SettingsStore::test(cx); + cx.set_global(settings_store); + cx.update_flags(true, vec!["agent-v2".to_string()]); + ThreadStore::init_global(cx); + ThreadMetadataStore::init_global(cx); + }); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, None::<&Path>, cx).await; + let connection = Rc::new(StubAgentConnection::new()); + + let thread = cx + .update(|cx| { + connection + .clone() + .new_session(project.clone(), PathList::default(), cx) + }) + .await + .unwrap(); + let session_id = cx.read(|cx| thread.read(cx).session_id().clone()); + + cx.update(|cx| { + thread.update(cx, |thread, cx| { + thread.push_user_content_block(None, "Hello".into(), cx); + }); + }); + cx.run_until_parked(); + + let metadata_ids = cx.update(|cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entry_ids() + .collect::>() + }); + assert_eq!(metadata_ids, vec![session_id.clone()]); + + drop(thread); + cx.update(|_| {}); + cx.run_until_parked(); + + let metadata_ids = cx.update(|cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entry_ids() + .collect::>() + }); + assert_eq!(metadata_ids, vec![session_id]); + } #[gpui::test] async fn test_subagent_threads_excluded_from_sidebar_metadata(cx: &mut TestAppContext) { @@ -957,7 +1148,7 @@ mod tests { cx.set_global(settings_store); cx.update_flags(true, vec!["agent-v2".to_string()]); ThreadStore::init_global(cx); - SidebarThreadMetadataStore::init_global(cx); + ThreadMetadataStore::init_global(cx); }); let fs = FakeFs::new(cx.executor()); @@ -1015,7 +1206,7 @@ mod tests { // List all metadata from the store cache. let list = cx.update(|cx| { - let store = SidebarThreadMetadataStore::global(cx); + let store = ThreadMetadataStore::global(cx); store.read(cx).entries().collect::>() }); @@ -1031,4 +1222,363 @@ mod tests { assert_eq!(list[0].session_id, regular_session_id); assert_eq!(list[0].title.as_ref(), "Regular Thread"); } + + #[test] + fn test_dedup_db_operations_keeps_latest_operation_for_session() { + let now = Utc::now(); + + let operations = vec![ + DbOperation::Insert(make_metadata( + "session-1", + "First Thread", + now, + PathList::default(), + )), + DbOperation::Delete(acp::SessionId::new("session-1")), + ]; + + let deduped = ThreadMetadataStore::dedup_db_operations(operations); + + assert_eq!(deduped.len(), 1); + assert_eq!( + deduped[0], + DbOperation::Delete(acp::SessionId::new("session-1")) + ); + } + + #[test] + fn test_dedup_db_operations_keeps_latest_insert_for_same_session() { + let now = Utc::now(); + let later = now + chrono::Duration::seconds(1); + + let old_metadata = make_metadata("session-1", "Old Title", now, PathList::default()); + let new_metadata = make_metadata("session-1", "New Title", later, PathList::default()); + + let deduped = ThreadMetadataStore::dedup_db_operations(vec![ + DbOperation::Insert(old_metadata), + DbOperation::Insert(new_metadata.clone()), + ]); + + assert_eq!(deduped.len(), 1); + assert_eq!(deduped[0], DbOperation::Insert(new_metadata)); + } + + #[test] + fn test_dedup_db_operations_preserves_distinct_sessions() { + let now = Utc::now(); + + let metadata1 = make_metadata("session-1", "First Thread", now, PathList::default()); + let metadata2 = make_metadata("session-2", "Second Thread", now, PathList::default()); + let deduped = ThreadMetadataStore::dedup_db_operations(vec![ + DbOperation::Insert(metadata1.clone()), + DbOperation::Insert(metadata2.clone()), + ]); + + assert_eq!(deduped.len(), 2); + assert!(deduped.contains(&DbOperation::Insert(metadata1))); + assert!(deduped.contains(&DbOperation::Insert(metadata2))); + } + + #[gpui::test] + async fn test_archive_and_unarchive_thread(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = settings::SettingsStore::test(cx); + cx.set_global(settings_store); + cx.update_flags(true, vec!["agent-v2".to_string()]); + ThreadMetadataStore::init_global(cx); + }); + + let paths = PathList::new(&[Path::new("/project-a")]); + let now = Utc::now(); + let metadata = make_metadata("session-1", "Thread 1", now, paths.clone()); + + cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + store.update(cx, |store, cx| { + store.save(metadata, cx); + }); + }); + + cx.run_until_parked(); + + cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + let store = store.read(cx); + + let path_entries = store + .entries_for_path(&paths) + .map(|e| e.session_id.0.to_string()) + .collect::>(); + assert_eq!(path_entries, vec!["session-1"]); + + let archived = store + .archived_entries() + .map(|e| e.session_id.0.to_string()) + .collect::>(); + assert!(archived.is_empty()); + }); + + cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + store.update(cx, |store, cx| { + store.archive(&acp::SessionId::new("session-1"), cx); + }); + }); + + cx.run_until_parked(); + + cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + let store = store.read(cx); + + let path_entries = store + .entries_for_path(&paths) + .map(|e| e.session_id.0.to_string()) + .collect::>(); + assert!(path_entries.is_empty()); + + let archived = store.archived_entries().collect::>(); + assert_eq!(archived.len(), 1); + assert_eq!(archived[0].session_id.0.as_ref(), "session-1"); + assert!(archived[0].archived); + }); + + cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + store.update(cx, |store, cx| { + store.unarchive(&acp::SessionId::new("session-1"), cx); + }); + }); + + cx.run_until_parked(); + + cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + let store = store.read(cx); + + let path_entries = store + .entries_for_path(&paths) + .map(|e| e.session_id.0.to_string()) + .collect::>(); + assert_eq!(path_entries, vec!["session-1"]); + + let archived = store + .archived_entries() + .map(|e| e.session_id.0.to_string()) + .collect::>(); + assert!(archived.is_empty()); + }); + } + + #[gpui::test] + async fn test_entries_for_path_excludes_archived(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = settings::SettingsStore::test(cx); + cx.set_global(settings_store); + cx.update_flags(true, vec!["agent-v2".to_string()]); + ThreadMetadataStore::init_global(cx); + }); + + let paths = PathList::new(&[Path::new("/project-a")]); + let now = Utc::now(); + + let metadata1 = make_metadata("session-1", "Active Thread", now, paths.clone()); + let metadata2 = make_metadata( + "session-2", + "Archived Thread", + now - chrono::Duration::seconds(1), + paths.clone(), + ); + + cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + store.update(cx, |store, cx| { + store.save(metadata1, cx); + store.save(metadata2, cx); + }); + }); + + cx.run_until_parked(); + + cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + store.update(cx, |store, cx| { + store.archive(&acp::SessionId::new("session-2"), cx); + }); + }); + + cx.run_until_parked(); + + cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + let store = store.read(cx); + + let path_entries = store + .entries_for_path(&paths) + .map(|e| e.session_id.0.to_string()) + .collect::>(); + assert_eq!(path_entries, vec!["session-1"]); + + let all_entries = store + .entries() + .map(|e| e.session_id.0.to_string()) + .collect::>(); + assert_eq!(all_entries.len(), 2); + assert!(all_entries.contains(&"session-1".to_string())); + assert!(all_entries.contains(&"session-2".to_string())); + + let archived = store + .archived_entries() + .map(|e| e.session_id.0.to_string()) + .collect::>(); + assert_eq!(archived, vec!["session-2"]); + }); + } + + #[gpui::test] + async fn test_save_all_persists_multiple_threads(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = settings::SettingsStore::test(cx); + cx.set_global(settings_store); + cx.update_flags(true, vec!["agent-v2".to_string()]); + ThreadMetadataStore::init_global(cx); + }); + + let paths = PathList::new(&[Path::new("/project-a")]); + let now = Utc::now(); + + let m1 = make_metadata("session-1", "Thread One", now, paths.clone()); + let m2 = make_metadata( + "session-2", + "Thread Two", + now - chrono::Duration::seconds(1), + paths.clone(), + ); + let m3 = make_metadata( + "session-3", + "Thread Three", + now - chrono::Duration::seconds(2), + paths, + ); + + cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + store.update(cx, |store, cx| { + store.save_all(vec![m1, m2, m3], cx); + }); + }); + + cx.run_until_parked(); + + cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + let store = store.read(cx); + + let all_entries = store + .entries() + .map(|e| e.session_id.0.to_string()) + .collect::>(); + assert_eq!(all_entries.len(), 3); + assert!(all_entries.contains(&"session-1".to_string())); + assert!(all_entries.contains(&"session-2".to_string())); + assert!(all_entries.contains(&"session-3".to_string())); + + let entry_ids = store.entry_ids().collect::>(); + assert_eq!(entry_ids.len(), 3); + }); + } + + #[gpui::test] + async fn test_archived_flag_persists_across_reload(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = settings::SettingsStore::test(cx); + cx.set_global(settings_store); + cx.update_flags(true, vec!["agent-v2".to_string()]); + ThreadMetadataStore::init_global(cx); + }); + + let paths = PathList::new(&[Path::new("/project-a")]); + let now = Utc::now(); + let metadata = make_metadata("session-1", "Thread 1", now, paths.clone()); + + cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + store.update(cx, |store, cx| { + store.save(metadata, cx); + }); + }); + + cx.run_until_parked(); + + cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + store.update(cx, |store, cx| { + store.archive(&acp::SessionId::new("session-1"), cx); + }); + }); + + cx.run_until_parked(); + + cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + store.update(cx, |store, cx| { + let _ = store.reload(cx); + }); + }); + + cx.run_until_parked(); + + cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + let store = store.read(cx); + + let thread = store + .entries() + .find(|e| e.session_id.0.as_ref() == "session-1") + .expect("thread should exist after reload"); + assert!(thread.archived); + + let path_entries = store + .entries_for_path(&paths) + .map(|e| e.session_id.0.to_string()) + .collect::>(); + assert!(path_entries.is_empty()); + + let archived = store + .archived_entries() + .map(|e| e.session_id.0.to_string()) + .collect::>(); + assert_eq!(archived, vec!["session-1"]); + }); + } + + #[gpui::test] + async fn test_archive_nonexistent_thread_is_noop(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = settings::SettingsStore::test(cx); + cx.set_global(settings_store); + cx.update_flags(true, vec!["agent-v2".to_string()]); + ThreadMetadataStore::init_global(cx); + }); + + cx.run_until_parked(); + + cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + store.update(cx, |store, cx| { + store.archive(&acp::SessionId::new("nonexistent"), cx); + }); + }); + + cx.run_until_parked(); + + cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + let store = store.read(cx); + + assert!(store.is_empty()); + assert_eq!(store.entries().count(), 0); + assert_eq!(store.archived_entries().count(), 0); + }); + } } diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs index ef4e3ab5393b1045b4de15b348c3e01e07c366bc..f96efe36ba4541304b855f7f67d8f9e5cd482c2f 100644 --- a/crates/agent_ui/src/threads_archive_view.rs +++ b/crates/agent_ui/src/threads_archive_view.rs @@ -1,29 +1,30 @@ -use std::sync::Arc; +use crate::agent_connection_store::AgentConnectionStore; +use crate::thread_import::ThreadImportModal; +use crate::thread_metadata_store::{ThreadMetadata, ThreadMetadataStore}; +use crate::{Agent, RemoveSelectedThread}; -use crate::{ - Agent, RemoveSelectedThread, agent_connection_store::AgentConnectionStore, - thread_history::ThreadHistory, -}; -use acp_thread::AgentSessionInfo; use agent::ThreadStore; use agent_client_protocol as acp; +use agent_settings::AgentSettings; use chrono::{DateTime, Datelike as _, Local, NaiveDate, TimeDelta, Utc}; use editor::Editor; use fs::Fs; use gpui::{ AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, ListState, Render, - SharedString, Subscription, Task, Window, list, prelude::*, px, + SharedString, Subscription, Task, WeakEntity, Window, list, prelude::*, px, }; use itertools::Itertools as _; use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious}; -use project::{AgentId, AgentServerStore}; +use project::{AgentId, AgentRegistryStore, AgentServerStore}; +use settings::Settings as _; use theme::ActiveTheme; +use ui::ThreadItem; use ui::{ - ButtonLike, CommonAnimationExt, ContextMenu, ContextMenuEntry, Divider, HighlightedLabel, - KeyBinding, PopoverMenu, PopoverMenuHandle, TintColor, Tooltip, WithScrollbar, prelude::*, - utils::platform_title_bar_height, + Divider, KeyBinding, Tooltip, WithScrollbar, prelude::*, utils::platform_title_bar_height, }; -use util::ResultExt as _; +use util::ResultExt; +use workspace::{MultiWorkspace, Workspace}; + use zed_actions::agents_sidebar::FocusSidebarFilter; use zed_actions::editor::{MoveDown, MoveUp}; @@ -31,7 +32,7 @@ use zed_actions::editor::{MoveDown, MoveUp}; enum ArchiveListItem { BucketSeparator(TimeBucket), Entry { - session: AgentSessionInfo, + thread: ThreadMetadata, highlight_positions: Vec, }, } @@ -93,40 +94,15 @@ fn fuzzy_match_positions(query: &str, text: &str) -> Option> { } } -fn archive_empty_state_message( - has_history: bool, - is_empty: bool, - has_query: bool, -) -> Option<&'static str> { - if !is_empty { - None - } else if !has_history { - Some("This agent does not support viewing archived threads.") - } else if has_query { - Some("No threads match your search.") - } else { - Some("No archived threads yet.") - } -} - pub enum ThreadsArchiveViewEvent { Close, - Unarchive { - agent: Agent, - session_info: AgentSessionInfo, - }, + Unarchive { thread: ThreadMetadata }, } impl EventEmitter for ThreadsArchiveView {} pub struct ThreadsArchiveView { - agent_connection_store: Entity, - agent_server_store: Entity, - thread_store: Entity, - fs: Arc, - history: Option>, _history_subscription: Subscription, - selected_agent: Agent, focus_handle: FocusHandle, list_state: ListState, items: Vec, @@ -134,17 +110,21 @@ pub struct ThreadsArchiveView { hovered_index: Option, filter_editor: Entity, _subscriptions: Vec, - selected_agent_menu: PopoverMenuHandle, _refresh_history_task: Task<()>, - is_loading: bool, + agent_connection_store: WeakEntity, + agent_server_store: WeakEntity, + agent_registry_store: WeakEntity, + workspace: WeakEntity, + multi_workspace: WeakEntity, } impl ThreadsArchiveView { pub fn new( - agent_connection_store: Entity, - agent_server_store: Entity, - thread_store: Entity, - fs: Arc, + agent_connection_store: WeakEntity, + agent_server_store: WeakEntity, + agent_registry_store: WeakEntity, + workspace: WeakEntity, + multi_workspace: WeakEntity, window: &mut Window, cx: &mut Context, ) -> Self { @@ -176,6 +156,13 @@ impl ThreadsArchiveView { ) .detach(); + let thread_metadata_store_subscription = cx.observe( + &ThreadMetadataStore::global(cx), + |this: &mut Self, _, cx| { + this.update_items(cx); + }, + ); + cx.on_focus_out(&focus_handle, window, |this: &mut Self, _, _window, cx| { this.selection = None; cx.notify(); @@ -183,25 +170,26 @@ impl ThreadsArchiveView { .detach(); let mut this = Self { - agent_connection_store, - agent_server_store, - thread_store, - fs, - history: None, _history_subscription: Subscription::new(|| {}), - selected_agent: Agent::NativeAgent, focus_handle, list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)), items: Vec::new(), selection: None, hovered_index: None, filter_editor, - _subscriptions: vec![filter_editor_subscription], - selected_agent_menu: PopoverMenuHandle::default(), + _subscriptions: vec![ + filter_editor_subscription, + thread_metadata_store_subscription, + ], _refresh_history_task: Task::ready(()), - is_loading: true, + agent_registry_store, + agent_connection_store, + agent_server_store, + workspace, + multi_workspace, }; - this.set_selected_agent(Agent::NativeAgent, window, cx); + + this.update_items(cx); this } @@ -218,59 +206,14 @@ impl ThreadsArchiveView { handle.focus(window, cx); } - fn set_selected_agent(&mut self, agent: Agent, window: &mut Window, cx: &mut Context) { - self.selected_agent = agent.clone(); - self.is_loading = true; - self.reset_history_subscription(); - self.history = None; - self.items.clear(); - self.selection = None; - self.list_state.reset(0); - self.reset_filter_editor_text(window, cx); - - let server = agent.server(self.fs.clone(), self.thread_store.clone()); - let connection = self - .agent_connection_store - .update(cx, |store, cx| store.request_connection(agent, server, cx)); - - let task = connection.read(cx).wait_for_connection(); - self._refresh_history_task = cx.spawn(async move |this, cx| { - if let Some(state) = task.await.log_err() { - this.update(cx, |this, cx| this.set_history(state.history, cx)) - .ok(); - } - }); - - cx.notify(); - } - - fn reset_history_subscription(&mut self) { - self._history_subscription = Subscription::new(|| {}); - } - - fn set_history(&mut self, history: Option>, cx: &mut Context) { - self.reset_history_subscription(); - - if let Some(history) = &history { - self._history_subscription = cx.observe(history, |this, _, cx| { - this.update_items(cx); - }); - history.update(cx, |history, cx| { - history.refresh_full_history(cx); - }); - } - self.history = history; - self.is_loading = false; - self.update_items(cx); - cx.notify(); - } - fn update_items(&mut self, cx: &mut Context) { - let sessions = self - .history - .as_ref() - .map(|h| h.read(cx).sessions().to_vec()) - .unwrap_or_default(); + let sessions = ThreadMetadataStore::global(cx) + .read(cx) + .archived_entries() + .sorted_by_cached_key(|t| t.created_at.unwrap_or(t.updated_at)) + .rev() + .collect::>(); + let query = self.filter_editor.read(cx).text(cx).to_lowercase(); let today = Local::now().naive_local().date(); @@ -279,8 +222,7 @@ impl ThreadsArchiveView { for session in sessions { let highlight_positions = if !query.is_empty() { - let title = session.title.as_ref().map(|t| t.as_ref()).unwrap_or(""); - match fuzzy_match_positions(&query, title) { + match fuzzy_match_positions(&query, &session.title) { Some(positions) => positions, None => continue, } @@ -288,13 +230,15 @@ impl ThreadsArchiveView { Vec::new() }; - let entry_bucket = session - .updated_at - .map(|timestamp| { - let entry_date = timestamp.with_timezone(&Local).naive_local().date(); - TimeBucket::from_dates(today, entry_date) - }) - .unwrap_or(TimeBucket::Older); + let entry_bucket = { + let entry_date = session + .created_at + .unwrap_or(session.updated_at) + .with_timezone(&Local) + .naive_local() + .date(); + TimeBucket::from_dates(today, entry_date) + }; if Some(entry_bucket) != current_bucket { current_bucket = Some(entry_bucket); @@ -302,7 +246,7 @@ impl ThreadsArchiveView { } items.push(ArchiveListItem::Entry { - session, + thread: session, highlight_positions, }); } @@ -322,47 +266,13 @@ impl ThreadsArchiveView { fn unarchive_thread( &mut self, - session_info: AgentSessionInfo, + thread: ThreadMetadata, window: &mut Window, cx: &mut Context, ) { self.selection = None; self.reset_filter_editor_text(window, cx); - cx.emit(ThreadsArchiveViewEvent::Unarchive { - agent: self.selected_agent.clone(), - session_info, - }); - } - - fn delete_thread(&mut self, session_id: &acp::SessionId, cx: &mut Context) { - let Some(history) = &self.history else { - return; - }; - if !history.read(cx).supports_delete() { - return; - } - let session_id = session_id.clone(); - history.update(cx, |history, cx| { - history - .delete_session(&session_id, cx) - .detach_and_log_err(cx); - }); - } - - fn remove_selected_thread( - &mut self, - _: &RemoveSelectedThread, - _window: &mut Window, - cx: &mut Context, - ) { - let Some(ix) = self.selection else { - return; - }; - let Some(ArchiveListItem::Entry { session, .. }) = self.items.get(ix) else { - return; - }; - let session_id = session.session_id.clone(); - self.delete_thread(&session_id, cx); + cx.emit(ThreadsArchiveViewEvent::Unarchive { thread }); } fn is_selectable_item(&self, ix: usize) -> bool { @@ -448,16 +358,15 @@ impl ThreadsArchiveView { fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { let Some(ix) = self.selection else { return }; - let Some(ArchiveListItem::Entry { session, .. }) = self.items.get(ix) else { + let Some(ArchiveListItem::Entry { thread, .. }) = self.items.get(ix) else { return; }; - let can_unarchive = session.work_dirs.as_ref().is_some_and(|p| !p.is_empty()); - if !can_unarchive { + if thread.folder_paths.is_empty() { return; } - self.unarchive_thread(session.clone(), window, cx); + self.unarchive_thread(thread.clone(), window, cx); } fn render_list_entry( @@ -483,71 +392,40 @@ impl ThreadsArchiveView { ) .into_any_element(), ArchiveListItem::Entry { - session, + thread, highlight_positions, } => { let id = SharedString::from(format!("archive-entry-{}", ix)); let is_focused = self.selection == Some(ix); - let hovered = self.hovered_index == Some(ix); - - let project_names = session.work_dirs.as_ref().and_then(|paths| { - let paths_str = paths - .paths() - .iter() - .filter_map(|p| p.file_name()) - .filter_map(|name| name.to_str()) - .join(", "); - if paths_str.is_empty() { - None - } else { - Some(paths_str) - } - }); + let is_hovered = self.hovered_index == Some(ix); - let can_unarchive = session.work_dirs.as_ref().is_some_and(|p| !p.is_empty()); - - let supports_delete = self - .history - .as_ref() - .map(|h| h.read(cx).supports_delete()) - .unwrap_or(false); - - let title: SharedString = - session.title.clone().unwrap_or_else(|| "Untitled".into()); - - let session_info = session.clone(); - let session_id_for_delete = session.session_id.clone(); let focus_handle = self.focus_handle.clone(); - let timestamp = session - .created_at - .or(session.updated_at) - .map(format_history_entry_timestamp); + let timestamp = + format_history_entry_timestamp(thread.created_at.unwrap_or(thread.updated_at)); - let highlight_positions = highlight_positions.clone(); - let title_label = if highlight_positions.is_empty() { - Label::new(title).truncate().flex_1().into_any_element() + let icon_from_external_svg = self + .agent_server_store + .upgrade() + .and_then(|store| store.read(cx).agent_icon(&thread.agent_id)); + + let icon = if thread.agent_id.as_ref() == agent::ZED_AGENT_ID.as_ref() { + IconName::ZedAgent } else { - HighlightedLabel::new(title, highlight_positions) - .truncate() - .flex_1() - .into_any_element() + IconName::Sparkle }; - h_flex() - .id(id) - .min_w_0() - .w_full() - .px(DynamicSpacing::Base06.rems(cx)) - .border_1() - .map(|this| { - if is_focused { - this.border_color(cx.theme().colors().border_focused) - } else { - this.border_color(gpui::transparent_black()) - } + ThreadItem::new(id, thread.title.clone()) + .icon(icon) + .when_some(icon_from_external_svg, |this, svg| { + this.custom_icon_from_external_svg(svg) }) + .timestamp(timestamp) + .highlight_positions(highlight_positions.clone()) + .project_paths(thread.folder_paths.paths_owned()) + .focused(is_focused) + .hovered(is_hovered) .on_hover(cx.listener(move |this, is_hovered, _window, cx| { if *is_hovered { this.hovered_index = Some(ix); @@ -556,105 +434,59 @@ impl ThreadsArchiveView { } cx.notify(); })) - .child( - v_flex() - .min_w_0() - .w_full() - .p_1() - .child( - h_flex() - .min_w_0() - .w_full() - .gap_1() - .justify_between() - .child(title_label) - .when(hovered || is_focused, |this| { - this.child( - h_flex() - .gap_0p5() - .when(can_unarchive, |this| { - this.child( - Button::new("unarchive-thread", "Restore") - .style(ButtonStyle::Filled) - .label_size(LabelSize::Small) - .when(is_focused, |this| { - this.key_binding( - KeyBinding::for_action_in( - &menu::Confirm, - &focus_handle, - cx, - ) - .map(|kb| { - kb.size(rems_from_px(12.)) - }), - ) - }) - .on_click(cx.listener( - move |this, _, window, cx| { - this.unarchive_thread( - session_info.clone(), - window, - cx, - ); - }, - )), - ) - }) - .when(supports_delete, |this| { - this.child( - IconButton::new( - "delete-thread", - IconName::Trash, - ) - .style(ButtonStyle::Filled) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .tooltip({ - move |_window, cx| { - Tooltip::for_action_in( - "Delete Thread", - &RemoveSelectedThread, - &focus_handle, - cx, - ) - } - }) - .on_click(cx.listener( - move |this, _, _, cx| { - this.delete_thread( - &session_id_for_delete, - cx, - ); - cx.stop_propagation(); - }, - )), - ) - }), - ) - }), - ) + .action_slot( + h_flex() + .gap_2() + .when(is_hovered || is_focused, |this| { + let focus_handle = self.focus_handle.clone(); + this.child( + Button::new("unarchive-thread", "Open") + .style(ButtonStyle::Filled) + .label_size(LabelSize::Small) + .when(is_focused, |this| { + this.key_binding( + KeyBinding::for_action_in( + &menu::Confirm, + &focus_handle, + cx, + ) + .map(|kb| kb.size(rems_from_px(12.))), + ) + }) + .on_click({ + let thread = thread.clone(); + cx.listener(move |this, _, window, cx| { + this.unarchive_thread(thread.clone(), window, cx); + }) + }), + ) + }) .child( - h_flex() - .gap_1() - .when_some(timestamp, |this, ts| { - this.child( - Label::new(ts) - .size(LabelSize::Small) - .color(Color::Muted), - ) + IconButton::new("delete-thread", IconName::Trash) + .style(ButtonStyle::Filled) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip({ + move |_window, cx| { + Tooltip::for_action_in( + "Delete Thread", + &RemoveSelectedThread, + &focus_handle, + cx, + ) + } }) - .when_some(project_names, |this, project| { - this.child( - Label::new("•") - .size(LabelSize::Small) - .color(Color::Muted) - .alpha(0.5), - ) - .child( - Label::new(project) - .size(LabelSize::Small) - .color(Color::Muted), - ) + .on_click({ + let agent = thread.agent_id.clone(); + let session_id = thread.session_id.clone(); + cx.listener(move |this, _, _, cx| { + this.delete_thread( + session_id.clone(), + agent.clone(), + cx, + ); + cx.stop_propagation(); + }) }), ), ) @@ -663,139 +495,77 @@ impl ThreadsArchiveView { } } - fn render_agent_picker(&self, cx: &mut Context) -> PopoverMenu { - let agent_server_store = self.agent_server_store.clone(); + fn delete_thread( + &mut self, + session_id: acp::SessionId, + agent: AgentId, + cx: &mut Context, + ) { + ThreadMetadataStore::global(cx) + .update(cx, |store, cx| store.delete(session_id.clone(), cx)); - let (chevron_icon, icon_color) = if self.selected_agent_menu.is_deployed() { - (IconName::ChevronUp, Color::Accent) - } else { - (IconName::ChevronDown, Color::Muted) + let agent = Agent::from(agent); + + let Some(agent_connection_store) = self.agent_connection_store.upgrade() else { + return; }; + let fs = ::global(cx); - let selected_agent_icon = if let Agent::Custom { id } = &self.selected_agent { - let store = agent_server_store.read(cx); - let icon = store.agent_icon(&id); + let task = agent_connection_store.update(cx, |store, cx| { + store + .request_connection(agent.clone(), agent.server(fs, ThreadStore::global(cx)), cx) + .read(cx) + .wait_for_connection() + }); + cx.spawn(async move |_this, cx| { + let state = task.await?; + let task = cx.update(|cx| { + if let Some(list) = state.connection.session_list(cx) { + list.delete_session(&session_id, cx) + } else { + Task::ready(Ok(())) + } + }); + task.await + }) + .detach_and_log_err(cx); + } - if let Some(icon) = icon { - Icon::from_external_svg(icon) - } else { - Icon::new(IconName::Sparkle) - } - .color(Color::Muted) - .size(IconSize::Small) - } else { - Icon::new(IconName::ZedAgent) - .color(Color::Muted) - .size(IconSize::Small) + fn show_thread_import_modal(&mut self, window: &mut Window, cx: &mut Context) { + let Some(agent_server_store) = self.agent_server_store.upgrade() else { + return; + }; + let Some(agent_registry_store) = self.agent_registry_store.upgrade() else { + return; }; - let this = cx.weak_entity(); - - PopoverMenu::new("agent_history_menu") - .trigger( - ButtonLike::new("selected_agent") - .selected_style(ButtonStyle::Tinted(TintColor::Accent)) - .child( - h_flex().gap_1().child(selected_agent_icon).child( - Icon::new(chevron_icon) - .color(icon_color) - .size(IconSize::XSmall), - ), - ), - ) - .menu(move |window, cx| { - Some(ContextMenu::build(window, cx, |menu, _window, cx| { - menu.item( - ContextMenuEntry::new("Zed Agent") - .icon(IconName::ZedAgent) - .icon_color(Color::Muted) - .handler({ - let this = this.clone(); - move |window, cx| { - this.update(cx, |this, cx| { - this.set_selected_agent(Agent::NativeAgent, window, cx) - }) - .ok(); - } - }), + let workspace_handle = self.workspace.clone(); + let multi_workspace = self.multi_workspace.clone(); + + self.workspace + .update(cx, |workspace, cx| { + workspace.toggle_modal(window, cx, |window, cx| { + ThreadImportModal::new( + agent_server_store, + agent_registry_store, + workspace_handle.clone(), + multi_workspace.clone(), + window, + cx, ) - .separator() - .map(|mut menu| { - let agent_server_store = agent_server_store.read(cx); - let registry_store = project::AgentRegistryStore::try_global(cx); - let registry_store_ref = registry_store.as_ref().map(|s| s.read(cx)); - - struct AgentMenuItem { - id: AgentId, - display_name: SharedString, - } - - let agent_items = agent_server_store - .external_agents() - .map(|agent_id| { - let display_name = agent_server_store - .agent_display_name(agent_id) - .or_else(|| { - registry_store_ref - .as_ref() - .and_then(|store| store.agent(agent_id)) - .map(|a| a.name().clone()) - }) - .unwrap_or_else(|| agent_id.0.clone()); - AgentMenuItem { - id: agent_id.clone(), - display_name, - } - }) - .sorted_unstable_by_key(|e| e.display_name.to_lowercase()) - .collect::>(); - - for item in &agent_items { - let mut entry = ContextMenuEntry::new(item.display_name.clone()); - - let icon_path = agent_server_store.agent_icon(&item.id).or_else(|| { - registry_store_ref - .as_ref() - .and_then(|store| store.agent(&item.id)) - .and_then(|a| a.icon_path().cloned()) - }); - - if let Some(icon_path) = icon_path { - entry = entry.custom_icon_svg(icon_path); - } else { - entry = entry.icon(IconName::ZedAgent); - } - - entry = entry.icon_color(Color::Muted).handler({ - let this = this.clone(); - let agent = Agent::Custom { - id: item.id.clone(), - }; - move |window, cx| { - this.update(cx, |this, cx| { - this.set_selected_agent(agent.clone(), window, cx) - }) - .ok(); - } - }); - - menu = menu.item(entry); - } - menu - }) - })) - }) - .with_handle(self.selected_agent_menu.clone()) - .anchor(gpui::Corner::TopRight) - .offset(gpui::Point { - x: px(1.0), - y: px(1.0), + }); }) + .log_err(); } fn render_header(&self, window: &Window, cx: &mut Context) -> impl IntoElement { let has_query = !self.filter_editor.read(cx).text(cx).is_empty(); - let traffic_lights = cfg!(target_os = "macos") && !window.is_fullscreen(); + let sidebar_on_left = matches!( + AgentSettings::get_global(cx).sidebar_side(), + settings::SidebarSide::Left + ); + let traffic_lights = + cfg!(target_os = "macos") && !window.is_fullscreen() && sidebar_on_left; let header_height = platform_title_bar_height(window); let show_focus_keybinding = self.selection.is_some() && !self.filter_editor.focus_handle(cx).is_focused(window); @@ -804,15 +574,21 @@ impl ThreadsArchiveView { .h(header_height) .mt_px() .pb_px() - .when(traffic_lights, |this| { - this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING)) + .map(|this| { + if traffic_lights { + this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING)) + } else { + this.pl_1p5() + } }) .pr_1p5() .gap_1() .justify_between() .border_b_1() .border_color(cx.theme().colors().border) - .child(Divider::vertical().color(ui::DividerColor::Border)) + .when(traffic_lights, |this| { + this.child(Divider::vertical().color(ui::DividerColor::Border)) + }) .child( h_flex() .ml_1() @@ -829,19 +605,27 @@ impl ThreadsArchiveView { .when(show_focus_keybinding, |this| { this.child(KeyBinding::for_action(&FocusSidebarFilter, cx)) }) - .when(!has_query && !show_focus_keybinding, |this| { - this.child(self.render_agent_picker(cx)) - }) - .when(has_query, |this| { - this.child( - IconButton::new("clear_filter", IconName::Close) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Clear Search")) - .on_click(cx.listener(|this, _, window, cx| { - this.reset_filter_editor_text(window, cx); - this.update_items(cx); - })), - ) + .map(|this| { + if has_query { + this.child( + IconButton::new("clear-filter", IconName::Close) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Clear Search")) + .on_click(cx.listener(|this, _, window, cx| { + this.reset_filter_editor_text(window, cx); + this.update_items(cx); + })), + ) + } else { + this.child( + IconButton::new("import-thread", IconName::Plus) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Import ACP Threads")) + .on_click(cx.listener(|this, _, window, cx| { + this.show_thread_import_modal(window, cx); + })), + ) + } }) } } @@ -875,30 +659,18 @@ impl Focusable for ThreadsArchiveView { } } -impl ThreadsArchiveView { - fn empty_state_message(&self, is_empty: bool, has_query: bool) -> Option<&'static str> { - archive_empty_state_message(self.history.is_some(), is_empty, has_query) - } -} - impl Render for ThreadsArchiveView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let is_empty = self.items.is_empty(); let has_query = !self.filter_editor.read(cx).text(cx).is_empty(); - let content = if self.is_loading { - v_flex() - .flex_1() - .justify_center() - .items_center() - .child( - Icon::new(IconName::LoadCircle) - .size(IconSize::Small) - .color(Color::Muted) - .with_rotate_animation(2), - ) - .into_any_element() - } else if let Some(message) = self.empty_state_message(is_empty, has_query) { + let content = if is_empty { + let message = if has_query { + "No threads match your search." + } else { + "No archived or hidden threads yet." + }; + v_flex() .flex_1() .justify_center() @@ -935,44 +707,8 @@ impl Render for ThreadsArchiveView { .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)) .size_full() .child(self.render_header(window, cx)) .child(content) } } - -#[cfg(test)] -mod tests { - use super::archive_empty_state_message; - - #[test] - fn empty_state_message_returns_none_when_archive_has_items() { - assert_eq!(archive_empty_state_message(false, false, false), None); - assert_eq!(archive_empty_state_message(true, false, true), None); - } - - #[test] - fn empty_state_message_distinguishes_unsupported_history() { - assert_eq!( - archive_empty_state_message(false, true, false), - Some("This agent does not support viewing archived threads.") - ); - assert_eq!( - archive_empty_state_message(false, true, true), - Some("This agent does not support viewing archived threads.") - ); - } - - #[test] - fn empty_state_message_distinguishes_empty_history_and_search_results() { - assert_eq!( - archive_empty_state_message(true, true, false), - Some("No archived threads yet.") - ); - assert_eq!( - archive_empty_state_message(true, true, true), - Some("No threads match your search.") - ); - } -} diff --git a/crates/agent_ui/src/ui/agent_notification.rs b/crates/agent_ui/src/ui/agent_notification.rs index 371523f129869786f13d1a220747f4d0d944d1e5..18a4161f1df99988177462059870234f81e48b5c 100644 --- a/crates/agent_ui/src/ui/agent_notification.rs +++ b/crates/agent_ui/src/ui/agent_notification.rs @@ -5,7 +5,6 @@ use gpui::{ }; use release_channel::ReleaseChannel; use std::rc::Rc; -use theme; use ui::{Render, prelude::*}; pub struct AgentNotification { @@ -87,7 +86,7 @@ impl AgentNotification { impl Render for AgentNotification { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let ui_font = theme::setup_ui_font(window, cx); + let ui_font = theme_settings::setup_ui_font(window, cx); let line_height = window.line_height(); let bg = cx.theme().colors().elevated_surface_background; diff --git a/crates/agent_ui/src/ui/mention_crease.rs b/crates/agent_ui/src/ui/mention_crease.rs index b70b77e6ca603aba8fd55706918ffb3543e2a734..91200684d7ca1891578bb70fd6db65b2885aed93 100644 --- a/crates/agent_ui/src/ui/mention_crease.rs +++ b/crates/agent_ui/src/ui/mention_crease.rs @@ -9,7 +9,7 @@ use gpui::{ use prompt_store::PromptId; use rope::Point; use settings::Settings; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ButtonLike, TintColor, Tooltip, prelude::*}; use workspace::{OpenOptions, Workspace}; diff --git a/crates/assistant_text_thread/Cargo.toml b/crates/assistant_text_thread/Cargo.toml index bbb5cf4778efd5d74b880b7350a71e72562f4d70..195fa34f4f0379248a189ee59fcf779d18bc09c9 100644 --- a/crates/assistant_text_thread/Cargo.toml +++ b/crates/assistant_text_thread/Cargo.toml @@ -21,7 +21,6 @@ assistant_slash_command.workspace = true chrono.workspace = true client.workspace = true clock.workspace = true -cloud_llm_client.workspace = true collections.workspace = true context_server.workspace = true fs.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 9829e8993c832ecd7ad0a298f11a7a7840573f04..c4f1688dd0183bdfc81ed284f0a3e2681e8e4582 100644 --- a/crates/assistant_text_thread/src/assistant_text_thread_tests.rs +++ b/crates/assistant_text_thread/src/assistant_text_thread_tests.rs @@ -526,7 +526,7 @@ async fn test_slash_commands(cx: &mut TestAppContext) { command_output_tx .unbounded_send(Ok(SlashCommandEvent::StartSection { - icon: IconName::Ai, + icon: IconName::ZedAgent, label: "src/main.rs".into(), metadata: None, })) @@ -870,7 +870,7 @@ async fn test_random_context_collaboration(cx: &mut TestAppContext, mut rng: Std rng.random_range(section_start..=output_text.len()), ); events.push(Ok(SlashCommandEvent::StartSection { - icon: IconName::Ai, + icon: IconName::ZedAgent, label: "section".into(), metadata: None, })); diff --git a/crates/assistant_text_thread/src/text_thread.rs b/crates/assistant_text_thread/src/text_thread.rs index 7df6b32e59733086b70ce4dccaa40bbc9cbccf32..8b2fdc6187af9e525e07c54d3cbd08f32261f734 100644 --- a/crates/assistant_text_thread/src/text_thread.rs +++ b/crates/assistant_text_thread/src/text_thread.rs @@ -6,7 +6,6 @@ use assistant_slash_command::{ }; use client::{self, proto}; use clock::ReplicaId; -use cloud_llm_client::CompletionIntent; use collections::{HashMap, HashSet}; use fs::{Fs, RenameOptions}; @@ -18,9 +17,9 @@ use gpui::{ use itertools::Itertools as _; use language::{AnchorRangeExt, Bias, Buffer, LanguageRegistry, OffsetRangeExt, Point, ToOffset}; use language_model::{ - AnthropicCompletionType, AnthropicEventData, AnthropicEventType, LanguageModel, - LanguageModelCacheConfiguration, LanguageModelCompletionEvent, LanguageModelImage, - LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, + AnthropicCompletionType, AnthropicEventData, AnthropicEventType, CompletionIntent, + LanguageModel, LanguageModelCacheConfiguration, LanguageModelCompletionEvent, + LanguageModelImage, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, LanguageModelToolUseId, MessageContent, PaymentRequiredError, Role, StopReason, report_anthropic_event, }; diff --git a/crates/audio/Cargo.toml b/crates/audio/Cargo.toml index f3898265e500dd40602c9877b5e4c0980932a81a..59df698af14aa611e44b4bb0f69ac4eb9c83ba10 100644 --- a/crates/audio/Cargo.toml +++ b/crates/audio/Cargo.toml @@ -14,7 +14,6 @@ doctest = false [dependencies] anyhow.workspace = true -async-tar.workspace = true collections.workspace = true cpal.workspace = true crossbeam.workspace = true @@ -25,7 +24,6 @@ parking_lot.workspace = true rodio.workspace = true serde.workspace = true settings.workspace = true -smol.workspace = true thiserror.workspace = true util.workspace = true diff --git a/crates/audio/src/audio.rs b/crates/audio/src/audio.rs index bfd30973923027e9ed5080fbee005abe4c0fd912..24a4032d954c2eb8282872c544bc240a0e1dabe8 100644 --- a/crates/audio/src/audio.rs +++ b/crates/audio/src/audio.rs @@ -11,7 +11,7 @@ pub use audio_settings::AudioSettings; pub use audio_settings::LIVE_SETTINGS; mod audio_pipeline; -pub use audio_pipeline::{Audio, VoipParts}; +pub use audio_pipeline::Audio; pub use audio_pipeline::{AudioDeviceInfo, AvailableAudioDevices}; pub use audio_pipeline::{ensure_devices_initialized, resolve_device}; // TODO(audio) replace with input test functionality in the audio crate diff --git a/crates/audio/src/audio_pipeline.rs b/crates/audio/src/audio_pipeline.rs index 3d2a6ae32c381b1cab590946c35fbb68325af5db..a2b353563bdf7b82df86a180617c21ccff95496d 100644 --- a/crates/audio/src/audio_pipeline.rs +++ b/crates/audio/src/audio_pipeline.rs @@ -4,23 +4,17 @@ use cpal::{ DeviceDescription, DeviceId, default_host, traits::{DeviceTrait, HostTrait}, }; -use gpui::{App, AsyncApp, BackgroundExecutor, BorrowAppContext, Global}; +use gpui::{App, AsyncApp, BorrowAppContext, Global}; pub(super) use cpal::Sample; -pub(super) use rodio::source::LimitSettings; -use rodio::{ - Decoder, DeviceSinkBuilder, MixerDeviceSink, Source, - mixer::Mixer, - source::{AutomaticGainControlSettings, Buffered}, -}; +use rodio::{Decoder, DeviceSinkBuilder, MixerDeviceSink, Source, mixer::Mixer, source::Buffered}; use settings::Settings; -use std::{io::Cursor, path::PathBuf, sync::atomic::Ordering, time::Duration}; +use std::io::Cursor; use util::ResultExt; mod echo_canceller; use echo_canceller::EchoCanceller; -mod replays; mod rodio_ext; pub use crate::audio_settings::AudioSettings; pub use rodio_ext::RodioExt; @@ -59,7 +53,6 @@ pub struct Audio { output: Option<(MixerDeviceSink, Mixer)>, pub echo_canceller: EchoCanceller, source_cache: HashMap>>>>, - replays: replays::Replays, } impl Global for Audio {} @@ -84,84 +77,6 @@ impl Audio { .expect("we only get here if opening the outputstream succeeded")) } - pub fn save_replays( - &self, - executor: BackgroundExecutor, - ) -> gpui::Task> { - self.replays.replays_to_tar(executor) - } - - pub fn open_microphone(mut voip_parts: VoipParts) -> anyhow::Result { - let stream = open_input_stream(voip_parts.input_audio_device)?; - let stream = stream - .possibly_disconnected_channels_to_mono() - .constant_params(CHANNEL_COUNT, SAMPLE_RATE) - .process_buffer::(move |buffer| { - let mut int_buffer: [i16; _] = buffer.map(|s| s.to_sample()); - if voip_parts - .echo_canceller - .process_stream(&mut int_buffer) - .log_err() - .is_some() - { - for (sample, processed) in buffer.iter_mut().zip(&int_buffer) { - *sample = (*processed).to_sample(); - } - } - }) - .limit(LimitSettings::live_performance()) - .automatic_gain_control(AutomaticGainControlSettings { - target_level: 0.90, - attack_time: Duration::from_secs(1), - release_time: Duration::from_secs(0), - absolute_max_gain: 5.0, - }) - .periodic_access(Duration::from_millis(100), move |agc_source| { - agc_source - .set_enabled(LIVE_SETTINGS.auto_microphone_volume.load(Ordering::Relaxed)); - let _ = LIVE_SETTINGS.denoise; // TODO(audio: re-introduce de-noising - }); - - let (replay, stream) = stream.replayable(crate::REPLAY_DURATION)?; - voip_parts - .replays - .add_voip_stream("local microphone".to_string(), replay); - - Ok(stream) - } - - pub fn play_voip_stream( - source: impl rodio::Source + Send + 'static, - speaker_name: String, - is_staff: bool, - cx: &mut App, - ) -> anyhow::Result<()> { - let (replay_source, source) = source - .automatic_gain_control(AutomaticGainControlSettings { - target_level: 0.90, - attack_time: Duration::from_secs(1), - release_time: Duration::from_secs(0), - absolute_max_gain: 5.0, - }) - .periodic_access(Duration::from_millis(100), move |agc_source| { - agc_source.set_enabled(LIVE_SETTINGS.auto_speaker_volume.load(Ordering::Relaxed)); - }) - .replayable(crate::REPLAY_DURATION) - .expect("REPLAY_DURATION is longer than 100ms"); - let output_audio_device = AudioSettings::get_global(cx).output_audio_device.clone(); - - cx.update_default_global(|this: &mut Self, _cx| { - let output_mixer = this - .ensure_output_exists(output_audio_device) - .context("Could not get output mixer")?; - output_mixer.add(source); - if is_staff { - this.replays.add_voip_stream(speaker_name, replay_source); - } - Ok(()) - }) - } - pub fn play_sound(sound: Sound, cx: &mut App) { let output_audio_device = AudioSettings::get_global(cx).output_audio_device.clone(); cx.update_default_global(|this: &mut Self, cx| { @@ -203,29 +118,6 @@ impl Audio { } } -pub struct VoipParts { - echo_canceller: EchoCanceller, - replays: replays::Replays, - input_audio_device: Option, -} - -impl VoipParts { - pub fn new(cx: &AsyncApp) -> anyhow::Result { - let (apm, replays) = cx.read_default_global::(|audio, _| { - (audio.echo_canceller.clone(), audio.replays.clone()) - }); - let input_audio_device = - AudioSettings::try_read_global(cx, |settings| settings.input_audio_device.clone()) - .flatten(); - - Ok(Self { - echo_canceller: apm, - replays, - input_audio_device, - }) - } -} - pub fn open_input_stream( device_id: Option, ) -> anyhow::Result { diff --git a/crates/audio/src/audio_pipeline/replays.rs b/crates/audio/src/audio_pipeline/replays.rs deleted file mode 100644 index 3228700b2df5581e862da6ec71787704985386a2..0000000000000000000000000000000000000000 --- a/crates/audio/src/audio_pipeline/replays.rs +++ /dev/null @@ -1,78 +0,0 @@ -use anyhow::{Context, anyhow}; -use async_tar::{Builder, Header}; -use gpui::{BackgroundExecutor, Task}; - -use collections::HashMap; -use parking_lot::Mutex; -use rodio::Source; -use smol::fs::File; -use std::{io, path::PathBuf, sync::Arc, time::Duration}; - -use crate::REPLAY_DURATION; -use crate::audio_pipeline::rodio_ext::Replay; - -#[derive(Default, Clone)] -pub(crate) struct Replays(Arc>>); - -impl Replays { - pub(crate) fn add_voip_stream(&self, stream_name: String, source: Replay) { - let mut map = self.0.lock(); - map.retain(|_, replay| replay.source_is_active()); - map.insert(stream_name, source); - } - - pub(crate) fn replays_to_tar( - &self, - executor: BackgroundExecutor, - ) -> Task> { - let map = Arc::clone(&self.0); - executor.spawn(async move { - let recordings: Vec<_> = map - .lock() - .iter_mut() - .map(|(name, replay)| { - let queued = REPLAY_DURATION.min(replay.duration_ready()); - (name.clone(), replay.take_duration(queued).record()) - }) - .collect(); - let longest = recordings - .iter() - .map(|(_, r)| { - r.total_duration() - .expect("SamplesBuffer always returns a total duration") - }) - .max() - .ok_or(anyhow!("There is no audio to capture"))?; - - let path = std::env::current_dir() - .context("Could not get current dir")? - .join("replays.tar"); - let tar = File::create(&path) - .await - .context("Could not create file for tar")?; - - let mut tar = Builder::new(tar); - - for (name, recording) in recordings { - let mut writer = io::Cursor::new(Vec::new()); - rodio::wav_to_writer(recording, &mut writer).context("failed to encode wav")?; - let wav_data = writer.into_inner(); - let path = name.replace(' ', "_") + ".wav"; - let mut header = Header::new_gnu(); - // rw permissions for everyone - header.set_mode(0o666); - header.set_size(wav_data.len() as u64); - tar.append_data(&mut header, path, wav_data.as_slice()) - .await - .context("failed to apped wav to tar")?; - } - tar.into_inner() - .await - .context("Could not finish writing tar")? - .sync_all() - .await - .context("Could not flush tar file to disk")?; - Ok((path, longest)) - }) - } -} diff --git a/crates/audio/src/audio_settings.rs b/crates/audio/src/audio_settings.rs index 109bff605cabde402e47f5c7015cbbaefcd6a637..e200e232888e91a58f7776ff92b7b65de5f9b318 100644 --- a/crates/audio/src/audio_settings.rs +++ b/crates/audio/src/audio_settings.rs @@ -9,12 +9,6 @@ use settings::{RegisterSetting, Settings, SettingsStore}; #[derive(Clone, Debug, RegisterSetting)] pub struct AudioSettings { - /// Opt into the new audio system. - /// - /// You need to rejoin a call for this setting to apply - pub rodio_audio: bool, // default is false - /// Requires 'rodio_audio: true' - /// /// Automatically increase or decrease you microphone's volume. This affects how /// loud you sound to others. /// @@ -23,25 +17,6 @@ pub struct AudioSettings { /// audio and has auto speaker volume on this will make you very loud /// compared to other speakers. pub auto_microphone_volume: bool, - /// Requires 'rodio_audio: true' - /// - /// Automatically increate or decrease the volume of other call members. - /// This only affects how things sound for you. - pub auto_speaker_volume: bool, - /// Requires 'rodio_audio: true' - /// - /// Remove background noises. Works great for typing, cars, dogs, AC. Does - /// not work well on music. - pub denoise: bool, - /// Requires 'rodio_audio: true' - /// - /// Use audio parameters compatible with the previous versions of - /// experimental audio and non-experimental audio. When this is false you - /// will sound strange to anyone not on the latest experimental audio. In - /// the future we will migrate by setting this to false - /// - /// You need to rejoin a call for this setting to apply - pub legacy_audio_compatible: bool, /// Select specific output audio device. pub output_audio_device: Option, /// Select specific input audio device. @@ -53,11 +28,7 @@ impl Settings for AudioSettings { fn from_settings(content: &settings::SettingsContent) -> Self { let audio = &content.audio.as_ref().unwrap(); AudioSettings { - rodio_audio: audio.rodio_audio.unwrap(), auto_microphone_volume: audio.auto_microphone_volume.unwrap(), - auto_speaker_volume: audio.auto_speaker_volume.unwrap(), - denoise: audio.denoise.unwrap(), - legacy_audio_compatible: audio.legacy_audio_compatible.unwrap(), output_audio_device: audio .output_audio_device .as_ref() @@ -73,8 +44,6 @@ impl Settings for AudioSettings { /// See docs on [LIVE_SETTINGS] pub struct LiveSettings { pub auto_microphone_volume: AtomicBool, - pub(crate) auto_speaker_volume: AtomicBool, - pub(crate) denoise: AtomicBool, } impl LiveSettings { @@ -84,24 +53,6 @@ impl LiveSettings { AudioSettings::get_global(cx).auto_microphone_volume, Ordering::Relaxed, ); - LIVE_SETTINGS.auto_speaker_volume.store( - AudioSettings::get_global(cx).auto_speaker_volume, - Ordering::Relaxed, - ); - - let denoise_enabled = AudioSettings::get_global(cx).denoise; - #[cfg(debug_assertions)] - { - static DENOISE_WARNING_SEND: AtomicBool = AtomicBool::new(false); - if denoise_enabled && !DENOISE_WARNING_SEND.load(Ordering::Relaxed) { - DENOISE_WARNING_SEND.store(true, Ordering::Relaxed); - log::warn!("Denoise does not work on debug builds, not enabling") - } - } - #[cfg(not(debug_assertions))] - LIVE_SETTINGS - .denoise - .store(denoise_enabled, Ordering::Relaxed); }) .detach(); @@ -109,18 +60,6 @@ impl LiveSettings { LIVE_SETTINGS .auto_microphone_volume .store(init_settings.auto_microphone_volume, Ordering::Relaxed); - LIVE_SETTINGS - .auto_speaker_volume - .store(init_settings.auto_speaker_volume, Ordering::Relaxed); - let denoise_enabled = AudioSettings::get_global(cx).denoise; - #[cfg(debug_assertions)] - if denoise_enabled { - log::warn!("Denoise does not work on debug builds, not enabling") - } - #[cfg(not(debug_assertions))] - LIVE_SETTINGS - .denoise - .store(denoise_enabled, Ordering::Relaxed); } } @@ -130,6 +69,4 @@ impl LiveSettings { /// use the background executor. pub static LIVE_SETTINGS: LiveSettings = LiveSettings { auto_microphone_volume: AtomicBool::new(true), - auto_speaker_volume: AtomicBool::new(true), - denoise: AtomicBool::new(true), }; diff --git a/crates/buffer_diff/src/buffer_diff.rs b/crates/buffer_diff/src/buffer_diff.rs index c0f62ed8fc1c990b3bb4aaef5fff5ae23bebff86..1cb1e801c2cd68d442321da76c0abb848f9fa0d8 100644 --- a/crates/buffer_diff/src/buffer_diff.rs +++ b/crates/buffer_diff/src/buffer_diff.rs @@ -2,8 +2,8 @@ use futures::channel::oneshot; use git2::{DiffLineType as GitDiffLineType, DiffOptions as GitOptions, Patch as GitPatch}; use gpui::{App, AppContext as _, Context, Entity, EventEmitter, Task}; use language::{ - Capability, Diff, DiffOptions, File, Language, LanguageName, LanguageRegistry, - language_settings::language_settings, word_diff_ranges, + Capability, Diff, DiffOptions, Language, LanguageName, LanguageRegistry, + language_settings::LanguageSettings, word_diff_ranges, }; use rope::Rope; use std::{ @@ -115,6 +115,7 @@ pub struct DiffHunk { struct InternalDiffHunk { buffer_range: Range, diff_base_byte_range: Range, + diff_base_point_range: Range, base_word_diffs: Vec>, buffer_word_diffs: Vec>, } @@ -131,15 +132,25 @@ struct PendingHunk { pub struct DiffHunkSummary { buffer_range: Range, diff_base_byte_range: Range, + added_rows: u32, + removed_rows: u32, } impl sum_tree::Item for InternalDiffHunk { type Summary = DiffHunkSummary; - fn summary(&self, _cx: &text::BufferSnapshot) -> Self::Summary { + fn summary(&self, buffer: &text::BufferSnapshot) -> Self::Summary { + let buffer_start = self.buffer_range.start.to_point(buffer); + let buffer_end = self.buffer_range.end.to_point(buffer); DiffHunkSummary { buffer_range: self.buffer_range.clone(), diff_base_byte_range: self.diff_base_byte_range.clone(), + added_rows: buffer_end.row.saturating_sub(buffer_start.row), + removed_rows: self + .diff_base_point_range + .end + .row + .saturating_sub(self.diff_base_point_range.start.row), } } } @@ -151,6 +162,8 @@ impl sum_tree::Item for PendingHunk { DiffHunkSummary { buffer_range: self.buffer_range.clone(), diff_base_byte_range: self.diff_base_byte_range.clone(), + added_rows: 0, + removed_rows: 0, } } } @@ -162,6 +175,8 @@ impl sum_tree::Summary for DiffHunkSummary { DiffHunkSummary { buffer_range: Anchor::MIN..Anchor::MIN, diff_base_byte_range: 0..0, + added_rows: 0, + removed_rows: 0, } } @@ -180,6 +195,9 @@ impl sum_tree::Summary for DiffHunkSummary { .diff_base_byte_range .end .max(other.diff_base_byte_range.end); + + self.added_rows += other.added_rows; + self.removed_rows += other.removed_rows; } } @@ -234,6 +252,11 @@ impl BufferDiffSnapshot { self.inner.hunks.is_empty() } + pub fn changed_row_counts(&self) -> (u32, u32) { + let summary = self.inner.hunks.summary(); + (summary.added_rows, summary.removed_rows) + } + pub fn base_text_string(&self) -> Option { self.inner .base_text_exists @@ -1067,7 +1090,6 @@ impl BufferDiffInner { } fn build_diff_options( - file: Option<&Arc>, language: Option, language_scope: Option, cx: &App, @@ -1083,7 +1105,7 @@ fn build_diff_options( } } - language_settings(language, file, cx) + LanguageSettings::resolve(None, language.as_ref(), cx) .word_diff_enabled .then_some(DiffOptions { language_scope, @@ -1121,6 +1143,8 @@ fn compute_hunks( InternalDiffHunk { buffer_range: buffer.anchor_before(0)..buffer.anchor_before(0), diff_base_byte_range: 0..diff_base.len() - 1, + diff_base_point_range: Point::new(0, 0) + ..diff_base_rope.offset_to_point(diff_base.len() - 1), base_word_diffs: Vec::default(), buffer_word_diffs: Vec::default(), }, @@ -1148,6 +1172,7 @@ fn compute_hunks( InternalDiffHunk { buffer_range: Anchor::min_max_range_for_buffer(buffer.remote_id()), diff_base_byte_range: 0..0, + diff_base_point_range: Point::new(0, 0)..Point::new(0, 0), base_word_diffs: Vec::default(), buffer_word_diffs: Vec::default(), }, @@ -1461,7 +1486,9 @@ fn process_patch_hunk( InternalDiffHunk { buffer_range, - diff_base_byte_range, + diff_base_byte_range: diff_base_byte_range.clone(), + diff_base_point_range: diff_base.offset_to_point(diff_base_byte_range.start) + ..diff_base.offset_to_point(diff_base_byte_range.end), base_word_diffs, buffer_word_diffs, } @@ -1566,6 +1593,8 @@ impl BufferDiff { self.inner.pending_hunks = SumTree::from_summary(DiffHunkSummary { buffer_range: Anchor::min_min_range_for_buffer(self.buffer_id), diff_base_byte_range: 0..0, + added_rows: 0, + removed_rows: 0, }); let changed_range = Some(Anchor::min_max_range_for_buffer(self.buffer_id)); let base_text_range = Some(0..self.base_text(cx).len()); @@ -1652,11 +1681,11 @@ impl BufferDiff { language: Option>, cx: &App, ) -> Task { + let base_text = base_text.map(|t| text::LineEnding::normalize_arc(t)); let prev_base_text = self.base_text(cx).as_rope().clone(); let base_text_changed = base_text_change.is_some(); let compute_base_text_edits = base_text_change == Some(true); let diff_options = build_diff_options( - None, language.as_ref().map(|l| l.name()), language.as_ref().map(|l| l.default_scope()), cx, @@ -3949,4 +3978,36 @@ mod tests { } } } + + #[gpui::test] + async fn test_set_base_text_with_crlf(cx: &mut gpui::TestAppContext) { + let base_text_crlf = "one\r\ntwo\r\nthree\r\nfour\r\nfive\r\n"; + let base_text_lf = "one\ntwo\nthree\nfour\nfive\n"; + assert_ne!(base_text_crlf.len(), base_text_lf.len()); + + let buffer_text = "one\nTWO\nthree\nfour\nfive\n"; + let buffer = Buffer::new( + ReplicaId::LOCAL, + BufferId::new(1).unwrap(), + buffer_text.to_string(), + ); + let buffer_snapshot = buffer.snapshot(); + + let diff = cx.new(|cx| BufferDiff::new(&buffer_snapshot, cx)); + diff.update(cx, |diff, cx| { + diff.set_base_text( + Some(Arc::from(base_text_crlf)), + None, + buffer_snapshot.clone(), + cx, + ) + }) + .await + .ok(); + cx.run_until_parked(); + + let snapshot = diff.update(cx, |diff, cx| diff.snapshot(cx)); + snapshot.buffer_point_to_base_text_range(Point::new(0, 0), &buffer_snapshot); + snapshot.buffer_point_to_base_text_range(Point::new(1, 0), &buffer_snapshot); + } } diff --git a/crates/call/src/call_impl/mod.rs b/crates/call/src/call_impl/mod.rs index e060ec5edae6277a92c2c09ab54ded449bc56e11..b4bad6d2f350c3caa03eccbb8ca6582a71c6128c 100644 --- a/crates/call/src/call_impl/mod.rs +++ b/crates/call/src/call_impl/mod.rs @@ -17,8 +17,8 @@ use room::Event; use settings::Settings; use std::sync::Arc; use workspace::{ - ActiveCallEvent, AnyActiveCall, GlobalAnyActiveCall, Pane, RemoteCollaborator, SharedScreen, - Workspace, + ActiveCallEvent, AnyActiveCall, GlobalAnyActiveCall, MultiWorkspace, MultiWorkspaceEvent, Pane, + RemoteCollaborator, SharedScreen, Workspace, }; pub use livekit_client::{RemoteVideoTrack, RemoteVideoTrackView, RemoteVideoTrackViewEvent}; @@ -28,6 +28,36 @@ use crate::call_settings::CallSettings; pub fn init(client: Arc, user_store: Entity, cx: &mut App) { let active_call = cx.new(|cx| ActiveCall::new(client, user_store, cx)); + let active_call_handle = active_call.downgrade(); + + cx.observe_new(move |_multi_workspace: &mut MultiWorkspace, window, cx| { + let Some(window) = window else { + return; + }; + + let active_call_handle = active_call_handle.clone(); + cx.subscribe_in( + &cx.entity(), + window, + move |multi_workspace, _, event: &MultiWorkspaceEvent, window, cx| { + if !matches!(event, MultiWorkspaceEvent::ActiveWorkspaceChanged) + && window.is_window_active() + { + return; + } + + let project = multi_workspace.workspace().read(cx).project().clone(); + if let Ok(task) = active_call_handle.update(cx, |active_call, cx| { + active_call.set_location(Some(&project), cx) + }) { + task.detach_and_log_err(cx); + } + }, + ) + .detach(); + }) + .detach(); + cx.set_global(GlobalAnyActiveCall(Arc::new(ActiveCallEntity(active_call)))) } diff --git a/crates/call/src/call_impl/room.rs b/crates/call/src/call_impl/room.rs index 5f8eeb965cbc9d5665e8316cf1dde329b4277260..f92a8163d54de0c21c7318c4baab5aad5ce49b75 100644 --- a/crates/call/src/call_impl/room.rs +++ b/crates/call/src/call_impl/room.rs @@ -1773,7 +1773,15 @@ fn spawn_room_connection( }); this.diagnostics = Some(cx.new(|cx| CallDiagnostics::new(weak_room, cx))); - if !muted_by_user && this.can_use_microphone() { + // Always open the microphone track on join, even when + // `muted_by_user` is set. Note that the microphone will still + // be muted, as it is still gated in `share_microphone` by + // `muted_by_user`. For users that have `mute_on_join` enabled, + // this moves the Bluetooth profile switch (A2DP -> HFP) (which + // can cause 1-2 seconds of audio silence on some Bluetooth + // headphones) from first unmute to channel join, where + // instability is expected. + if this.can_use_microphone() { this.share_microphone(cx) } else { Task::ready(Ok(())) diff --git a/crates/channel/src/channel_store.rs b/crates/channel/src/channel_store.rs index f8d28ac96d7c140141ac520b1c38a10c82dd75a9..bc629f8740d5cb1969cf99746a87f81f191db122 100644 --- a/crates/channel/src/channel_store.rs +++ b/crates/channel/src/channel_store.rs @@ -38,6 +38,7 @@ pub struct ChannelStore { channel_invitations: Vec>, channel_participants: HashMap>>, channel_states: HashMap, + favorite_channel_ids: Vec, outgoing_invites: HashSet<(ChannelId, UserId)>, update_channels_tx: mpsc::UnboundedSender, opened_buffers: HashMap>, @@ -160,6 +161,32 @@ impl ChannelStore { cx.try_global::().map(|g| g.0.clone()) } + pub fn favorite_channel_ids(&self) -> &[ChannelId] { + &self.favorite_channel_ids + } + + pub fn is_channel_favorited(&self, channel_id: ChannelId) -> bool { + self.favorite_channel_ids.contains(&channel_id) + } + + pub fn toggle_favorite_channel(&mut self, channel_id: ChannelId, cx: &mut Context) { + if let Some(ix) = self + .favorite_channel_ids + .iter() + .position(|id| *id == channel_id) + { + self.favorite_channel_ids.remove(ix); + } else { + self.favorite_channel_ids.push(channel_id); + } + cx.notify(); + } + + pub fn set_favorite_channel_ids(&mut self, ids: Vec, cx: &mut Context) { + self.favorite_channel_ids = ids; + cx.notify(); + } + pub fn new(client: Arc, user_store: Entity, cx: &mut Context) -> Self { let rpc_subscriptions = [ client.add_message_handler(cx.weak_entity(), Self::handle_update_channels), @@ -217,6 +244,7 @@ impl ChannelStore { .log_err(); }), channel_states: Default::default(), + favorite_channel_ids: Vec::default(), did_subscribe: false, channels_loaded: watch::channel_with(false), } @@ -1066,6 +1094,8 @@ impl ChannelStore { self.channel_index.delete_channels(&delete_channels); self.channel_participants .retain(|channel_id, _| !delete_channels.contains(channel_id)); + self.favorite_channel_ids + .retain(|channel_id| !delete_channels.contains(channel_id)); for channel_id in &delete_channels { let channel_id = *channel_id; diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index d9ef55056049e387d931bc9fe59e0327b4ce1637..1edbb3399e4332e2ebd23f812c66697bda72d587 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -25,6 +25,7 @@ cloud_api_client.workspace = true cloud_llm_client.workspace = true collections.workspace = true credentials_provider.workspace = true +db.workspace = true derive_more.workspace = true feature_flags.workspace = true fs.workspace = true diff --git a/crates/client/src/user.rs b/crates/client/src/user.rs index df0c00d86636b6b4a138161a151e20a1c50a688d..e9b9acf68573ef5a05d642c09ed96a4d8aa23580 100644 --- a/crates/client/src/user.rs +++ b/crates/client/src/user.rs @@ -9,6 +9,7 @@ use cloud_llm_client::{ EDIT_PREDICTIONS_USAGE_AMOUNT_HEADER_NAME, EDIT_PREDICTIONS_USAGE_LIMIT_HEADER_NAME, UsageLimit, }; use collections::{HashMap, HashSet, hash_map::Entry}; +use db::kvp::KeyValueStore; use derive_more::Deref; use feature_flags::FeatureFlagAppExt; use futures::{Future, StreamExt, channel::mpsc}; @@ -25,6 +26,8 @@ use std::{ use text::ReplicaId; use util::{ResultExt, TryFutureExt as _}; +const CURRENT_ORGANIZATION_ID_KEY: &str = "current_organization_id"; + pub type UserId = u64; #[derive( @@ -706,9 +709,16 @@ impl UserStore { .is_some_and(|current| current.id == organization.id); if !is_same_organization { + let organization_id = organization.id.0.to_string(); self.current_organization.replace(organization); cx.emit(Event::OrganizationChanged); cx.notify(); + + let kvp = KeyValueStore::global(cx); + db::write_and_log(cx, move || async move { + kvp.write_kvp(CURRENT_ORGANIZATION_ID_KEY.into(), organization_id) + .await + }); } } @@ -816,14 +826,29 @@ impl UserStore { } self.organizations = response.organizations.into_iter().map(Arc::new).collect(); - self.current_organization = response - .default_organization_id - .and_then(|default_organization_id| { + let persisted_org_id = KeyValueStore::global(cx) + .read_kvp(CURRENT_ORGANIZATION_ID_KEY) + .log_err() + .flatten() + .map(|id| OrganizationId(Arc::from(id))); + + self.current_organization = persisted_org_id + .and_then(|persisted_id| { self.organizations .iter() - .find(|organization| organization.id == default_organization_id) + .find(|org| org.id == persisted_id) .cloned() }) + .or_else(|| { + response + .default_organization_id + .and_then(|default_organization_id| { + self.organizations + .iter() + .find(|organization| organization.id == default_organization_id) + .cloned() + }) + }) .or_else(|| self.organizations.first().cloned()); self.plans_by_organization = response .plans_by_organization diff --git a/crates/cloud_api_types/src/cloud_api_types.rs b/crates/cloud_api_types/src/cloud_api_types.rs index 286bdbbc1fb08f12ddc41107705975691e6ed1b4..a606b61923074b4eda42c861afddee9efba5f4b5 100644 --- a/crates/cloud_api_types/src/cloud_api_types.rs +++ b/crates/cloud_api_types/src/cloud_api_types.rs @@ -47,6 +47,7 @@ pub struct OrganizationId(pub Arc); pub struct Organization { pub id: OrganizationId, pub name: Arc, + pub is_personal: bool, } #[derive(Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/cloud_llm_client/src/cloud_llm_client.rs b/crates/cloud_llm_client/src/cloud_llm_client.rs index 8c06bd7ebb10b48acca46a4fc7c8afd82eb1a979..35eb3f2b80dd400558b1f027781f5b8cf63bb6cb 100644 --- a/crates/cloud_llm_client/src/cloud_llm_client.rs +++ b/crates/cloud_llm_client/src/cloud_llm_client.rs @@ -193,28 +193,12 @@ pub enum EditPredictionRejectReason { Rejected, } -#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum CompletionIntent { - UserPrompt, - ToolResults, - ThreadSummarization, - ThreadContextSummarization, - CreateFile, - EditFile, - InlineAssist, - TerminalInlineAssist, - GenerateGitCommitMessage, -} - #[derive(Debug, Serialize, Deserialize)] pub struct CompletionBody { #[serde(skip_serializing_if = "Option::is_none", default)] pub thread_id: Option, #[serde(skip_serializing_if = "Option::is_none", default)] pub prompt_id: Option, - #[serde(skip_serializing_if = "Option::is_none", default)] - pub intent: Option, pub provider: LanguageModelProvider, pub model: String, pub provider_request: serde_json::Value, diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index 447c2da08e054c9964f3813ac569964173ded5c3..41f1ba2c14c6c09bcbf6861674a845b3954aa733 100644 --- a/crates/collab/Cargo.toml +++ b/crates/collab/Cargo.toml @@ -130,6 +130,7 @@ settings = { workspace = true, features = ["test-support"] } smol.workspace = true sqlx = { version = "0.8", features = ["sqlite"] } task.workspace = true +theme_settings = { workspace = true, features = ["test-support"] } theme.workspace = true unindent.workspace = true diff --git a/crates/collab/tests/integration/collab_panel_tests.rs b/crates/collab/tests/integration/collab_panel_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..f1b65655e77bd9902970af6acbf5c89575df90ae --- /dev/null +++ b/crates/collab/tests/integration/collab_panel_tests.rs @@ -0,0 +1,356 @@ +use crate::TestServer; +use collab_ui::CollabPanel; +use collab_ui::collab_panel::{MoveChannelDown, MoveChannelUp, ToggleSelectedChannelFavorite}; +use gpui::TestAppContext; +use menu::{SelectNext, SelectPrevious}; + +#[gpui::test] +async fn test_reorder_favorite_channels_independently_of_channels(cx: &mut TestAppContext) { + let (server, client) = TestServer::start1(cx).await; + let root = server + .make_channel("root", None, (&client, cx), &mut []) + .await; + let _ = server + .make_channel("channel-a", Some(root), (&client, cx), &mut []) + .await; + let _ = server + .make_channel("channel-b", Some(root), (&client, cx), &mut []) + .await; + let _ = server + .make_channel("channel-c", Some(root), (&client, cx), &mut []) + .await; + + let (workspace, cx) = client.build_test_workspace(cx).await; + let panel = workspace.update_in(cx, |workspace, window, cx| { + let panel = CollabPanel::new(workspace, window, cx); + workspace.add_panel(panel.clone(), window, cx); + panel + }); + cx.run_until_parked(); + + // Verify initial state. + assert_eq!( + panel.read_with(cx, |panel, _| panel.entries_as_strings()), + &[ + "[Channels]", + " v root", + " #️⃣ channel-a", + " #️⃣ channel-b", + " #️⃣ channel-c", + "[Contacts]", + ] + ); + + // Select channel-b. + panel.update_in(cx, |panel, window, cx| { + panel.select_next(&SelectNext, window, cx); + panel.select_next(&SelectNext, window, cx); + panel.select_next(&SelectNext, window, cx); + panel.select_next(&SelectNext, window, cx); + }); + assert_eq!( + panel.read_with(cx, |panel, _| panel.entries_as_strings()), + &[ + "[Channels]", + " v root", + " #️⃣ channel-a", + " #️⃣ channel-b <== selected", + " #️⃣ channel-c", + "[Contacts]", + ] + ); + + // Favorite channel-b. + panel.update_in(cx, |panel, window, cx| { + panel.toggle_selected_channel_favorite(&ToggleSelectedChannelFavorite, window, cx); + }); + assert_eq!( + panel.read_with(cx, |panel, _| panel.entries_as_strings()), + &[ + "[Favorites]", + " #️⃣ channel-b", + "[Channels]", + " v root", + " #️⃣ channel-a", + " #️⃣ channel-b <== selected", + " #️⃣ channel-c", + "[Contacts]", + ] + ); + + // Select channel-c. + panel.update_in(cx, |panel, window, cx| { + panel.select_next(&SelectNext, window, cx); + }); + // Favorite channel-c. + panel.update_in(cx, |panel, window, cx| { + panel.toggle_selected_channel_favorite(&ToggleSelectedChannelFavorite, window, cx); + }); + assert_eq!( + panel.read_with(cx, |panel, _| panel.entries_as_strings()), + &[ + "[Favorites]", + " #️⃣ channel-b", + " #️⃣ channel-c", + "[Channels]", + " v root", + " #️⃣ channel-a", + " #️⃣ channel-b", + " #️⃣ channel-c <== selected", + "[Contacts]", + ] + ); + + // Navigate up to favorite channel-b . + panel.update_in(cx, |panel, window, cx| { + panel.select_previous(&SelectPrevious, window, cx); + panel.select_previous(&SelectPrevious, window, cx); + panel.select_previous(&SelectPrevious, window, cx); + panel.select_previous(&SelectPrevious, window, cx); + panel.select_previous(&SelectPrevious, window, cx); + panel.select_previous(&SelectPrevious, window, cx); + }); + assert_eq!( + panel.read_with(cx, |panel, _| panel.entries_as_strings()), + &[ + "[Favorites]", + " #️⃣ channel-b <== selected", + " #️⃣ channel-c", + "[Channels]", + " v root", + " #️⃣ channel-a", + " #️⃣ channel-b", + " #️⃣ channel-c", + "[Contacts]", + ] + ); + + // Move favorite channel-b down. + // The Channels section should remain unchanged + panel.update_in(cx, |panel, window, cx| { + panel.move_channel_down(&MoveChannelDown, window, cx); + }); + assert_eq!( + panel.read_with(cx, |panel, _| panel.entries_as_strings()), + &[ + "[Favorites]", + " #️⃣ channel-c", + " #️⃣ channel-b <== selected", + "[Channels]", + " v root", + " #️⃣ channel-a", + " #️⃣ channel-b", + " #️⃣ channel-c", + "[Contacts]", + ] + ); + + // Move favorite channel-b down again when it's already last (should be no-op). + panel.update_in(cx, |panel, window, cx| { + panel.move_channel_down(&MoveChannelDown, window, cx); + }); + assert_eq!( + panel.read_with(cx, |panel, _| panel.entries_as_strings()), + &[ + "[Favorites]", + " #️⃣ channel-c", + " #️⃣ channel-b <== selected", + "[Channels]", + " v root", + " #️⃣ channel-a", + " #️⃣ channel-b", + " #️⃣ channel-c", + "[Contacts]", + ] + ); + + // Move favorite channel-b back up. + // The Channels section should remain unchanged. + panel.update_in(cx, |panel, window, cx| { + panel.move_channel_up(&MoveChannelUp, window, cx); + }); + assert_eq!( + panel.read_with(cx, |panel, _| panel.entries_as_strings()), + &[ + "[Favorites]", + " #️⃣ channel-b <== selected", + " #️⃣ channel-c", + "[Channels]", + " v root", + " #️⃣ channel-a", + " #️⃣ channel-b", + " #️⃣ channel-c", + "[Contacts]", + ] + ); + + // Move favorite channel-b up again when it's already first (should be no-op). + panel.update_in(cx, |panel, window, cx| { + panel.move_channel_up(&MoveChannelUp, window, cx); + }); + assert_eq!( + panel.read_with(cx, |panel, _| panel.entries_as_strings()), + &[ + "[Favorites]", + " #️⃣ channel-b <== selected", + " #️⃣ channel-c", + "[Channels]", + " v root", + " #️⃣ channel-a", + " #️⃣ channel-b", + " #️⃣ channel-c", + "[Contacts]", + ] + ); + + // Unfavorite channel-b. + // Selection should move to the next favorite (channel-c). + panel.update_in(cx, |panel, window, cx| { + panel.toggle_selected_channel_favorite(&ToggleSelectedChannelFavorite, window, cx); + }); + assert_eq!( + panel.read_with(cx, |panel, _| panel.entries_as_strings()), + &[ + "[Favorites]", + " #️⃣ channel-c <== selected", + "[Channels]", + " v root", + " #️⃣ channel-a", + " #️⃣ channel-b", + " #️⃣ channel-c", + "[Contacts]", + ] + ); + + // Unfavorite channel-c. + // Favorites section should disappear entirely. + // Selection should move to the next available item. + panel.update_in(cx, |panel, window, cx| { + panel.toggle_selected_channel_favorite(&ToggleSelectedChannelFavorite, window, cx); + }); + assert_eq!( + panel.read_with(cx, |panel, _| panel.entries_as_strings()), + &[ + "[Channels]", + " v root <== selected", + " #️⃣ channel-a", + " #️⃣ channel-b", + " #️⃣ channel-c", + "[Contacts]", + ] + ); +} + +#[gpui::test] +async fn test_reorder_channels_independently_of_favorites(cx: &mut TestAppContext) { + let (server, client) = TestServer::start1(cx).await; + let root = server + .make_channel("root", None, (&client, cx), &mut []) + .await; + let _ = server + .make_channel("channel-a", Some(root), (&client, cx), &mut []) + .await; + let _ = server + .make_channel("channel-b", Some(root), (&client, cx), &mut []) + .await; + let _ = server + .make_channel("channel-c", Some(root), (&client, cx), &mut []) + .await; + + let (workspace, cx) = client.build_test_workspace(cx).await; + let panel = workspace.update_in(cx, |workspace, window, cx| { + let panel = CollabPanel::new(workspace, window, cx); + workspace.add_panel(panel.clone(), window, cx); + panel + }); + cx.run_until_parked(); + + // Select channel-a. + panel.update_in(cx, |panel, window, cx| { + panel.select_next(&SelectNext, window, cx); + panel.select_next(&SelectNext, window, cx); + panel.select_next(&SelectNext, window, cx); + }); + assert_eq!( + panel.read_with(cx, |panel, _| panel.entries_as_strings()), + &[ + "[Channels]", + " v root", + " #️⃣ channel-a <== selected", + " #️⃣ channel-b", + " #️⃣ channel-c", + "[Contacts]", + ] + ); + + // Favorite channel-a. + panel.update_in(cx, |panel, window, cx| { + panel.toggle_selected_channel_favorite(&ToggleSelectedChannelFavorite, window, cx); + }); + + // Select channel-b. + // Favorite channel-b. + panel.update_in(cx, |panel, window, cx| { + panel.select_next(&SelectNext, window, cx); + panel.toggle_selected_channel_favorite(&ToggleSelectedChannelFavorite, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + panel.read_with(cx, |panel, _| panel.entries_as_strings()), + &[ + "[Favorites]", + " #️⃣ channel-a", + " #️⃣ channel-b", + "[Channels]", + " v root", + " #️⃣ channel-a", + " #️⃣ channel-b <== selected", + " #️⃣ channel-c", + "[Contacts]", + ] + ); + + // Select channel-a in the Channels section. + panel.update_in(cx, |panel, window, cx| { + panel.select_previous(&SelectPrevious, window, cx); + }); + assert_eq!( + panel.read_with(cx, |panel, _| panel.entries_as_strings()), + &[ + "[Favorites]", + " #️⃣ channel-a", + " #️⃣ channel-b", + "[Channels]", + " v root", + " #️⃣ channel-a <== selected", + " #️⃣ channel-b", + " #️⃣ channel-c", + "[Contacts]", + ] + ); + + // Move channel-a down. + // The Favorites section should remain unchanged. + // Selection should remain on channel-a in the Channels section, + // not jump to channel-a in Favorites. + panel.update_in(cx, |panel, window, cx| { + panel.move_channel_down(&MoveChannelDown, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + panel.read_with(cx, |panel, _| panel.entries_as_strings()), + &[ + "[Favorites]", + " #️⃣ channel-a", + " #️⃣ channel-b", + "[Channels]", + " v root", + " #️⃣ channel-b", + " #️⃣ channel-a <== selected", + " #️⃣ channel-c", + "[Contacts]", + ] + ); +} diff --git a/crates/collab/tests/integration/collab_tests.rs b/crates/collab/tests/integration/collab_tests.rs index 8c568c5c4e1f9b8414b48d5b7175763ded5e89c9..5079698a96a1d04e0dd5da4baca9d8bae7cf9665 100644 --- a/crates/collab/tests/integration/collab_tests.rs +++ b/crates/collab/tests/integration/collab_tests.rs @@ -6,6 +6,7 @@ mod agent_sharing_tests; mod channel_buffer_tests; mod channel_guest_tests; mod channel_tests; +mod collab_panel_tests; mod db_tests; mod editor_tests; mod following_tests; diff --git a/crates/collab/tests/integration/editor_tests.rs b/crates/collab/tests/integration/editor_tests.rs index 1590f498308c74125c7672595cb7510b6653e9b1..2ce3abf48f12b2ede1f0340e2e438d3df0704985 100644 --- a/crates/collab/tests/integration/editor_tests.rs +++ b/crates/collab/tests/integration/editor_tests.rs @@ -23,7 +23,7 @@ use gpui::{ VisualTestContext, }; use indoc::indoc; -use language::{FakeLspAdapter, language_settings::language_settings, rust_lang}; +use language::{FakeLspAdapter, language_settings::LanguageSettings, rust_lang}; use lsp::DEFAULT_LSP_REQUEST_TIMEOUT; use multi_buffer::{AnchorRangeExt as _, MultiBufferRow}; use pretty_assertions::assert_eq; @@ -4036,6 +4036,8 @@ async fn test_collaborating_with_external_editorconfig( .await .unwrap(); + project_a.update(cx_a, |project, _| project.languages().add(rust_lang())); + // Open buffer on client A let buffer_a = project_a .update(cx_a, |p, cx| { @@ -4048,13 +4050,13 @@ async fn test_collaborating_with_external_editorconfig( // Verify client A sees external editorconfig settings cx_a.read(|cx| { - let file = buffer_a.read(cx).file(); - let settings = language_settings(Some("Rust".into()), file, cx); + let settings = LanguageSettings::for_buffer(&buffer_a.read(cx), cx); assert_eq!(Some(settings.tab_size), NonZeroU32::new(5)); }); // Client B joins the project let project_b = client_b.join_remote_project(project_id, cx_b).await; + project_b.update(cx_b, |project, _| project.languages().add(rust_lang())); let buffer_b = project_b .update(cx_b, |p, cx| { p.open_buffer((worktree_id, rel_path("src/main.rs")), cx) @@ -4066,8 +4068,7 @@ async fn test_collaborating_with_external_editorconfig( // Verify client B also sees external editorconfig settings cx_b.read(|cx| { - let file = buffer_b.read(cx).file(); - let settings = language_settings(Some("Rust".into()), file, cx); + let settings = LanguageSettings::for_buffer(&buffer_b.read(cx), cx); assert_eq!(Some(settings.tab_size), NonZeroU32::new(5)); }); @@ -4086,15 +4087,13 @@ async fn test_collaborating_with_external_editorconfig( // Verify client A sees updated settings cx_a.read(|cx| { - let file = buffer_a.read(cx).file(); - let settings = language_settings(Some("Rust".into()), file, cx); + let settings = LanguageSettings::for_buffer(&buffer_a.read(cx), cx); assert_eq!(Some(settings.tab_size), NonZeroU32::new(9)); }); // Verify client B also sees updated settings cx_b.read(|cx| { - let file = buffer_b.read(cx).file(); - let settings = language_settings(Some("Rust".into()), file, cx); + let settings = LanguageSettings::for_buffer(&buffer_b.read(cx), cx); assert_eq!(Some(settings.tab_size), NonZeroU32::new(9)); }); } diff --git a/crates/collab/tests/integration/randomized_test_helpers.rs b/crates/collab/tests/integration/randomized_test_helpers.rs index a6772019768ba19e2a92843a1e33b256f0eb8b0c..0a2555929a959fe04735ad7cb03595eea56c5cf5 100644 --- a/crates/collab/tests/integration/randomized_test_helpers.rs +++ b/crates/collab/tests/integration/randomized_test_helpers.rs @@ -191,7 +191,7 @@ pub async fn run_randomized_test( let settings = cx.remove_global::(); cx.clear_globals(); cx.set_global(settings); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); drop(client); }); executor.run_until_parked(); diff --git a/crates/collab/tests/integration/remote_editing_collaboration_tests.rs b/crates/collab/tests/integration/remote_editing_collaboration_tests.rs index 4c4f37489608be0313921be13cd9b09d5bf77c6d..fe93a06f7265d102d8727466c46e83daf066e506 100644 --- a/crates/collab/tests/integration/remote_editing_collaboration_tests.rs +++ b/crates/collab/tests/integration/remote_editing_collaboration_tests.rs @@ -14,7 +14,7 @@ use gpui::{ use http_client::BlockedHttpClient; use language::{ FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, LanguageRegistry, - language_settings::{Formatter, FormatterList, language_settings}, + language_settings::{Formatter, FormatterList, LanguageSettings}, rust_lang, tree_sitter_typescript, }; use node_runtime::NodeRuntime; @@ -91,6 +91,7 @@ async fn test_sharing_an_ssh_remote_project( 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 _headless_project = server_cx.new(|cx| { HeadlessProject::new( HeadlessAppState { @@ -121,6 +122,7 @@ async fn test_sharing_an_ssh_remote_project( // User B joins the project. let project_b = client_b.join_remote_project(project_id, cx_b).await; + project_b.update(cx_b, |project, _| project.languages().add(rust_lang())); let worktree_b = project_b .update(cx_b, |project, cx| project.worktree_for_id(worktree_id, cx)) .unwrap(); @@ -173,9 +175,8 @@ async fn test_sharing_an_ssh_remote_project( executor.run_until_parked(); cx_b.read(|cx| { - let file = buffer_b.read(cx).file(); assert_eq!( - language_settings(Some("Rust".into()), file, cx).language_servers, + LanguageSettings::for_buffer(buffer_b.read(cx), cx).language_servers, ["override-rust-analyzer".to_string()] ) }); @@ -1284,9 +1285,8 @@ async fn test_ssh_remote_worktree_trust(cx_a: &mut TestAppContext, server_cx: &m 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, + LanguageSettings::for_buffer(buffer_before_approval.read(cx), cx).language_servers, ["...".to_string()], "remote .zed/settings.json must not sync before trust approval" ) @@ -1313,9 +1313,8 @@ async fn test_ssh_remote_worktree_trust(cx_a: &mut TestAppContext, server_cx: &m 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, + LanguageSettings::for_buffer(buffer_before_approval.read(cx), cx).language_servers, ["override-rust-analyzer".to_string()], "remote .zed/settings.json should sync after trust approval" ) diff --git a/crates/collab/tests/integration/test_server.rs b/crates/collab/tests/integration/test_server.rs index 7472bd01173eca007eb762bf6e7920c55489ae7d..cca48bea973f178000d24bddcbb73252c5657b53 100644 --- a/crates/collab/tests/integration/test_server.rs +++ b/crates/collab/tests/integration/test_server.rs @@ -173,7 +173,7 @@ impl TestServer { } let settings = SettingsStore::test(cx); cx.set_global(settings); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); release_channel::init(semver::Version::new(0, 0, 0), cx); }); @@ -341,7 +341,7 @@ impl TestServer { let os_keymap = "keymaps/default-macos.json"; cx.update(|cx| { - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); Project::init(&client, cx); client::init(&client, cx); editor::init(cx); diff --git a/crates/collab_ui/Cargo.toml b/crates/collab_ui/Cargo.toml index 498f3f0bd76e002797389a279a17849448e6e873..efcba05456955e308e5a00e938bf3092d894efeb 100644 --- a/crates/collab_ui/Cargo.toml +++ b/crates/collab_ui/Cargo.toml @@ -54,6 +54,7 @@ settings.workspace = true smallvec.workspace = true telemetry.workspace = true theme.workspace = true +theme_settings.workspace = true time.workspace = true time_format.workspace = true title_bar.workspace = true diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 34595e9440f518a23128e4a00ba909cec055b1e2..6a53e590586ec2353feafe267501619e8bbfcc71 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -23,13 +23,14 @@ use menu::{Cancel, Confirm, SecondaryConfirm, SelectNext, SelectPrevious}; use project::{Fs, Project}; use rpc::{ ErrorCode, ErrorExt, - proto::{self, ChannelVisibility, PeerId}, + proto::{self, ChannelVisibility, PeerId, reorder_channel::Direction}, }; use serde::{Deserialize, Serialize}; use settings::Settings; use smallvec::SmallVec; use std::{mem, sync::Arc}; -use theme::{ActiveTheme, ThemeSettings}; +use theme::ActiveTheme; +use theme_settings::ThemeSettings; use ui::{ Avatar, AvatarAvailabilityIndicator, ContextMenu, CopyButton, Facepile, HighlightedLabel, IconButtonShape, Indicator, ListHeader, ListItem, Tab, Tooltip, prelude::*, tooltip_container, @@ -61,6 +62,8 @@ actions!( /// /// Use `collab::OpenChannelNotes` to open the channel notes for the current call. OpenSelectedChannelNotes, + /// Toggles whether the selected channel is in the Favorites section. + ToggleSelectedChannelFavorite, /// Starts moving a channel to a new location. StartMoveChannel, /// Moves the selected item to the current location. @@ -237,7 +240,6 @@ impl ChannelEditingState { } pub struct CollabPanel { - width: Option, fs: Arc, focus_handle: FocusHandle, channel_clipboard: Option, @@ -263,13 +265,13 @@ pub struct CollabPanel { #[derive(Serialize, Deserialize)] struct SerializedCollabPanel { - width: Option, collapsed_channels: Option>, } #[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)] enum Section { ActiveCall, + FavoriteChannels, Channels, ChannelInvites, ContactRequests, @@ -304,6 +306,7 @@ enum ListEntry { channel: Arc, depth: usize, has_children: bool, + is_favorite: bool, // `None` when the channel is a parent of a matched channel. string_match: Option, }, @@ -371,7 +374,6 @@ impl CollabPanel { .detach(); let mut this = Self { - width: None, focus_handle: cx.focus_handle(), channel_clipboard: None, fs: workspace.app_state().fs.clone(), @@ -461,16 +463,34 @@ impl CollabPanel { let panel = CollabPanel::new(workspace, window, cx); if let Some(serialized_panel) = serialized_panel { panel.update(cx, |panel, cx| { - panel.width = serialized_panel.width.map(|w| w.round()); panel.collapsed_channels = serialized_panel .collapsed_channels - .unwrap_or_else(Vec::new) + .unwrap_or_default() .iter() .map(|cid| ChannelId(*cid)) .collect(); cx.notify(); }); } + + let favorites: Vec = KeyValueStore::global(cx) + .read_kvp("favorite_channels") + .ok() + .flatten() + .and_then(|json| serde_json::from_str::>(&json).ok()) + .unwrap_or_default() + .into_iter() + .map(ChannelId) + .collect(); + + if !favorites.is_empty() { + panel.update(cx, |panel, cx| { + panel.channel_store.update(cx, |store, cx| { + store.set_favorite_channel_ids(favorites, cx); + }); + }); + } + panel }) } @@ -492,19 +512,18 @@ impl CollabPanel { else { return; }; - let width = self.width; - let collapsed_channels = self.collapsed_channels.clone(); + let collapsed_channels = if self.collapsed_channels.is_empty() { + None + } else { + Some(self.collapsed_channels.iter().map(|id| id.0).collect()) + }; + let kvp = KeyValueStore::global(cx); self.pending_serialization = cx.background_spawn( async move { kvp.write_kvp( serialization_key, - serde_json::to_string(&SerializedCollabPanel { - width, - collapsed_channels: Some( - collapsed_channels.iter().map(|cid| cid.0).collect(), - ), - })?, + serde_json::to_string(&SerializedCollabPanel { collapsed_channels })?, ) .await?; anyhow::Ok(()) @@ -518,10 +537,8 @@ impl CollabPanel { } fn update_entries(&mut self, select_same_item: bool, cx: &mut Context) { - let channel_store = self.channel_store.read(cx); - let user_store = self.user_store.read(cx); let query = self.filter_editor.read(cx).text(cx); - let fg_executor = cx.foreground_executor(); + let fg_executor = cx.foreground_executor().clone(); let executor = cx.background_executor().clone(); let prev_selected_entry = self.selection.and_then(|ix| self.entries.get(ix).cloned()); @@ -547,7 +564,7 @@ impl CollabPanel { } // Populate the active user. - if let Some(user) = user_store.current_user() { + if let Some(user) = self.user_store.read(cx).current_user() { self.match_candidates.clear(); self.match_candidates .push(StringMatchCandidate::new(0, &user.github_login)); @@ -668,6 +685,56 @@ impl CollabPanel { let mut request_entries = Vec::new(); + let channel_store = self.channel_store.read(cx); + let user_store = self.user_store.read(cx); + + let favorite_ids = channel_store.favorite_channel_ids(); + if !favorite_ids.is_empty() { + let favorite_channels: Vec<_> = favorite_ids + .iter() + .filter_map(|id| channel_store.channel_for_id(*id)) + .collect(); + + self.match_candidates.clear(); + self.match_candidates.extend( + favorite_channels + .iter() + .enumerate() + .map(|(ix, channel)| StringMatchCandidate::new(ix, &channel.name)), + ); + + let matches = fg_executor.block_on(match_strings( + &self.match_candidates, + &query, + true, + true, + usize::MAX, + &Default::default(), + executor.clone(), + )); + + if !matches.is_empty() || query.is_empty() { + self.entries + .push(ListEntry::Header(Section::FavoriteChannels)); + + let matches_by_candidate: HashMap = + matches.iter().map(|mat| (mat.candidate_id, mat)).collect(); + + for (ix, channel) in favorite_channels.iter().enumerate() { + if !query.is_empty() && !matches_by_candidate.contains_key(&ix) { + continue; + } + self.entries.push(ListEntry::Channel { + channel: (*channel).clone(), + depth: 0, + has_children: false, + is_favorite: true, + string_match: matches_by_candidate.get(&ix).cloned().cloned(), + }); + } + } + } + self.entries.push(ListEntry::Header(Section::Channels)); if channel_store.channel_count() > 0 || self.channel_editing_state.is_some() { @@ -762,6 +829,7 @@ impl CollabPanel { channel: channel.clone(), depth, has_children: false, + is_favorite: false, string_match: matches_by_id.get(&channel.id).map(|mat| (*mat).clone()), }); self.entries @@ -778,6 +846,7 @@ impl CollabPanel { channel: channel.clone(), depth, has_children, + is_favorite: false, string_match: matches_by_id.get(&channel.id).map(|mat| (*mat).clone()), }); } @@ -929,13 +998,22 @@ impl CollabPanel { if select_same_item { if let Some(prev_selected_entry) = prev_selected_entry { - self.selection.take(); + let prev_selection = self.selection.take(); for (ix, entry) in self.entries.iter().enumerate() { if *entry == prev_selected_entry { self.selection = Some(ix); break; } } + if self.selection.is_none() { + self.selection = prev_selection.and_then(|prev_ix| { + if self.entries.is_empty() { + None + } else { + Some(prev_ix.min(self.entries.len() - 1)) + } + }); + } } } else { self.selection = self.selection.and_then(|prev_selection| { @@ -1365,6 +1443,18 @@ impl CollabPanel { window.handler_for(&this, move |this, _, cx| { this.copy_channel_notes_link(channel_id, cx) }), + ) + .separator() + .entry( + if self.is_channel_favorited(channel_id, cx) { + "Remove from Favorites" + } else { + "Add to Favorites" + }, + None, + window.handler_for(&this, move |this, _window, cx| { + this.toggle_favorite_channel(channel_id, cx) + }), ); let mut has_destructive_actions = false; @@ -1577,7 +1667,7 @@ impl CollabPanel { self.update_entries(false, cx); } - fn select_next(&mut self, _: &SelectNext, _: &mut Window, cx: &mut Context) { + pub fn select_next(&mut self, _: &SelectNext, _: &mut Window, cx: &mut Context) { let ix = self.selection.map_or(0, |ix| ix + 1); if ix < self.entries.len() { self.selection = Some(ix); @@ -1589,7 +1679,7 @@ impl CollabPanel { cx.notify(); } - fn select_previous(&mut self, _: &SelectPrevious, _: &mut Window, cx: &mut Context) { + pub fn select_previous(&mut self, _: &SelectPrevious, _: &mut Window, cx: &mut Context) { let ix = self.selection.take().unwrap_or(0); if ix > 0 { self.selection = Some(ix - 1); @@ -1614,7 +1704,8 @@ impl CollabPanel { Section::ActiveCall => Self::leave_call(window, cx), Section::Channels => self.new_root_channel(window, cx), Section::Contacts => self.toggle_contact_finder(window, cx), - Section::ContactRequests + Section::FavoriteChannels + | Section::ContactRequests | Section::Online | Section::Offline | Section::ChannelInvites => { @@ -1844,6 +1935,38 @@ impl CollabPanel { self.collapsed_channels.binary_search(&channel_id).is_ok() } + pub fn toggle_favorite_channel(&mut self, channel_id: ChannelId, cx: &mut Context) { + self.channel_store.update(cx, |store, cx| { + store.toggle_favorite_channel(channel_id, cx); + }); + self.persist_favorites(cx); + } + + fn is_channel_favorited(&self, channel_id: ChannelId, cx: &App) -> bool { + self.channel_store.read(cx).is_channel_favorited(channel_id) + } + + fn persist_favorites(&mut self, cx: &mut Context) { + let favorite_ids: Vec = self + .channel_store + .read(cx) + .favorite_channel_ids() + .iter() + .map(|id| id.0) + .collect(); + let kvp_store = KeyValueStore::global(cx); + self.pending_serialization = cx.background_spawn( + async move { + let json = serde_json::to_string(&favorite_ids)?; + kvp_store + .write_kvp("favorite_channels".to_string(), json) + .await?; + anyhow::Ok(()) + } + .log_err(), + ); + } + fn leave_call(window: &mut Window, cx: &mut App) { ActiveCall::global(cx) .update(cx, |call, cx| call.hang_up(cx)) @@ -1960,6 +2083,17 @@ impl CollabPanel { } } + pub fn toggle_selected_channel_favorite( + &mut self, + _: &ToggleSelectedChannelFavorite, + _window: &mut Window, + cx: &mut Context, + ) { + if let Some(channel) = self.selected_channel() { + self.toggle_favorite_channel(channel.id, cx); + } + } + fn set_channel_visibility( &mut self, channel_id: ChannelId, @@ -2040,33 +2174,79 @@ impl CollabPanel { }) } - fn move_channel_up(&mut self, _: &MoveChannelUp, window: &mut Window, cx: &mut Context) { - if let Some(channel) = self.selected_channel() { - self.channel_store.update(cx, |store, cx| { - store - .reorder_channel(channel.id, proto::reorder_channel::Direction::Up, cx) - .detach_and_prompt_err("Failed to move channel up", window, cx, |_, _, _| None) - }); - } + pub fn move_channel_up( + &mut self, + _: &MoveChannelUp, + window: &mut Window, + cx: &mut Context, + ) { + self.reorder_selected_channel(Direction::Up, window, cx); } - fn move_channel_down( + pub fn move_channel_down( &mut self, _: &MoveChannelDown, window: &mut Window, cx: &mut Context, ) { - if let Some(channel) = self.selected_channel() { + self.reorder_selected_channel(Direction::Down, window, cx); + } + + fn reorder_selected_channel( + &mut self, + direction: Direction, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(channel) = self.selected_channel().cloned() { + if self.selected_entry_is_favorite() { + self.reorder_favorite(channel.id, direction, cx); + return; + } + self.channel_store.update(cx, |store, cx| { store - .reorder_channel(channel.id, proto::reorder_channel::Direction::Down, cx) - .detach_and_prompt_err("Failed to move channel down", window, cx, |_, _, _| { - None - }) + .reorder_channel(channel.id, direction, cx) + .detach_and_prompt_err( + match direction { + Direction::Up => "Failed to move channel up", + Direction::Down => "Failed to move channel down", + }, + window, + cx, + |_, _, _| None, + ) }); } } + pub fn reorder_favorite( + &mut self, + channel_id: ChannelId, + direction: Direction, + cx: &mut Context, + ) { + self.channel_store.update(cx, |store, cx| { + let favorite_ids = store.favorite_channel_ids(); + let Some(channel_index) = favorite_ids.iter().position(|id| *id == channel_id) else { + return; + }; + let target_channel_index = match direction { + Direction::Up => channel_index.checked_sub(1), + Direction::Down => { + let next = channel_index + 1; + (next < favorite_ids.len()).then_some(next) + } + }; + if let Some(target_channel_index) = target_channel_index { + let mut new_ids = favorite_ids.to_vec(); + new_ids.swap(channel_index, target_channel_index); + store.set_favorite_channel_ids(new_ids, cx); + } + }); + self.persist_favorites(cx); + } + fn open_channel_notes( &mut self, channel_id: ChannelId, @@ -2135,6 +2315,20 @@ impl CollabPanel { }) } + fn selected_entry_is_favorite(&self) -> bool { + self.selection + .and_then(|ix| self.entries.get(ix)) + .is_some_and(|entry| { + matches!( + entry, + ListEntry::Channel { + is_favorite: true, + .. + } + ) + }) + } + fn selected_contact(&self) -> Option> { self.selection .and_then(|ix| self.entries.get(ix)) @@ -2346,46 +2540,57 @@ impl CollabPanel { fn render_signed_out(&mut self, cx: &mut Context) -> Div { let collab_blurb = "Work with your team in realtime with collaborative editing, voice, shared notes and more."; - let is_signing_in = self.client.status().borrow().is_signing_in(); - let button_label = if is_signing_in { - "Signing in…" + + // Two distinct "not connected" states: + // - Authenticated (has credentials): user just needs to connect. + // - Unauthenticated (no credentials): user needs to sign in via GitHub. + let is_authenticated = self.client.user_id().is_some(); + let status = *self.client.status().borrow(); + let is_busy = status.is_signing_in(); + + let (button_id, button_label, button_icon) = if is_authenticated { + ( + "connect", + if is_busy { "Connecting…" } else { "Connect" }, + IconName::Public, + ) } else { - "Sign in" + ( + "sign_in", + if is_busy { + "Signing in…" + } else { + "Sign In with GitHub" + }, + IconName::Github, + ) }; v_flex() - .gap_6() .p_4() + .gap_4() + .size_full() + .text_center() + .justify_center() .child(Label::new(collab_blurb)) .child( - v_flex() - .gap_2() - .child( - Button::new("sign_in", button_label) - .start_icon(Icon::new(IconName::Github).color(Color::Muted)) - .style(ButtonStyle::Filled) - .full_width() - .disabled(is_signing_in) - .on_click(cx.listener(|this, _, window, cx| { - let client = this.client.clone(); - let workspace = this.workspace.clone(); - cx.spawn_in(window, async move |_, mut cx| { - client - .connect(true, &mut cx) - .await - .into_response() - .notify_workspace_async_err(workspace, &mut cx); - }) - .detach() - })), - ) - .child( - v_flex().w_full().items_center().child( - Label::new("Sign in to enable collaboration.") - .color(Color::Muted) - .size(LabelSize::Small), - ), - ), + Button::new(button_id, button_label) + .full_width() + .start_icon(Icon::new(button_icon).color(Color::Muted)) + .style(ButtonStyle::Outlined) + .disabled(is_busy) + .on_click(cx.listener(|this, _, window, cx| { + let client = this.client.clone(); + let workspace = this.workspace.clone(); + cx.spawn_in(window, async move |_, mut cx| { + client + .connect(true, &mut cx) + .await + .into_response() + .notify_workspace_async_err(workspace, &mut cx); + }) + .detach() + })), ) } @@ -2421,6 +2626,7 @@ impl CollabPanel { depth, has_children, string_match, + .. } => self .render_channel( channel, @@ -2584,6 +2790,7 @@ impl CollabPanel { SharedString::from("Current Call") } } + Section::FavoriteChannels => SharedString::from("Favorites"), Section::ContactRequests => SharedString::from("Requests"), Section::Contacts => SharedString::from("Contacts"), Section::Channels => SharedString::from("Channels"), @@ -2601,6 +2808,7 @@ impl CollabPanel { }), Section::Contacts => Some( IconButton::new("add-contact", IconName::Plus) + .icon_size(IconSize::Small) .on_click( cx.listener(|this, _, window, cx| this.toggle_contact_finder(window, cx)), ) @@ -2614,9 +2822,6 @@ impl CollabPanel { IconButton::new("filter-active-channels", IconName::ListFilter) .icon_size(IconSize::Small) .toggle_state(self.filter_active_channels) - .when(!self.filter_active_channels, |button| { - button.visible_on_hover("section-header") - }) .on_click(cx.listener(|this, _, _window, cx| { this.filter_active_channels = !this.filter_active_channels; this.update_entries(true, cx); @@ -2624,15 +2829,16 @@ impl CollabPanel { .tooltip(Tooltip::text(if self.filter_active_channels { "Show All Channels" } else { - "Show Active Channels" + "Show Occupied Channels" })), ) .child( IconButton::new("add-channel", IconName::Plus) + .icon_size(IconSize::Small) .on_click(cx.listener(|this, _, window, cx| { this.new_root_channel(window, cx) })) - .tooltip(Tooltip::text("Create a channel")), + .tooltip(Tooltip::text("Create Channel")), ) .into_any_element(), ) @@ -2641,7 +2847,11 @@ impl CollabPanel { }; let can_collapse = match section { - Section::ActiveCall | Section::Channels | Section::Contacts => false, + Section::ActiveCall + | Section::Channels + | Section::Contacts + | Section::FavoriteChannels => false, + Section::ChannelInvites | Section::ContactRequests | Section::Online @@ -2915,14 +3125,28 @@ impl CollabPanel { Some(result) }; - let width = self.width.unwrap_or(px(240.)); + let width = self + .workspace + .read_with(cx, |workspace, cx| { + workspace + .panel_size_state::(cx) + .and_then(|size_state| size_state.size) + }) + .ok() + .flatten() + .unwrap_or(px(240.)); let root_id = channel.root_id(); - div() - .h_6() - .id(channel_id.0 as usize) + let is_favorited = self.is_channel_favorited(channel_id, cx); + let (favorite_icon, favorite_color, favorite_tooltip) = if is_favorited { + (IconName::StarFilled, Color::Accent, "Remove from Favorites") + } else { + (IconName::Star, Color::Default, "Add to Favorites") + }; + + h_flex() + .id(ix) .group("") - .flex() .w_full() .when(!channel.is_root_channel(), |el| { el.on_drag(channel.clone(), move |channel, _, _, cx| { @@ -2950,7 +3174,7 @@ impl CollabPanel { }), ) .child( - ListItem::new(channel_id.0 as usize) + ListItem::new(ix) // Add one level of depth for the disclosure arrow. .indent_level(depth + 1) .indent_step_size(px(20.)) @@ -2977,78 +3201,101 @@ impl CollabPanel { ) }, )) - .start_slot( - div() - .relative() - .child( - Icon::new(if is_public { - IconName::Public - } else { - IconName::Hash - }) - .size(IconSize::Small) - .color(Color::Muted), - ) - .children(has_notes_notification.then(|| { - div() - .w_1p5() - .absolute() - .right(px(-1.)) - .top(px(-1.)) - .child(Indicator::dot().color(Color::Info)) - })), - ) .child( h_flex() - .id(channel_id.0 as usize) - .child(match string_match { - None => Label::new(channel.name.clone()).into_any_element(), - Some(string_match) => HighlightedLabel::new( - channel.name.clone(), - string_match.positions.clone(), - ) - .into_any_element(), - }) - .children(face_pile.map(|face_pile| face_pile.p_1())), + .id(format!("inside-{}", channel_id.0)) + .w_full() + .gap_1() + .child( + div() + .relative() + .child( + Icon::new(if is_public { + IconName::Public + } else { + IconName::Hash + }) + .size(IconSize::Small) + .color(Color::Muted), + ) + .children(has_notes_notification.then(|| { + div() + .w_1p5() + .absolute() + .right(px(-1.)) + .top(px(-1.)) + .child(Indicator::dot().color(Color::Info)) + })), + ) + .child( + h_flex() + .id(channel_id.0 as usize) + .child(match string_match { + None => Label::new(channel.name.clone()).into_any_element(), + Some(string_match) => HighlightedLabel::new( + channel.name.clone(), + string_match.positions.clone(), + ) + .into_any_element(), + }) + .children(face_pile.map(|face_pile| face_pile.p_1())), + ) + .tooltip({ + let channel_store = self.channel_store.clone(); + move |_window, cx| { + cx.new(|_| JoinChannelTooltip { + channel_store: channel_store.clone(), + channel_id, + has_notes_notification, + }) + .into() + } + }), ), ) .child( - h_flex().absolute().right(rems(0.)).h_full().child( - h_flex() - .h_full() - .bg(cx.theme().colors().background) - .rounded_l_sm() - .gap_1() - .px_1() - .child( - IconButton::new("channel_notes", IconName::Reader) - .style(ButtonStyle::Filled) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::Small) - .icon_color(if has_notes_notification { - Color::Default - } else { - Color::Muted - }) - .on_click(cx.listener(move |this, _, window, cx| { - this.open_channel_notes(channel_id, window, cx) - })) - .tooltip(Tooltip::text("Open channel notes")), - ) - .visible_on_hover(""), - ), - ) - .tooltip({ - let channel_store = self.channel_store.clone(); - move |_window, cx| { - cx.new(|_| JoinChannelTooltip { - channel_store: channel_store.clone(), - channel_id, - has_notes_notification, + h_flex() + .visible_on_hover("") + .absolute() + .right_0() + .px_1() + .gap_px() + .bg(cx.theme().colors().background) + .rounded_l_md() + .child({ + let focus_handle = self.focus_handle.clone(); + IconButton::new("channel_favorite", favorite_icon) + .icon_size(IconSize::Small) + .icon_color(favorite_color) + .on_click(cx.listener(move |this, _, _window, cx| { + this.toggle_favorite_channel(channel_id, cx) + })) + .tooltip(move |_window, cx| { + Tooltip::for_action_in( + favorite_tooltip, + &ToggleSelectedChannelFavorite, + &focus_handle, + cx, + ) + }) }) - .into() - } - }) + .child({ + let focus_handle = self.focus_handle.clone(); + IconButton::new("channel_notes", IconName::Reader) + .icon_size(IconSize::Small) + .on_click(cx.listener(move |this, _, window, cx| { + this.open_channel_notes(channel_id, window, cx) + })) + .tooltip(move |_window, cx| { + Tooltip::for_action_in( + "Open Channel Notes", + &OpenSelectedChannelNotes, + &focus_handle, + cx, + ) + }) + }), + ) } fn render_channel_editor( @@ -3147,6 +3394,7 @@ impl Render for CollabPanel { .on_action(cx.listener(CollabPanel::show_inline_context_menu)) .on_action(cx.listener(CollabPanel::rename_selected_channel)) .on_action(cx.listener(CollabPanel::open_selected_channel_notes)) + .on_action(cx.listener(CollabPanel::toggle_selected_channel_favorite)) .on_action(cx.listener(CollabPanel::collapse_selected_channel)) .on_action(cx.listener(CollabPanel::expand_selected_channel)) .on_action(cx.listener(CollabPanel::start_move_selected_channel)) @@ -3193,17 +3441,8 @@ impl Panel for CollabPanel { }); } - fn size(&self, _window: &Window, cx: &App) -> Pixels { - self.width - .unwrap_or_else(|| CollaborationPanelSettings::get_global(cx).default_width) - } - - fn set_size(&mut self, size: Option, window: &mut Window, cx: &mut Context) { - self.width = size; - cx.notify(); - cx.defer_in(window, |this, _, cx| { - this.serialize(cx); - }); + fn default_size(&self, _window: &Window, cx: &App) -> Pixels { + CollaborationPanelSettings::get_global(cx).default_width } fn icon(&self, _window: &Window, cx: &App) -> Option { @@ -3229,7 +3468,7 @@ impl Panel for CollabPanel { } fn activation_priority(&self) -> u32 { - 6 + 5 } } @@ -3275,13 +3514,17 @@ impl PartialEq for ListEntry { } } ListEntry::Channel { - channel: channel_1, .. + channel: channel_1, + is_favorite: is_favorite_1, + .. } => { if let ListEntry::Channel { - channel: channel_2, .. + channel: channel_2, + is_favorite: is_favorite_2, + .. } = other { - return channel_1.id == channel_2.id; + return channel_1.id == channel_2.id && is_favorite_1 == is_favorite_2; } } ListEntry::ChannelNotes { channel_id } => { @@ -3377,7 +3620,7 @@ impl Render for JoinChannelTooltip { .channel_participants(self.channel_id); container - .child(Label::new("Join channel")) + .child(Label::new("Join Channel")) .children(participants.iter().map(|participant| { h_flex() .gap_2() @@ -3387,3 +3630,91 @@ impl Render for JoinChannelTooltip { }) } } + +#[cfg(any(test, feature = "test-support"))] +impl CollabPanel { + pub fn entries_as_strings(&self) -> Vec { + let mut string_entries = Vec::new(); + for (index, entry) in self.entries.iter().enumerate() { + let selected_marker = if self.selection == Some(index) { + " <== selected" + } else { + "" + }; + match entry { + ListEntry::Header(section) => { + let name = match section { + Section::ActiveCall => "Active Call", + Section::FavoriteChannels => "Favorites", + Section::Channels => "Channels", + Section::ChannelInvites => "Channel Invites", + Section::ContactRequests => "Contact Requests", + Section::Contacts => "Contacts", + Section::Online => "Online", + Section::Offline => "Offline", + }; + string_entries.push(format!("[{name}]")); + } + ListEntry::Channel { + channel, + depth, + has_children, + .. + } => { + let indent = " ".repeat(*depth + 1); + let icon = if *has_children { + "v " + } else if channel.visibility == proto::ChannelVisibility::Public { + "🛜 " + } else { + "#️⃣ " + }; + string_entries.push(format!("{indent}{icon}{}{selected_marker}", channel.name)); + } + ListEntry::ChannelNotes { .. } => { + string_entries.push(format!(" (notes){selected_marker}")); + } + ListEntry::ChannelEditor { depth } => { + let indent = " ".repeat(*depth + 1); + string_entries.push(format!("{indent}[editor]{selected_marker}")); + } + ListEntry::ChannelInvite(channel) => { + string_entries.push(format!(" (invite) #{}{selected_marker}", channel.name)); + } + ListEntry::CallParticipant { user, .. } => { + string_entries.push(format!(" {}{selected_marker}", user.github_login)); + } + ListEntry::ParticipantProject { + worktree_root_names, + .. + } => { + string_entries.push(format!( + " {}{selected_marker}", + worktree_root_names.join(", ") + )); + } + ListEntry::ParticipantScreen { .. } => { + string_entries.push(format!(" (screen){selected_marker}")); + } + ListEntry::IncomingRequest(user) => { + string_entries.push(format!( + " (incoming) {}{selected_marker}", + user.github_login + )); + } + ListEntry::OutgoingRequest(user) => { + string_entries.push(format!( + " (outgoing) {}{selected_marker}", + user.github_login + )); + } + ListEntry::Contact { contact, .. } => { + string_entries + .push(format!(" {}{selected_marker}", contact.user.github_login)); + } + ListEntry::ContactPlaceholder => {} + } + } + string_entries + } +} diff --git a/crates/collab_ui/src/notification_panel.rs b/crates/collab_ui/src/notification_panel.rs index 4374349b15f1c8e6404c61648fed720550e31a3e..d7fef4873c687ab23a25b3144ba902cf4c42c137 100644 --- a/crates/collab_ui/src/notification_panel.rs +++ b/crates/collab_ui/src/notification_panel.rs @@ -3,7 +3,6 @@ use anyhow::Result; use channel::ChannelStore; use client::{ChannelId, Client, Notification, User, UserStore}; use collections::HashMap; -use db::kvp::KeyValueStore; use futures::StreamExt; use gpui::{ AnyElement, App, AsyncWindowContext, ClickEvent, Context, DismissEvent, Element, Entity, @@ -14,14 +13,14 @@ use gpui::{ use notifications::{NotificationEntry, NotificationEvent, NotificationStore}; use project::Fs; use rpc::proto; -use serde::{Deserialize, Serialize}; + use settings::{Settings, SettingsStore}; use std::{sync::Arc, time::Duration}; use time::{OffsetDateTime, UtcOffset}; use ui::{ Avatar, Button, Icon, IconButton, IconName, Label, Tab, Tooltip, h_flex, prelude::*, v_flex, }; -use util::{ResultExt, TryFutureExt}; +use util::ResultExt; use workspace::notifications::{ Notification as WorkspaceNotification, NotificationId, SuppressEvent, }; @@ -41,10 +40,8 @@ pub struct NotificationPanel { channel_store: Entity, notification_store: Entity, fs: Arc, - width: Option, active: bool, notification_list: ListState, - pending_serialization: Task>, subscriptions: Vec, workspace: WeakEntity, current_notification_toast: Option<(u64, Task<()>)>, @@ -54,11 +51,6 @@ pub struct NotificationPanel { unseen_notifications: Vec, } -#[derive(Serialize, Deserialize)] -struct SerializedNotificationPanel { - width: Option, -} - #[derive(Debug)] pub enum Event { DockPositionChanged, @@ -146,15 +138,13 @@ impl NotificationPanel { channel_store: ChannelStore::global(cx), notification_store: NotificationStore::global(cx), notification_list, - pending_serialization: Task::ready(None), workspace: workspace_handle, focus_handle: cx.focus_handle(), + subscriptions: Default::default(), current_notification_toast: None, - subscriptions: Vec::new(), active: false, - mark_as_read_tasks: HashMap::default(), - width: None, - unseen_notifications: Vec::new(), + mark_as_read_tasks: Default::default(), + unseen_notifications: Default::default(), }; let mut old_dock_position = this.position(window, cx); @@ -186,43 +176,10 @@ impl NotificationPanel { cx: AsyncWindowContext, ) -> Task>> { cx.spawn(async move |cx| { - let kvp = cx.update(|_, cx| KeyValueStore::global(cx))?; - let serialized_panel = - if let Some(panel) = kvp.read_kvp(NOTIFICATION_PANEL_KEY).log_err().flatten() { - Some(serde_json::from_str::(&panel)?) - } else { - None - }; - - workspace.update_in(cx, |workspace, window, cx| { - let panel = Self::new(workspace, window, cx); - if let Some(serialized_panel) = serialized_panel { - panel.update(cx, |panel, cx| { - panel.width = serialized_panel.width.map(|w| w.round()); - cx.notify(); - }); - } - panel - }) + workspace.update_in(cx, |workspace, window, cx| Self::new(workspace, window, cx)) }) } - fn serialize(&mut self, cx: &mut Context) { - let width = self.width; - let kvp = KeyValueStore::global(cx); - self.pending_serialization = cx.background_spawn( - async move { - kvp.write_kvp( - NOTIFICATION_PANEL_KEY.into(), - serde_json::to_string(&SerializedNotificationPanel { width })?, - ) - .await?; - anyhow::Ok(()) - } - .log_err(), - ); - } - fn render_notification( &mut self, ix: usize, @@ -632,15 +589,8 @@ impl Panel for NotificationPanel { }); } - fn size(&self, _: &Window, cx: &App) -> Pixels { - self.width - .unwrap_or_else(|| NotificationPanelSettings::get_global(cx).default_width) - } - - fn set_size(&mut self, size: Option, _: &mut Window, cx: &mut Context) { - self.width = size; - self.serialize(cx); - cx.notify(); + fn default_size(&self, _: &Window, cx: &App) -> Pixels { + NotificationPanelSettings::get_global(cx).default_width } fn set_active(&mut self, active: bool, _: &mut Window, cx: &mut Context) { @@ -690,7 +640,7 @@ impl Panel for NotificationPanel { } fn activation_priority(&self) -> u32 { - 8 + 4 } } diff --git a/crates/collab_ui/src/notifications/incoming_call_notification.rs b/crates/collab_ui/src/notifications/incoming_call_notification.rs index 164b91395a8853c330e2f7842b5676fff0916e63..71940794f4180e18d54a8b2ff258d37642c1e83b 100644 --- a/crates/collab_ui/src/notifications/incoming_call_notification.rs +++ b/crates/collab_ui/src/notifications/incoming_call_notification.rs @@ -111,7 +111,7 @@ impl IncomingCallNotification { impl Render for IncomingCallNotification { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let ui_font = theme::setup_ui_font(window, cx); + let ui_font = theme_settings::setup_ui_font(window, cx); div().size_full().font(ui_font).child( CollabNotification::new( diff --git a/crates/collab_ui/src/notifications/project_shared_notification.rs b/crates/collab_ui/src/notifications/project_shared_notification.rs index 165e46458438850f872794d057c17faee86775e2..3c231c5397af23656cc914e71269bdfff52d4af1 100644 --- a/crates/collab_ui/src/notifications/project_shared_notification.rs +++ b/crates/collab_ui/src/notifications/project_shared_notification.rs @@ -120,7 +120,7 @@ impl ProjectSharedNotification { impl Render for ProjectSharedNotification { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let ui_font = theme::setup_ui_font(window, cx); + let ui_font = theme_settings::setup_ui_font(window, cx); let no_worktree_root_names = self.worktree_root_names.is_empty(); let punctuation = if no_worktree_root_names { "" } else { ":" }; diff --git a/crates/collections/Cargo.toml b/crates/collections/Cargo.toml index 8675504347f171397ea7372841cb00b7959eafe3..aa3dd899a7222f38377ea5f62927eea23534d1d8 100644 --- a/crates/collections/Cargo.toml +++ b/crates/collections/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition.workspace = true publish = false license = "Apache-2.0" -description = "Standard collection type re-exports used by Zed and GPUI" +description = "Standard collection types used by Zed and GPUI" [lints] workspace = true diff --git a/crates/collections/src/collections.rs b/crates/collections/src/collections.rs index ea5ea7332fb14e5e2ac33ba2d6f957dbfdc28c7a..8e6c334d2bd5d544f36666184df5fe095c3fdbe1 100644 --- a/crates/collections/src/collections.rs +++ b/crates/collections/src/collections.rs @@ -7,3 +7,7 @@ pub use indexmap::Equivalent; pub use rustc_hash::FxHasher; pub use rustc_hash::{FxHashMap, FxHashSet}; pub use std::collections::*; + +pub mod vecmap; +#[cfg(test)] +mod vecmap_tests; diff --git a/crates/collections/src/vecmap.rs b/crates/collections/src/vecmap.rs new file mode 100644 index 0000000000000000000000000000000000000000..bec6596b924742daf4e1da3831f1182557875d61 --- /dev/null +++ b/crates/collections/src/vecmap.rs @@ -0,0 +1,192 @@ +/// A collection that provides a map interface but is backed by vectors. +/// +/// This is suitable for small key-value stores where the item count is not +/// large enough to overcome the overhead of a more complex algorithm. +/// +/// If this meets your use cases, then [`VecMap`] should be a drop-in +/// replacement for [`std::collections::HashMap`] or [`crate::HashMap`]. Note +/// that we are adding APIs on an as-needed basis. If the API you need is not +/// present yet, please add it! +/// +/// Because it uses vectors as a backing store, the map also iterates over items +/// in insertion order, like [`crate::IndexMap`]. +/// +/// This struct uses a struct-of-arrays (SoA) representation which tends to be +/// more cache efficient and promotes autovectorization when using simple key or +/// value types. +#[derive(Default)] +pub struct VecMap { + keys: Vec, + values: Vec, +} + +impl VecMap { + pub fn new() -> Self { + Self { + keys: Vec::new(), + values: Vec::new(), + } + } + + pub fn iter(&self) -> Iter<'_, K, V> { + Iter { + iter: self.keys.iter().zip(self.values.iter()), + } + } +} + +impl VecMap { + pub fn entry(&mut self, key: K) -> Entry<'_, K, V> { + match self.keys.iter().position(|k| k == &key) { + Some(index) => Entry::Occupied(OccupiedEntry { + key: &self.keys[index], + value: &mut self.values[index], + }), + None => Entry::Vacant(VacantEntry { map: self, key }), + } + } + + /// Like [`Self::entry`] but takes its key by reference instead of by value. + /// + /// This can be helpful if you have a key where cloning is expensive, as we + /// can avoid cloning the key until a value is inserted under that entry. + pub fn entry_ref<'a, 'k>(&'a mut self, key: &'k K) -> EntryRef<'k, 'a, K, V> { + match self.keys.iter().position(|k| k == key) { + Some(index) => EntryRef::Occupied(OccupiedEntry { + key: &self.keys[index], + value: &mut self.values[index], + }), + None => EntryRef::Vacant(VacantEntryRef { map: self, key }), + } + } +} + +pub struct Iter<'a, K, V> { + iter: std::iter::Zip, std::slice::Iter<'a, V>>, +} + +impl<'a, K, V> Iterator for Iter<'a, K, V> { + type Item = (&'a K, &'a V); + + fn next(&mut self) -> Option { + self.iter.next() + } +} + +pub enum Entry<'a, K, V> { + Occupied(OccupiedEntry<'a, K, V>), + Vacant(VacantEntry<'a, K, V>), +} + +impl<'a, K, V> Entry<'a, K, V> { + pub fn key(&self) -> &K { + match self { + Entry::Occupied(entry) => entry.key, + Entry::Vacant(entry) => &entry.key, + } + } + + pub fn or_insert_with_key(self, default: F) -> &'a mut V + where + F: FnOnce(&K) -> V, + { + match self { + Entry::Occupied(entry) => entry.value, + Entry::Vacant(entry) => { + entry.map.values.push(default(&entry.key)); + entry.map.keys.push(entry.key); + match entry.map.values.last_mut() { + Some(value) => value, + None => unreachable!("vec empty after pushing to it"), + } + } + } + } + + pub fn or_insert_with(self, default: F) -> &'a mut V + where + F: FnOnce() -> V, + { + self.or_insert_with_key(|_| default()) + } + + pub fn or_insert(self, value: V) -> &'a mut V { + self.or_insert_with_key(|_| value) + } + + pub fn or_insert_default(self) -> &'a mut V + where + V: Default, + { + self.or_insert_with_key(|_| Default::default()) + } +} + +pub struct OccupiedEntry<'a, K, V> { + key: &'a K, + value: &'a mut V, +} + +pub struct VacantEntry<'a, K, V> { + map: &'a mut VecMap, + key: K, +} + +pub enum EntryRef<'key, 'map, K, V> { + Occupied(OccupiedEntry<'map, K, V>), + Vacant(VacantEntryRef<'key, 'map, K, V>), +} + +impl<'key, 'map, K, V> EntryRef<'key, 'map, K, V> { + pub fn key(&self) -> &K { + match self { + EntryRef::Occupied(entry) => entry.key, + EntryRef::Vacant(entry) => entry.key, + } + } +} + +impl<'key, 'map, K, V> EntryRef<'key, 'map, K, V> +where + K: Clone, +{ + pub fn or_insert_with_key(self, default: F) -> &'map mut V + where + F: FnOnce(&K) -> V, + { + match self { + EntryRef::Occupied(entry) => entry.value, + EntryRef::Vacant(entry) => { + entry.map.values.push(default(entry.key)); + entry.map.keys.push(entry.key.clone()); + match entry.map.values.last_mut() { + Some(value) => value, + None => unreachable!("vec empty after pushing to it"), + } + } + } + } + + pub fn or_insert_with(self, default: F) -> &'map mut V + where + F: FnOnce() -> V, + { + self.or_insert_with_key(|_| default()) + } + + pub fn or_insert(self, value: V) -> &'map mut V { + self.or_insert_with_key(|_| value) + } + + pub fn or_insert_default(self) -> &'map mut V + where + V: Default, + { + self.or_insert_with_key(|_| Default::default()) + } +} + +pub struct VacantEntryRef<'key, 'map, K, V> { + map: &'map mut VecMap, + key: &'key K, +} diff --git a/crates/collections/src/vecmap_tests.rs b/crates/collections/src/vecmap_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..1f698f8331cc5044f23c19603005e253f8a81ef3 --- /dev/null +++ b/crates/collections/src/vecmap_tests.rs @@ -0,0 +1,211 @@ +//! Tests for the VecMap collection. +//! +//! This is in a sibling module so that the tests are guaranteed to only cover +//! states that can be created by the public API. + +use crate::vecmap::*; + +#[test] +fn test_entry_vacant_or_insert() { + let mut map: VecMap<&str, i32> = VecMap::new(); + let value = map.entry("a").or_insert(1); + assert_eq!(*value, 1); + assert_eq!(map.iter().collect::>(), vec![(&"a", &1)]); +} + +#[test] +fn test_entry_occupied_or_insert_keeps_existing() { + let mut map: VecMap<&str, i32> = VecMap::new(); + map.entry("a").or_insert(1); + let value = map.entry("a").or_insert(99); + assert_eq!(*value, 1); + assert_eq!(map.iter().collect::>(), vec![(&"a", &1)]); +} + +#[test] +fn test_entry_or_insert_with() { + let mut map: VecMap<&str, i32> = VecMap::new(); + map.entry("a").or_insert_with(|| 42); + assert_eq!(map.iter().collect::>(), vec![(&"a", &42)]); +} + +#[test] +fn test_entry_or_insert_with_not_called_when_occupied() { + let mut map: VecMap<&str, i32> = VecMap::new(); + map.entry("a").or_insert(1); + map.entry("a") + .or_insert_with(|| panic!("should not be called")); + assert_eq!(map.iter().collect::>(), vec![(&"a", &1)]); +} + +#[test] +fn test_entry_or_insert_with_key() { + let mut map: VecMap<&str, String> = VecMap::new(); + map.entry("hello").or_insert_with_key(|k| k.to_uppercase()); + assert_eq!( + map.iter().collect::>(), + vec![(&"hello", &"HELLO".to_string())] + ); +} + +#[test] +fn test_entry_or_insert_default() { + let mut map: VecMap<&str, i32> = VecMap::new(); + map.entry("a").or_insert_default(); + assert_eq!(map.iter().collect::>(), vec![(&"a", &0)]); +} + +#[test] +fn test_entry_key() { + let mut map: VecMap<&str, i32> = VecMap::new(); + assert_eq!(*map.entry("a").key(), "a"); + map.entry("a").or_insert(1); + assert_eq!(*map.entry("a").key(), "a"); +} + +#[test] +fn test_entry_mut_ref_can_be_updated() { + let mut map: VecMap<&str, i32> = VecMap::new(); + let value = map.entry("a").or_insert(0); + *value = 5; + assert_eq!(map.iter().collect::>(), vec![(&"a", &5)]); +} + +#[test] +fn test_insertion_order_preserved() { + let mut map: VecMap<&str, i32> = VecMap::new(); + map.entry("b").or_insert(2); + map.entry("a").or_insert(1); + map.entry("c").or_insert(3); + assert_eq!( + map.iter().collect::>(), + vec![(&"b", &2), (&"a", &1), (&"c", &3)] + ); +} + +#[test] +fn test_multiple_entries_independent() { + let mut map: VecMap = VecMap::new(); + map.entry(1).or_insert(10); + map.entry(2).or_insert(20); + map.entry(3).or_insert(30); + assert_eq!(map.iter().count(), 3); + // Re-inserting does not duplicate keys + map.entry(1).or_insert(99); + assert_eq!(map.iter().count(), 3); +} + +// entry_ref tests + +use std::cell::Cell; +use std::rc::Rc; + +#[derive(PartialEq, Eq)] +struct CountedKey { + value: String, + clone_count: Rc>, +} + +impl Clone for CountedKey { + fn clone(&self) -> Self { + self.clone_count.set(self.clone_count.get() + 1); + CountedKey { + value: self.value.clone(), + clone_count: self.clone_count.clone(), + } + } +} + +#[test] +fn test_entry_ref_vacant_or_insert() { + let mut map: VecMap = VecMap::new(); + let key = "a".to_string(); + let value = map.entry_ref(&key).or_insert(1); + assert_eq!(*value, 1); + assert_eq!(map.iter().count(), 1); +} + +#[test] +fn test_entry_ref_occupied_or_insert_keeps_existing() { + let mut map: VecMap = VecMap::new(); + map.entry_ref(&"a".to_string()).or_insert(1); + let value = map.entry_ref(&"a".to_string()).or_insert(99); + assert_eq!(*value, 1); + assert_eq!(map.iter().count(), 1); +} + +#[test] +fn test_entry_ref_key_not_cloned_when_occupied() { + let clone_count = Rc::new(Cell::new(0)); + let key = CountedKey { + value: "a".to_string(), + clone_count: clone_count.clone(), + }; + + let mut map: VecMap = VecMap::new(); + map.entry_ref(&key).or_insert(1); + let clones_after_insert = clone_count.get(); + + // Looking up an existing key must not clone it. + map.entry_ref(&key).or_insert(99); + assert_eq!(clone_count.get(), clones_after_insert); +} + +#[test] +fn test_entry_ref_key_cloned_exactly_once_on_vacant_insert() { + let clone_count = Rc::new(Cell::new(0)); + let key = CountedKey { + value: "a".to_string(), + clone_count: clone_count.clone(), + }; + + let mut map: VecMap = VecMap::new(); + map.entry_ref(&key).or_insert(1); + assert_eq!(clone_count.get(), 1); +} + +#[test] +fn test_entry_ref_or_insert_with_key() { + let mut map: VecMap = VecMap::new(); + let key = "hello".to_string(); + map.entry_ref(&key).or_insert_with_key(|k| k.to_uppercase()); + assert_eq!( + map.iter().collect::>(), + vec![(&"hello".to_string(), &"HELLO".to_string())] + ); +} + +#[test] +fn test_entry_ref_or_insert_with_not_called_when_occupied() { + let mut map: VecMap = VecMap::new(); + let key = "a".to_string(); + map.entry_ref(&key).or_insert(1); + map.entry_ref(&key) + .or_insert_with(|| panic!("should not be called")); + assert_eq!(map.iter().collect::>(), vec![(&key, &1)]); +} + +#[test] +fn test_entry_ref_or_insert_default() { + let mut map: VecMap = VecMap::new(); + map.entry_ref(&"a".to_string()).or_insert_default(); + assert_eq!(map.iter().collect::>(), vec![(&"a".to_string(), &0)]); +} + +#[test] +fn test_entry_ref_key() { + let mut map: VecMap = VecMap::new(); + let key = "a".to_string(); + assert_eq!(*map.entry_ref(&key).key(), key); + map.entry_ref(&key).or_insert(1); + assert_eq!(*map.entry_ref(&key).key(), key); +} + +#[test] +fn test_entry_ref_mut_ref_can_be_updated() { + let mut map: VecMap = VecMap::new(); + let key = "a".to_string(); + let value = map.entry_ref(&key).or_insert(0); + *value = 5; + assert_eq!(map.iter().collect::>(), vec![(&key, &5)]); +} diff --git a/crates/command_palette/Cargo.toml b/crates/command_palette/Cargo.toml index 96be6cb9ee2b767bc14503cbae7e2de6838e6724..df9da6f67e5c2c2e7d91b2ece0245c352e4190b7 100644 --- a/crates/command_palette/Cargo.toml +++ b/crates/command_palette/Cargo.toml @@ -49,3 +49,4 @@ menu.workspace = true project = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } +theme_settings.workspace = true \ No newline at end of file diff --git a/crates/command_palette/src/command_palette.rs b/crates/command_palette/src/command_palette.rs index 579946f30d88db379f6649fd65b13d7d291e19de..90ed7d0d3518aa4f6d49bb4cc18cbf3c275ce7c5 100644 --- a/crates/command_palette/src/command_palette.rs +++ b/crates/command_palette/src/command_palette.rs @@ -931,7 +931,7 @@ mod tests { fn init_test(cx: &mut TestAppContext) -> Arc { cx.update(|cx| { let app_state = AppState::test(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); menu::init(); go_to_line::init(cx); diff --git a/crates/component_preview/Cargo.toml b/crates/component_preview/Cargo.toml index 3bfbdcf2979ebca34a80c9d8703813c40a20387b..4a3cde33631e2da7839d93267de0afe94a7a62c7 100644 --- a/crates/component_preview/Cargo.toml +++ b/crates/component_preview/Cargo.toml @@ -33,6 +33,7 @@ reqwest_client.workspace = true session.workspace = true settings.workspace = true theme.workspace = true +theme_settings.workspace = true ui.workspace = true ui_input.workspace = true uuid.workspace = true diff --git a/crates/component_preview/examples/component_preview.rs b/crates/component_preview/examples/component_preview.rs index 99222a9ffd47222eb11375b2277bd7ee4e6c7a94..8deaff1a8a61a404f482ac30f071164807267f5b 100644 --- a/crates/component_preview/examples/component_preview.rs +++ b/crates/component_preview/examples/component_preview.rs @@ -39,7 +39,7 @@ fn main() { ::set_global(fs.clone(), cx); settings::init(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); let languages = Arc::new(LanguageRegistry::new(cx.background_executor().clone())); let client = Client::production(cx); @@ -65,7 +65,7 @@ fn main() { node_runtime, session, }); - AppState::set_global(Arc::downgrade(&app_state), cx); + AppState::set_global(app_state.clone(), cx); workspace::init(app_state.clone(), cx); init(app_state.clone(), cx); @@ -81,7 +81,7 @@ fn main() { { move |window, cx| { let app_state = app_state; - theme::setup_ui_font(window, cx); + theme_settings::setup_ui_font(window, cx); let project = Project::local( app_state.client.clone(), diff --git a/crates/copilot/Cargo.toml b/crates/copilot/Cargo.toml index d625c998b034a249cb3f498ae1fdd4e0e179a4cc..4d2ffde10c783d4fdbbad29b1fcd497cdfc30ced 100644 --- a/crates/copilot/Cargo.toml +++ b/crates/copilot/Cargo.toml @@ -68,3 +68,4 @@ settings = { workspace = true, features = ["test-support"] } theme = { workspace = true, features = ["test-support"] } util = { workspace = true, features = ["test-support"] } zlog.workspace = true +theme_settings.workspace = true diff --git a/crates/copilot/src/copilot_edit_prediction_delegate.rs b/crates/copilot/src/copilot_edit_prediction_delegate.rs index 2d5b387479f380f66519a07468a88929d1c5cc55..6f69bc6bc7bea4ec31aa59262a4abc5640999a2e 100644 --- a/crates/copilot/src/copilot_edit_prediction_delegate.rs +++ b/crates/copilot/src/copilot_edit_prediction_delegate.rs @@ -1120,7 +1120,7 @@ mod tests { cx.update(|cx| { let store = SettingsStore::test(cx); cx.set_global(store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); SettingsStore::update_global(cx, |store: &mut SettingsStore, cx| { store.update_user_settings(cx, |settings| f(&mut settings.project.all_languages)); }); diff --git a/crates/copilot_chat/src/copilot_chat.rs b/crates/copilot_chat/src/copilot_chat.rs index d1f339f89a01d1ed0d17e03b8712b42232177db8..850190701e526fe3fad896a17cdc704b89253fea 100644 --- a/crates/copilot_chat/src/copilot_chat.rs +++ b/crates/copilot_chat/src/copilot_chat.rs @@ -370,7 +370,7 @@ pub enum Tool { #[serde(rename_all = "lowercase")] pub enum ToolChoice { Auto, - Any, + Required, None, } @@ -1736,4 +1736,22 @@ mod tests { // Only /v1/messages endpoint -> supports_response = false (doesn't have /responses) assert!(!model_with_messages.supports_response()); } + + #[test] + fn test_tool_choice_required_serializes_as_required() { + // Regression test: ToolChoice::Required must serialize as "required" (not "any") + // for OpenAI-compatible APIs. Reverting the rename would break this. + assert_eq!( + serde_json::to_string(&ToolChoice::Required).unwrap(), + "\"required\"" + ); + assert_eq!( + serde_json::to_string(&ToolChoice::Auto).unwrap(), + "\"auto\"" + ); + assert_eq!( + serde_json::to_string(&ToolChoice::None).unwrap(), + "\"none\"" + ); + } } diff --git a/crates/copilot_chat/src/responses.rs b/crates/copilot_chat/src/responses.rs index 4f30ba1eb083c8a70c9a91853c7df37e65783ce3..1241a76fb1410461cef7337e2339180b18da7a3f 100644 --- a/crates/copilot_chat/src/responses.rs +++ b/crates/copilot_chat/src/responses.rs @@ -52,7 +52,7 @@ pub enum ToolDefinition { #[serde(rename_all = "lowercase")] pub enum ToolChoice { Auto, - Any, + Required, None, #[serde(untagged)] Other(ToolDefinition), @@ -408,3 +408,26 @@ pub async fn stream_response( } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_tool_choice_required_serializes_as_required() { + // Regression test: ToolChoice::Required must serialize as "required" (not "any") + // for OpenAI Responses API. Reverting the rename would break this. + assert_eq!( + serde_json::to_string(&ToolChoice::Required).unwrap(), + "\"required\"" + ); + assert_eq!( + serde_json::to_string(&ToolChoice::Auto).unwrap(), + "\"auto\"" + ); + assert_eq!( + serde_json::to_string(&ToolChoice::None).unwrap(), + "\"none\"" + ); + } +} diff --git a/crates/copilot_ui/src/sign_in.rs b/crates/copilot_ui/src/sign_in.rs index 033effd230d65fee7594d0241b2828a41908a432..09267020e5c3599675807f01777097d23b4d9ab0 100644 --- a/crates/copilot_ui/src/sign_in.rs +++ b/crates/copilot_ui/src/sign_in.rs @@ -481,7 +481,6 @@ impl ConfigurationView { cx: &mut Context, ) -> Self { let copilot = AppState::try_global(cx) - .and_then(|state| state.upgrade()) .and_then(|state| GlobalCopilotAuth::try_get_or_init(state, cx)); Self { @@ -578,9 +577,8 @@ impl ConfigurationView { ) .when(edit_prediction, |this| this.tab_index(0isize)) .on_click(|_, window, cx| { - if let Some(app_state) = AppState::global(cx).upgrade() - && let Some(copilot) = GlobalCopilotAuth::try_get_or_init(app_state, cx) - { + let app_state = AppState::global(cx); + if let Some(copilot) = GlobalCopilotAuth::try_get_or_init(app_state, cx) { initiate_sign_in(copilot.0, window, cx) } }) @@ -608,9 +606,8 @@ impl ConfigurationView { .color(Color::Muted), ) .on_click(|_, window, cx| { - if let Some(app_state) = AppState::global(cx).upgrade() - && let Some(copilot) = GlobalCopilotAuth::try_get_or_init(app_state, cx) - { + let app_state = AppState::global(cx); + if let Some(copilot) = GlobalCopilotAuth::try_get_or_init(app_state, cx) { reinstall_and_sign_in(copilot.0, window, cx); } }) diff --git a/crates/csv_preview/src/csv_preview.rs b/crates/csv_preview/src/csv_preview.rs index f056f5a12225b000527b9087760e3d683bda1b5b..b0b6ad4186758fd33693d5ee29bd2f0d4d28b816 100644 --- a/crates/csv_preview/src/csv_preview.rs +++ b/crates/csv_preview/src/csv_preview.rs @@ -122,8 +122,9 @@ impl CsvPreviewView { fn new(editor: &Entity, cx: &mut Context) -> Entity { let contents = TableLikeContent::default(); let table_interaction_state = cx.new(|cx| { - TableInteractionState::new(cx) - .with_custom_scrollbar(ui::Scrollbars::for_settings::()) + TableInteractionState::new(cx).with_custom_scrollbar(ui::Scrollbars::for_settings::< + editor::EditorSettingsScrollbarProxy, + >()) }); cx.new(|cx| { diff --git a/crates/dap/src/client.rs b/crates/dap/src/client.rs index aa2e53f43cb13511e7eee2e9685a4939b07243b9..966445e4480c12a11bb7e331d14a32bb9118096a 100644 --- a/crates/dap/src/client.rs +++ b/crates/dap/src/client.rs @@ -284,6 +284,7 @@ mod tests { #[gpui::test] pub async fn test_initialize_client(cx: &mut TestAppContext) { + #![expect(clippy::result_large_err)] init_test(cx); let client = DebugAdapterClient::start( diff --git a/crates/debugger_ui/Cargo.toml b/crates/debugger_ui/Cargo.toml index f95712b05129b7f86699f658c4c2c3effbd7d216..ba98df3e3764f773d490b76d6a5912bab5e4adbe 100644 --- a/crates/debugger_ui/Cargo.toml +++ b/crates/debugger_ui/Cargo.toml @@ -67,6 +67,7 @@ tasks_ui.workspace = true terminal_view.workspace = true text.workspace = true theme.workspace = true +theme_settings.workspace = true tree-sitter-json.workspace = true tree-sitter.workspace = true ui.workspace = true diff --git a/crates/debugger_ui/src/debugger_panel.rs b/crates/debugger_ui/src/debugger_panel.rs index 39c0dc9d7a79afae19ae27cba244253e37460117..c2d8a7a5478cfc9eae53f9e7a6018864865a4d1a 100644 --- a/crates/debugger_ui/src/debugger_panel.rs +++ b/crates/debugger_ui/src/debugger_panel.rs @@ -55,7 +55,6 @@ impl FeatureFlag for DebuggerHistoryFeatureFlag { const DEBUG_PANEL_KEY: &str = "DebugPanel"; pub struct DebugPanel { - size: Pixels, active_session: Option>, project: Entity, workspace: WeakEntity, @@ -93,7 +92,6 @@ impl DebugPanel { ); Self { - size: px(300.), sessions_with_children: Default::default(), active_session: None, focus_handle, @@ -1572,12 +1570,8 @@ impl Panel for DebugPanel { }); } - fn size(&self, _window: &Window, _: &App) -> Pixels { - self.size - } - - fn set_size(&mut self, size: Option, _window: &mut Window, _cx: &mut Context) { - self.size = size.unwrap_or(px(300.)); + fn default_size(&self, _window: &Window, _: &App) -> Pixels { + px(300.) } fn remote_id() -> Option { @@ -1607,7 +1601,7 @@ impl Panel for DebugPanel { } fn activation_priority(&self) -> u32 { - 9 + 7 } fn set_active(&mut self, _: bool, _: &mut Window, _: &mut Context) {} @@ -1637,13 +1631,6 @@ impl Render for DebugPanel { } v_flex() - .when(!self.is_zoomed, |this| { - this.when_else( - self.position(window, cx) == DockPosition::Bottom, - |this| this.max_h(self.size), - |this| this.max_w(self.size), - ) - }) .size_full() .key_context("DebugPanel") .child(h_flex().children(self.top_controls_strip(window, cx))) diff --git a/crates/debugger_ui/src/new_process_modal.rs b/crates/debugger_ui/src/new_process_modal.rs index 5b028671ed512a09cb90bcc4098d793a50b0fdb8..1ea974c4fe2ace4be4aeaf0064304a7a4ee2fb08 100644 --- a/crates/debugger_ui/src/new_process_modal.rs +++ b/crates/debugger_ui/src/new_process_modal.rs @@ -1337,11 +1337,10 @@ impl PickerDelegate for DebugDelegate { else { return; }; - let file = location.buffer.read(cx).file(); - let language = location.buffer.read(cx).language(); - let language_name = language.as_ref().map(|l| l.name()); + let buffer = location.buffer.read(cx); + let language = buffer.language(); let Some(adapter): Option = - language::language_settings::language_settings(language_name, file, cx) + language::language_settings::LanguageSettings::for_buffer(buffer, cx) .debuggers .first() .map(SharedString::from) diff --git a/crates/debugger_ui/src/session/running/console.rs b/crates/debugger_ui/src/session/running/console.rs index e33efd2c4904fe83cbbffb9ae57aadfbfc6d5470..c488e88d74e7f282bd0424a2213e08e2c9bec15f 100644 --- a/crates/debugger_ui/src/session/running/console.rs +++ b/crates/debugger_ui/src/session/running/console.rs @@ -26,7 +26,8 @@ use project::{ use settings::Settings; use std::fmt::Write; use std::{ops::Range, rc::Rc, usize}; -use theme::{Theme, ThemeSettings}; +use theme::Theme; +use theme_settings::ThemeSettings; use ui::{ContextMenu, Divider, PopoverMenu, SplitButton, Tooltip, prelude::*}; use util::ResultExt; diff --git a/crates/debugger_ui/src/session/running/memory_view.rs b/crates/debugger_ui/src/session/running/memory_view.rs index 69ea556018fdadeb1e270b1d7c2520d25752e670..ebcabe210f8ee78af750793b36edd256ddbf984e 100644 --- a/crates/debugger_ui/src/session/running/memory_view.rs +++ b/crates/debugger_ui/src/session/running/memory_view.rs @@ -17,7 +17,7 @@ use gpui::{ use notifications::status_toast::{StatusToast, ToastIcon}; use project::debugger::{MemoryCell, dap_command::DataBreakpointContext, session::Session}; use settings::Settings; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ ContextMenu, Divider, DropdownMenu, FluentBuilder, IntoElement, PopoverMenuHandle, Render, ScrollableHandle, StatefulInteractiveElement, Tooltip, WithScrollbar, prelude::*, diff --git a/crates/debugger_ui/src/session/running/variable_list.rs b/crates/debugger_ui/src/session/running/variable_list.rs index 8329a6baf04061cc33e8130a4e6b3a33b35267b6..fd8fd736b9e5194d34df3928c0c2983bb40be954 100644 --- a/crates/debugger_ui/src/session/running/variable_list.rs +++ b/crates/debugger_ui/src/session/running/variable_list.rs @@ -1076,7 +1076,12 @@ impl VariableList { presentation_hint: Option<&VariablePresentationHint>, cx: &Context, ) -> VariableColor { - let syntax_color_for = |name| cx.theme().syntax().get(name).color; + let syntax_color_for = |name| { + cx.theme() + .syntax() + .style_for_name(name) + .and_then(|style| style.color) + }; let name = if self.disabled { Some(Color::Disabled.color(cx)) } else { diff --git a/crates/debugger_ui/src/tests.rs b/crates/debugger_ui/src/tests.rs index cc407dfd810ceedb11c4d8030c46a6f17065b34b..4b4cebb2931d0b47e8bc18bd1f79f823528b416b 100644 --- a/crates/debugger_ui/src/tests.rs +++ b/crates/debugger_ui/src/tests.rs @@ -41,7 +41,7 @@ pub fn init_test(cx: &mut gpui::TestAppContext) { let settings = SettingsStore::test(cx); cx.set_global(settings); terminal_view::init(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); command_palette_hooks::init(cx); editor::init(cx); crate::init(cx); diff --git a/crates/debugger_ui/src/tests/attach_modal.rs b/crates/debugger_ui/src/tests/attach_modal.rs index 4e8839f82f4de69fd1851ef50ff0d55ad09d0aa9..0b89b8c133956791892ea1f1959d06a20df8d005 100644 --- a/crates/debugger_ui/src/tests/attach_modal.rs +++ b/crates/debugger_ui/src/tests/attach_modal.rs @@ -1,3 +1,4 @@ +#![expect(clippy::result_large_err)] use crate::{ attach_modal::{Candidate, ModalIntent}, tests::start_debug_session_with, diff --git a/crates/debugger_ui/src/tests/console.rs b/crates/debugger_ui/src/tests/console.rs index fad483b0f4af19826f9da0d32659c8ac83712f1f..9e672be080aad417ca299a16c8de126617a3bce6 100644 --- a/crates/debugger_ui/src/tests/console.rs +++ b/crates/debugger_ui/src/tests/console.rs @@ -1,3 +1,4 @@ +#![expect(clippy::result_large_err)] use crate::{ tests::{active_debug_session_panel, start_debug_session}, *, diff --git a/crates/debugger_ui/src/tests/dap_logger.rs b/crates/debugger_ui/src/tests/dap_logger.rs index ff2b0f695f6a2e7f0ca65b49938e0129efb04326..3b40e27aa43e3ec251b12b020b1d9bced829875e 100644 --- a/crates/debugger_ui/src/tests/dap_logger.rs +++ b/crates/debugger_ui/src/tests/dap_logger.rs @@ -1,3 +1,4 @@ +#![expect(clippy::result_large_err)] use crate::tests::{init_test, init_test_workspace, start_debug_session}; use dap::requests::{StackTrace, Threads}; use debugger_tools::LogStore; diff --git a/crates/debugger_ui/src/tests/debugger_panel.rs b/crates/debugger_ui/src/tests/debugger_panel.rs index e4c258a8d2af0b865f13c28430c44a66117a11cd..223ed13142d91584eb1ec309e33e2442a3601782 100644 --- a/crates/debugger_ui/src/tests/debugger_panel.rs +++ b/crates/debugger_ui/src/tests/debugger_panel.rs @@ -1,3 +1,4 @@ +#![expect(clippy::result_large_err)] use crate::{ persistence::DebuggerPaneItem, tests::{start_debug_session, start_debug_session_with}, diff --git a/crates/debugger_ui/src/tests/inline_values.rs b/crates/debugger_ui/src/tests/inline_values.rs index 379bc4c98f5341b089b5936ed8571da5a6280723..c82276d824349c5d5e588cbde68ee3ae9bc5370f 100644 --- a/crates/debugger_ui/src/tests/inline_values.rs +++ b/crates/debugger_ui/src/tests/inline_values.rs @@ -1,3 +1,4 @@ +#![expect(clippy::result_large_err)] use std::{path::Path, sync::Arc}; use dap::{Scope, StackFrame, Variable, requests::Variables}; @@ -1826,7 +1827,7 @@ def process_data(untyped_param, typed_param: int, another_typed: str): } fn python_lang() -> Language { - let debug_variables_query = include_str!("../../../languages/src/python/debugger.scm"); + let debug_variables_query = include_str!("../../../grammars/src/python/debugger.scm"); Language::new( LanguageConfig { name: "Python".into(), @@ -1843,7 +1844,7 @@ fn python_lang() -> Language { } fn go_lang() -> Arc { - let debug_variables_query = include_str!("../../../languages/src/go/debugger.scm"); + let debug_variables_query = include_str!("../../../grammars/src/go/debugger.scm"); Arc::new( Language::new( LanguageConfig { @@ -2262,7 +2263,7 @@ fn main() { } fn javascript_lang() -> Arc { - let debug_variables_query = include_str!("../../../languages/src/javascript/debugger.scm"); + let debug_variables_query = include_str!("../../../grammars/src/javascript/debugger.scm"); Arc::new( Language::new( LanguageConfig { @@ -2281,7 +2282,7 @@ fn javascript_lang() -> Arc { } fn typescript_lang() -> Arc { - let debug_variables_query = include_str!("../../../languages/src/typescript/debugger.scm"); + let debug_variables_query = include_str!("../../../grammars/src/typescript/debugger.scm"); Arc::new( Language::new( LanguageConfig { @@ -2300,7 +2301,7 @@ fn typescript_lang() -> Arc { } fn tsx_lang() -> Arc { - let debug_variables_query = include_str!("../../../languages/src/tsx/debugger.scm"); + let debug_variables_query = include_str!("../../../grammars/src/tsx/debugger.scm"); Arc::new( Language::new( LanguageConfig { diff --git a/crates/debugger_ui/src/tests/module_list.rs b/crates/debugger_ui/src/tests/module_list.rs index 09c90cbc4a3af71aa9fb7273cf3535e9f7ece592..21b5ec67c20b5fda509df501e206919d0e83be08 100644 --- a/crates/debugger_ui/src/tests/module_list.rs +++ b/crates/debugger_ui/src/tests/module_list.rs @@ -1,3 +1,4 @@ +#![expect(clippy::result_large_err)] use crate::{ debugger_panel::DebugPanel, persistence::DebuggerPaneItem, diff --git a/crates/debugger_ui/src/tests/new_process_modal.rs b/crates/debugger_ui/src/tests/new_process_modal.rs index 54c38d8b1cec8d043748338830d643d63479e533..01e83b533eb21e178bfaacaf9184757fcb207738 100644 --- a/crates/debugger_ui/src/tests/new_process_modal.rs +++ b/crates/debugger_ui/src/tests/new_process_modal.rs @@ -1,3 +1,4 @@ +#![expect(clippy::result_large_err)] use dap::DapRegistry; use editor::Editor; use gpui::{BackgroundExecutor, TestAppContext, VisualTestContext}; diff --git a/crates/debugger_ui/src/tests/persistence.rs b/crates/debugger_ui/src/tests/persistence.rs index f5fb4f0ab2d5957240c6981d20526a025f54a387..ca04828c40b3b33467b6fed8f623d9e6919aeb65 100644 --- a/crates/debugger_ui/src/tests/persistence.rs +++ b/crates/debugger_ui/src/tests/persistence.rs @@ -1,3 +1,4 @@ +#![expect(clippy::result_large_err)] use std::iter::zip; use crate::{ diff --git a/crates/debugger_ui/src/tests/stack_frame_list.rs b/crates/debugger_ui/src/tests/stack_frame_list.rs index 7e1763f6650127be12803f4d64bc16f0ab3c9989..dd1ddcdf7a160e051a18993983da59a50dba71fd 100644 --- a/crates/debugger_ui/src/tests/stack_frame_list.rs +++ b/crates/debugger_ui/src/tests/stack_frame_list.rs @@ -1,3 +1,4 @@ +#![expect(clippy::result_large_err)] use crate::{ debugger_panel::DebugPanel, session::running::stack_frame_list::{ diff --git a/crates/debugger_ui/src/tests/variable_list.rs b/crates/debugger_ui/src/tests/variable_list.rs index 4cfdae093f6a1464b178c053e629a6ebe6d76d02..8e6c1259921b45fe6c63099fae04d284c3c134fc 100644 --- a/crates/debugger_ui/src/tests/variable_list.rs +++ b/crates/debugger_ui/src/tests/variable_list.rs @@ -1,3 +1,4 @@ +#![expect(clippy::result_large_err)] use std::sync::{ Arc, atomic::{AtomicBool, Ordering}, diff --git a/crates/diagnostics/Cargo.toml b/crates/diagnostics/Cargo.toml index 09ee023d57fbb9b9f2c7d828f9b2ea25f73d23d9..6a19e7e40e0ce91cfb78ca44c5c5e7f74205106f 100644 --- a/crates/diagnostics/Cargo.toml +++ b/crates/diagnostics/Cargo.toml @@ -32,6 +32,7 @@ serde_json.workspace = true settings.workspace = true text.workspace = true theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true diff --git a/crates/diagnostics/src/diagnostic_renderer.rs b/crates/diagnostics/src/diagnostic_renderer.rs index 89cebf8fb237a032866e14c36d3097e18388e6ab..27e1cbbac9c779056ecd9da00dd7a56ff3536f17 100644 --- a/crates/diagnostics/src/diagnostic_renderer.rs +++ b/crates/diagnostics/src/diagnostic_renderer.rs @@ -11,7 +11,7 @@ use lsp::DiagnosticSeverity; use markdown::{Markdown, MarkdownElement}; use settings::Settings; use text::{AnchorRangeExt, Point}; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{CopyButton, prelude::*}; use util::maybe; diff --git a/crates/diagnostics/src/diagnostics_tests.rs b/crates/diagnostics/src/diagnostics_tests.rs index 06b71a583f5d02a103db69e17d4e2db48c98a415..527f5b5bfcbfa2350233f9f3a119e56e4f72b9a5 100644 --- a/crates/diagnostics/src/diagnostics_tests.rs +++ b/crates/diagnostics/src/diagnostics_tests.rs @@ -2034,7 +2034,7 @@ fn init_test(cx: &mut TestAppContext) { zlog::init_test(); let settings = SettingsStore::test(cx); cx.set_global(settings); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); crate::init(cx); editor::init(cx); }); diff --git a/crates/docs_preprocessor/src/main.rs b/crates/docs_preprocessor/src/main.rs index 43efbeea0b0310cf70cd9bdb560b1b0d2b0c14ef..fc1bc404244a4896e7d13fbb0e9c81674438568f 100644 --- a/crates/docs_preprocessor/src/main.rs +++ b/crates/docs_preprocessor/src/main.rs @@ -22,8 +22,45 @@ static KEYMAP_WINDOWS: LazyLock = LazyLock::new(|| { load_keymap("keymaps/default-windows.json").expect("Failed to load Windows keymap") }); +static KEYMAP_JETBRAINS_MACOS: LazyLock = LazyLock::new(|| { + load_keymap("keymaps/macos/jetbrains.json").expect("Failed to load JetBrains macOS keymap") +}); + +static KEYMAP_JETBRAINS_LINUX: LazyLock = LazyLock::new(|| { + load_keymap("keymaps/linux/jetbrains.json").expect("Failed to load JetBrains Linux keymap") +}); + static ALL_ACTIONS: LazyLock = LazyLock::new(load_all_actions); +#[derive(Clone, Copy)] +#[allow(dead_code)] +enum Os { + MacOs, + Linux, + Windows, +} + +#[derive(Clone, Copy)] +enum KeymapOverlay { + JetBrains, +} + +impl KeymapOverlay { + fn parse(name: &str) -> Option { + match name { + "jetbrains" => Some(Self::JetBrains), + _ => None, + } + } + + fn keymap(self, os: Os) -> &'static KeymapFile { + match (self, os) { + (Self::JetBrains, Os::MacOs) => &KEYMAP_JETBRAINS_MACOS, + (Self::JetBrains, Os::Linux | Os::Windows) => &KEYMAP_JETBRAINS_LINUX, + } + } +} + const FRONT_MATTER_COMMENT: &str = ""; fn main() -> Result<()> { @@ -64,6 +101,9 @@ enum PreprocessorError { snippet: String, error: String, }, + UnknownKeymapOverlay { + overlay_name: String, + }, } impl PreprocessorError { @@ -125,6 +165,13 @@ impl std::fmt::Display for PreprocessorError { snippet ) } + PreprocessorError::UnknownKeymapOverlay { overlay_name } => { + write!( + f, + "Unknown keymap overlay: '{}'. Supported overlays: jetbrains", + overlay_name + ) + } } } } @@ -205,20 +252,39 @@ fn format_binding(binding: String) -> String { } fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet) { - let regex = Regex::new(r"\{#kb (.*?)\}").unwrap(); + let regex = Regex::new(r"\{#kb(?::(\w+))?\s+(.*?)\}").unwrap(); for_each_chapter_mut(book, |chapter| { chapter.content = regex .replace_all(&chapter.content, |caps: ®ex::Captures| { - let action = caps[1].trim(); + let overlay_name = caps.get(1).map(|m| m.as_str()); + let action = caps[2].trim(); + if is_missing_action(action) { errors.insert(PreprocessorError::new_for_not_found_action( action.to_string(), )); return String::new(); } - let macos_binding = find_binding("macos", action).unwrap_or_default(); - let linux_binding = find_binding("linux", action).unwrap_or_default(); + + let overlay = if let Some(name) = overlay_name { + let Some(overlay) = KeymapOverlay::parse(name) else { + errors.insert(PreprocessorError::UnknownKeymapOverlay { + overlay_name: name.to_string(), + }); + return String::new(); + }; + Some(overlay) + } else { + None + }; + + let macos_binding = + find_binding_with_overlay(Os::MacOs, action, overlay) + .unwrap_or_default(); + let linux_binding = + find_binding_with_overlay(Os::Linux, action, overlay) + .unwrap_or_default(); if macos_binding.is_empty() && linux_binding.is_empty() { return "
No default binding
".to_string(); @@ -227,7 +293,7 @@ fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet{formatted_macos_binding}|{formatted_linux_binding}") + format!("{formatted_macos_binding}|{formatted_linux_binding}") }) .into_owned() }); @@ -270,15 +336,8 @@ fn is_missing_action(name: &str) -> bool { actions_available() && find_action_by_name(name).is_none() } -fn find_binding(os: &str, action: &str) -> Option { - let keymap = match os { - "macos" => &KEYMAP_MACOS, - "linux" | "freebsd" => &KEYMAP_LINUX, - "windows" => &KEYMAP_WINDOWS, - _ => unreachable!("Not a valid OS: {}", os), - }; - - // Find the binding in reverse order, as the last binding takes precedence. +// Find the binding in reverse order, as the last binding takes precedence. +fn find_binding_in_keymap(keymap: &KeymapFile, action: &str) -> Option { keymap.sections().rev().find_map(|section| { section.bindings().rev().find_map(|(keystroke, a)| { if name_for_action(a.to_string()) == action { @@ -290,6 +349,25 @@ fn find_binding(os: &str, action: &str) -> Option { }) } +fn find_binding(os: Os, action: &str) -> Option { + let keymap = match os { + Os::MacOs => &KEYMAP_MACOS, + Os::Linux => &KEYMAP_LINUX, + Os::Windows => &KEYMAP_WINDOWS, + }; + find_binding_in_keymap(keymap, action) +} + +fn find_binding_with_overlay( + os: Os, + action: &str, + overlay: Option, +) -> Option { + overlay + .and_then(|overlay| find_binding_in_keymap(overlay.keymap(os), action)) + .or_else(|| find_binding(os, action)) +} + fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet) { let settings_schema = SettingsStore::json_schema(&Default::default()); let settings_validator = jsonschema::validator_for(&settings_schema) diff --git a/crates/edit_prediction/Cargo.toml b/crates/edit_prediction/Cargo.toml index a6a7d8777cbf0d52575489e91a5ae03be2d031ea..75a589dea8f9c7fefe7bf13400cbdde54bf90bf1 100644 --- a/crates/edit_prediction/Cargo.toml +++ b/crates/edit_prediction/Cargo.toml @@ -18,7 +18,6 @@ cli-support = [] ai_onboarding.workspace = true anyhow.workspace = true heapless.workspace = true -brotli.workspace = true buffer_diff.workspace = true client.workspace = true clock.workspace = true diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index 421a51b055693617a915e622b617298f5f8a01c5..3a66f712e31d7853bede21ab96ca6c7e92bea967 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -50,7 +50,8 @@ use std::path::Path; use std::rc::Rc; use std::str::FromStr as _; use std::sync::Arc; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use std::time::{Duration, Instant}; + use thiserror::Error; use util::{RangeExt as _, ResultExt as _}; @@ -63,7 +64,6 @@ pub mod ollama; mod onboarding_modal; pub mod open_ai_response; mod prediction; -pub mod sweep_ai; pub mod udiff; @@ -83,7 +83,6 @@ use crate::onboarding_modal::ZedPredictModal; pub use crate::prediction::EditPrediction; pub use crate::prediction::EditPredictionId; use crate::prediction::EditPredictionResult; -pub use crate::sweep_ai::SweepAi; pub use capture_example::capture_example; pub use language_model::ApiKeyState; pub use telemetry_events::EditPredictionRating; @@ -143,7 +142,6 @@ pub struct EditPredictionStore { zeta2_raw_config: Option, preferred_experiment: Option, available_experiments: Vec, - pub sweep_ai: SweepAi, pub mercury: Mercury, data_collection_choice: DataCollectionChoice, reject_predictions_tx: mpsc::UnboundedSender, @@ -163,7 +161,6 @@ pub(crate) struct EditPredictionRejectionPayload { pub enum EditPredictionModel { Zeta, Fim { format: EditPredictionPromptFormat }, - Sweep, Mercury, } @@ -175,13 +172,11 @@ pub struct EditPredictionModelInput { position: Anchor, events: Vec>, related_files: Vec, - recent_paths: VecDeque, trigger: PredictEditsRequestTrigger, diagnostic_search_range: Range, debug_tx: Option>, can_collect_data: bool, is_open_source: bool, - pub user_actions: Vec, } #[derive(Debug)] @@ -220,26 +215,6 @@ pub struct EditPredictionFinishedDebugEvent { pub model_output: Option, } -const USER_ACTION_HISTORY_SIZE: usize = 16; - -#[derive(Clone, Debug)] -pub struct UserActionRecord { - pub action_type: UserActionType, - pub buffer_id: EntityId, - pub line_number: u32, - pub offset: usize, - pub timestamp_epoch_ms: u64, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum UserActionType { - InsertChar, - InsertSelection, - DeleteChar, - DeleteSelection, - CursorMovement, -} - /// An event with associated metadata for reconstructing buffer state. #[derive(Clone)] pub struct StoredEvent { @@ -339,19 +314,11 @@ struct ProjectState { cancelled_predictions: HashSet, context: Entity, license_detection_watchers: HashMap>, - user_actions: VecDeque, _subscriptions: [gpui::Subscription; 2], copilot: Option>, } impl ProjectState { - fn record_user_action(&mut self, action: UserActionRecord) { - if self.user_actions.len() >= USER_ACTION_HISTORY_SIZE { - self.user_actions.pop_front(); - } - self.user_actions.push_back(action); - } - pub fn events(&self, cx: &App) -> Vec { self.events .iter() @@ -811,8 +778,11 @@ impl EditPredictionStore { while current_user.borrow().is_none() { current_user.next().await; } + this.update(cx, |this, cx| { - this.refresh_available_experiments(cx); + if cx.is_staff() { + this.refresh_available_experiments(cx); + } }) .log_err(); }); @@ -828,7 +798,6 @@ impl EditPredictionStore { zeta2_raw_config: Self::zeta2_raw_config_from_env(), preferred_experiment: None, available_experiments: Vec::new(), - sweep_ai: SweepAi::new(cx), mercury: Mercury::new(cx), data_collection_choice, @@ -939,13 +908,6 @@ impl EditPredictionStore { pub fn icons(&self, cx: &App) -> edit_prediction_types::EditPredictionIconSet { use ui::IconName; match self.edit_prediction_model { - EditPredictionModel::Sweep => { - edit_prediction_types::EditPredictionIconSet::new(IconName::SweepAi) - .with_disabled(IconName::SweepAiDisabled) - .with_up(IconName::SweepAiUp) - .with_down(IconName::SweepAiDown) - .with_error(IconName::SweepAiError) - } EditPredictionModel::Mercury => { edit_prediction_types::EditPredictionIconSet::new(IconName::Inception) } @@ -970,10 +932,6 @@ impl EditPredictionStore { } } - 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, cx: &App) -> bool { self.mercury.api_token.read(cx).has_key() } @@ -1132,7 +1090,6 @@ impl EditPredictionStore { last_edit_prediction_refresh: None, last_jump_prediction_refresh: None, license_detection_watchers: HashMap::default(), - user_actions: VecDeque::with_capacity(USER_ACTION_HISTORY_SIZE), _subscriptions: [ cx.subscribe(&project, Self::handle_project_event), cx.observe_release(&project, move |this, _, cx| { @@ -1347,24 +1304,16 @@ impl EditPredictionStore { } 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 mut num_edits = 0usize; - let mut total_deleted = 0usize; - let mut total_inserted = 0usize; let mut edit_range: Option> = None; - let mut last_offset: Option = None; let now = cx.background_executor().now(); - for (edit, anchor_range) in + for (_edit, anchor_range) in new_snapshot.anchored_edits_since::(&old_snapshot.version) { - num_edits += 1; - total_deleted += edit.old.len(); - total_inserted += edit.new.len(); edit_range = Some(match edit_range { None => anchor_range, Some(acc) => acc.start..anchor_range.end, }); - last_offset = Some(edit.new.end); } let Some(edit_range) = edit_range else { @@ -1387,32 +1336,6 @@ impl EditPredictionStore { cx, ); - if is_local { - let action_type = match (total_deleted, total_inserted, num_edits) { - (0, ins, n) if ins == n => UserActionType::InsertChar, - (0, _, _) => UserActionType::InsertSelection, - (del, 0, n) if del == n => UserActionType::DeleteChar, - (_, 0, _) => UserActionType::DeleteSelection, - (_, ins, n) if ins == n => UserActionType::InsertChar, - (_, _, _) => UserActionType::InsertSelection, - }; - - if let Some(offset) = last_offset { - let point = new_snapshot.offset_to_point(offset); - let timestamp_epoch_ms = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or(0); - project_state.record_user_action(UserActionRecord { - action_type, - buffer_id: buffer.entity_id(), - line_number: point.row, - offset, - timestamp_epoch_ms, - }); - } - } - if !include_in_history { return; } @@ -1562,9 +1485,6 @@ impl EditPredictionStore { } match self.edit_prediction_model { - EditPredictionModel::Sweep => { - sweep_ai::edit_prediction_accepted(self, current_prediction, cx) - } EditPredictionModel::Mercury => { mercury::edit_prediction_accepted( current_prediction.prediction.id, @@ -1792,7 +1712,7 @@ impl EditPredictionStore { &mut self, project: &Entity, display_type: edit_prediction_types::SuggestionDisplayType, - cx: &mut Context, + _cx: &mut Context, ) { let Some(project_state) = self.projects.get_mut(&project.entity_id()) else { return; @@ -1815,18 +1735,6 @@ impl EditPredictionStore { current_prediction.was_shown = true; } - let display_type_changed = previous_shown_with != Some(display_type); - - if self.edit_prediction_model == EditPredictionModel::Sweep && display_type_changed { - sweep_ai::edit_prediction_shown( - &self.sweep_ai, - self.client.clone(), - ¤t_prediction.prediction, - display_type, - cx, - ); - } - if is_first_non_jump_show { self.shown_predictions .push_front(current_prediction.prediction.clone()); @@ -1883,7 +1791,7 @@ impl EditPredictionStore { cx, ); } - EditPredictionModel::Sweep | EditPredictionModel::Fim { .. } => {} + EditPredictionModel::Fim { .. } => {} } } @@ -2087,7 +1995,7 @@ impl EditPredictionStore { } fn currently_following(project: &Entity, cx: &App) -> bool { - let Some(app_state) = AppState::try_global(cx).and_then(|app_state| app_state.upgrade()) else { + let Some(app_state) = AppState::try_global(cx) else { return false; }; @@ -2108,7 +2016,6 @@ fn currently_following(project: &Entity, cx: &App) -> bool { fn is_ep_store_provider(provider: EditPredictionProvider) -> bool { match provider { EditPredictionProvider::Zed - | EditPredictionProvider::Sweep | EditPredictionProvider::Mercury | EditPredictionProvider::Ollama | EditPredictionProvider::OpenAiCompatibleApi @@ -2148,7 +2055,6 @@ impl EditPredictionStore { let (needs_acceptance_tracking, max_pending_predictions) = match all_language_settings(None, cx).edit_predictions.provider { EditPredictionProvider::Zed - | EditPredictionProvider::Sweep | EditPredictionProvider::Mercury | EditPredictionProvider::Experimental(_) => (true, 2), EditPredictionProvider::Ollama => (false, 1), @@ -2370,28 +2276,6 @@ impl EditPredictionStore { let snapshot = active_buffer.read(cx).snapshot(); let cursor_point = position.to_point(&snapshot); - let current_offset = position.to_offset(&snapshot); - - let mut user_actions: Vec = - project_state.user_actions.iter().cloned().collect(); - - if let Some(last_action) = user_actions.last() { - if last_action.buffer_id == active_buffer.entity_id() - && current_offset != last_action.offset - { - let timestamp_epoch_ms = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or(0); - user_actions.push(UserActionRecord { - action_type: UserActionType::CursorMovement, - buffer_id: active_buffer.entity_id(), - line_number: cursor_point.row, - offset: current_offset, - timestamp_epoch_ms, - }); - } - } let diagnostic_search_start = cursor_point.row.saturating_sub(DIAGNOSTIC_LINES_RANGE); let diagnostic_search_end = cursor_point.row + DIAGNOSTIC_LINES_RANGE; let diagnostic_search_range = @@ -2410,8 +2294,6 @@ impl EditPredictionStore { && self.is_data_collection_enabled(cx) && matches!(self.edit_prediction_model, EditPredictionModel::Zeta); - let recent_paths = project_state.recent_paths.clone(); - let inputs = EditPredictionModelInput { project: project.clone(), buffer: active_buffer, @@ -2419,11 +2301,9 @@ impl EditPredictionStore { position, events, related_files, - recent_paths, trigger, diagnostic_search_range: diagnostic_search_range, debug_tx, - user_actions, can_collect_data, is_open_source, }; @@ -2435,7 +2315,6 @@ impl EditPredictionStore { zeta::request_prediction_with_zeta(self, inputs, capture_data, cx) } EditPredictionModel::Fim { format } => fim::request_prediction(inputs, format, cx), - EditPredictionModel::Sweep => self.sweep_ai.request_prediction_with_sweep(inputs, cx), EditPredictionModel::Mercury => self.mercury.request_prediction(inputs, cx), }; diff --git a/crates/edit_prediction/src/edit_prediction_tests.rs b/crates/edit_prediction/src/edit_prediction_tests.rs index 7583ba629bc2c490c5f8e8dd83218c200025fe7c..6fe61338e764a40aec9cf6f3191f1191bafe9200 100644 --- a/crates/edit_prediction/src/edit_prediction_tests.rs +++ b/crates/edit_prediction/src/edit_prediction_tests.rs @@ -204,7 +204,7 @@ async fn test_diagnostics_refresh_suppressed_while_following(cx: &mut TestAppCon let app_state = cx.update(|cx| { let app_state = AppState::test(cx); - AppState::set_global(Arc::downgrade(&app_state), cx); + AppState::set_global(app_state.clone(), cx); app_state }); @@ -214,7 +214,7 @@ async fn test_diagnostics_refresh_suppressed_while_following(cx: &mut TestAppCon .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()) .unwrap(); cx.update(|cx| { - AppState::set_global(Arc::downgrade(workspace.read(cx).app_state()), cx); + AppState::set_global(workspace.read(cx).app_state().clone(), cx); }); let _ = app_state; diff --git a/crates/edit_prediction/src/sweep_ai.rs b/crates/edit_prediction/src/sweep_ai.rs deleted file mode 100644 index 93a9a34340cfe0b55e40d35bb4c8980dff983fa5..0000000000000000000000000000000000000000 --- a/crates/edit_prediction/src/sweep_ai.rs +++ /dev/null @@ -1,669 +0,0 @@ -use crate::{ - CurrentEditPrediction, DebugEvent, EditPrediction, EditPredictionFinishedDebugEvent, - EditPredictionId, EditPredictionModelInput, EditPredictionStartedDebugEvent, - EditPredictionStore, UserActionRecord, UserActionType, prediction::EditPredictionResult, -}; -use anyhow::{Result, bail}; -use client::Client; -use edit_prediction_types::SuggestionDisplayType; -use futures::{AsyncReadExt as _, channel::mpsc}; -use gpui::{ - App, AppContext as _, Entity, Global, SharedString, Task, - http_client::{self, AsyncBody, Method}, -}; -use language::language_settings::all_language_settings; -use language::{Anchor, Buffer, BufferSnapshot, Point, ToOffset as _}; -use language_model::{ApiKeyState, EnvVar, env_var}; -use lsp::DiagnosticSeverity; -use serde::{Deserialize, Serialize}; -use std::{ - fmt::{self, Write as _}, - ops::Range, - path::Path, - sync::Arc, -}; - -const SWEEP_API_URL: &str = "https://autocomplete.sweep.dev/backend/next_edit_autocomplete"; -const SWEEP_METRICS_URL: &str = "https://backend.app.sweep.dev/backend/track_autocomplete_metrics"; - -pub struct SweepAi { - pub api_token: Entity, - pub debug_info: Arc, -} - -impl SweepAi { - pub fn new(cx: &mut App) -> Self { - SweepAi { - api_token: sweep_api_token(cx), - debug_info: debug_info(cx), - } - } - - pub fn request_prediction_with_sweep( - &self, - inputs: EditPredictionModelInput, - cx: &mut App, - ) -> Task>> { - let privacy_mode_enabled = all_language_settings(None, cx) - .edit_predictions - .sweep - .privacy_mode; - let debug_info = self.debug_info.clone(); - let request_start = cx.background_executor().now(); - self.api_token.update(cx, |key_state, cx| { - _ = key_state.load_if_needed(SWEEP_CREDENTIALS_URL, |s| s, cx); - }); - - let buffer = inputs.buffer.clone(); - let debug_tx = inputs.debug_tx.clone(); - - let Some(api_token) = self.api_token.read(cx).key(&SWEEP_CREDENTIALS_URL) else { - return Task::ready(Ok(None)); - }; - 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(inputs.snapshot.file()); - let repo_name = project_file - .map(|file| file.worktree.read(cx).root_name_str()) - .unwrap_or("untitled") - .into(); - let offset = inputs.position.to_offset(&inputs.snapshot); - let buffer_entity_id = inputs.buffer.entity_id(); - - 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 = inputs.project.read(cx).get_open_buffer(&project_path, cx)?; - if inputs.buffer == buffer { - None - } else { - Some(buffer.read(cx).snapshot()) - } - }) - .take(3) - .collect::>(); - - let result = cx.background_spawn(async move { - let text = inputs.snapshot.text(); - - let mut recent_changes = String::new(); - for event in &inputs.events { - write_event(event.as_ref(), &mut recent_changes).unwrap(); - } - - let file_chunks = recent_buffer_snapshots - .into_iter() - .map(|snapshot| { - let end_point = Point::new(30, 0).min(snapshot.max_point()); - FileChunk { - content: snapshot.text_for_range(Point::zero()..end_point).collect(), - file_path: snapshot - .file() - .map(|f| f.path().as_unix_str()) - .unwrap_or("untitled") - .to_string(), - start_line: 0, - end_line: end_point.row as usize, - timestamp: snapshot.file().and_then(|file| { - Some( - file.disk_state() - .mtime()? - .to_seconds_and_nanos_for_persistence()? - .0, - ) - }), - } - }) - .collect::>(); - - let mut retrieval_chunks: Vec = inputs - .related_files - .iter() - .flat_map(|related_file| { - related_file.excerpts.iter().map(|excerpt| FileChunk { - 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 = inputs - .snapshot - .diagnostics_in_range(inputs.diagnostic_search_range, false); - let mut diagnostic_content = String::new(); - let mut diagnostic_count = 0; - - for entry in diagnostic_entries { - let start_point: Point = entry.range.start; - - let severity = match entry.diagnostic.severity { - DiagnosticSeverity::ERROR => "error", - DiagnosticSeverity::WARNING => "warning", - DiagnosticSeverity::INFORMATION => "info", - DiagnosticSeverity::HINT => "hint", - _ => continue, - }; - - diagnostic_count += 1; - - writeln!( - &mut diagnostic_content, - "{}:{}:{}: {}: {}", - full_path.display(), - start_point.row + 1, - start_point.column + 1, - severity, - entry.diagnostic.message - )?; - } - - if !diagnostic_content.is_empty() { - retrieval_chunks.push(FileChunk { - file_path: "diagnostics".to_string(), - start_line: 1, - end_line: diagnostic_count, - content: diagnostic_content, - timestamp: None, - }); - } - - let file_path_str = full_path.display().to_string(); - let recent_user_actions = inputs - .user_actions - .iter() - .filter(|r| r.buffer_id == buffer_entity_id) - .map(|r| to_sweep_user_action(r, &file_path_str)) - .collect(); - - let request_body = AutocompleteRequest { - debug_info, - repo_name, - file_path: full_path.clone(), - file_contents: text.clone(), - original_file_contents: text, - cursor_position: offset, - recent_changes: recent_changes.clone(), - changes_above_cursor: true, - multiple_suggestions: false, - branch: None, - file_chunks, - retrieval_chunks, - recent_user_actions, - use_bytes: true, - privacy_mode_enabled, - }; - - let mut buf: Vec = Vec::new(); - let writer = brotli::CompressorWriter::new(&mut buf, 4096, 1, 22); - serde_json::to_writer(writer, &request_body)?; - let body: AsyncBody = buf.into(); - - let ep_inputs = zeta_prompt::ZetaPromptInput { - events: inputs.events, - related_files: Some(inputs.related_files.clone()), - active_buffer_diagnostics: vec![], - cursor_path: full_path.clone(), - cursor_excerpt: request_body.file_contents.clone().into(), - cursor_offset_in_excerpt: request_body.cursor_position, - excerpt_start_row: Some(0), - excerpt_ranges: zeta_prompt::ExcerptRanges { - editable_150: 0..inputs.snapshot.len(), - editable_180: 0..inputs.snapshot.len(), - editable_350: 0..inputs.snapshot.len(), - editable_150_context_350: 0..inputs.snapshot.len(), - editable_180_context_350: 0..inputs.snapshot.len(), - editable_350_context_150: 0..inputs.snapshot.len(), - ..Default::default() - }, - syntax_ranges: None, - experiment: None, - in_open_source_repo: false, - can_collect_data: false, - repo_url: None, - }; - - send_started_event( - &debug_tx, - &buffer, - inputs.position, - serde_json::to_string(&request_body).unwrap_or_default(), - ); - - let request = http_client::Request::builder() - .uri(SWEEP_API_URL) - .header("Content-Type", "application/json") - .header("Authorization", format!("Bearer {}", api_token)) - .header("Connection", "keep-alive") - .header("Content-Encoding", "br") - .method(Method::POST) - .body(body)?; - - let mut response = http_client.send(request).await?; - - let mut body = String::new(); - response.body_mut().read_to_string(&mut body).await?; - - if !response.status().is_success() { - let message = format!( - "Request failed with status: {:?}\nBody: {}", - response.status(), - body, - ); - send_finished_event(&debug_tx, &buffer, inputs.position, message.clone()); - bail!(message); - }; - - let response: AutocompleteResponse = serde_json::from_str(&body)?; - - send_finished_event(&debug_tx, &buffer, inputs.position, body); - - 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)| { - ( - inputs - .snapshot - .anchor_after(response.start_index + range.start) - ..inputs - .snapshot - .anchor_before(response.start_index + range.end), - text, - ) - }) - .collect::>(); - - anyhow::Ok((response.autocomplete_id, edits, inputs.snapshot, ep_inputs)) - }); - - let buffer = inputs.buffer.clone(); - - cx.spawn(async move |cx| { - let (id, edits, old_snapshot, inputs) = result.await?; - anyhow::Ok(Some( - EditPredictionResult::new( - EditPredictionId(id.into()), - &buffer, - &old_snapshot, - edits.into(), - None, - inputs, - None, - cx.background_executor().now() - request_start, - cx, - ) - .await, - )) - }) - } -} - -fn send_started_event( - debug_tx: &Option>, - buffer: &Entity, - position: Anchor, - prompt: String, -) { - if let Some(debug_tx) = debug_tx { - _ = debug_tx.unbounded_send(DebugEvent::EditPredictionStarted( - EditPredictionStartedDebugEvent { - buffer: buffer.downgrade(), - position, - prompt: Some(prompt), - }, - )); - } -} - -fn send_finished_event( - debug_tx: &Option>, - buffer: &Entity, - position: Anchor, - model_output: String, -) { - if let Some(debug_tx) = debug_tx { - _ = debug_tx.unbounded_send(DebugEvent::EditPredictionFinished( - EditPredictionFinishedDebugEvent { - buffer: buffer.downgrade(), - position, - model_output: Some(model_output), - }, - )); - } -} - -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 sweep_api_token(cx: &mut App) -> Entity { - if let Some(global) = cx.try_global::() { - return global.0.clone(); - } - let entity = - cx.new(|_| ApiKeyState::new(SWEEP_CREDENTIALS_URL, SWEEP_AI_TOKEN_ENV_VAR.clone())); - cx.set_global(GlobalSweepApiKey(entity.clone())); - entity -} - -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) - }) -} - -#[derive(Debug, Clone, Serialize)] -struct AutocompleteRequest { - pub debug_info: Arc, - pub repo_name: String, - pub branch: Option, - pub file_path: Arc, - pub file_contents: String, - pub recent_changes: String, - pub cursor_position: usize, - pub original_file_contents: String, - pub file_chunks: Vec, - pub retrieval_chunks: Vec, - pub recent_user_actions: Vec, - pub multiple_suggestions: bool, - pub privacy_mode_enabled: bool, - pub changes_above_cursor: bool, - pub use_bytes: bool, -} - -#[derive(Debug, Clone, Serialize)] -struct FileChunk { - pub file_path: String, - pub start_line: usize, - pub end_line: usize, - pub content: String, - pub timestamp: Option, -} - -#[derive(Debug, Clone, Serialize)] -struct UserAction { - pub action_type: ActionType, - pub line_number: usize, - pub offset: usize, - pub file_path: String, - pub timestamp: u64, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -enum ActionType { - CursorMovement, - InsertChar, - DeleteChar, - InsertSelection, - DeleteSelection, -} - -fn to_sweep_user_action(record: &UserActionRecord, file_path: &str) -> UserAction { - UserAction { - action_type: match record.action_type { - UserActionType::InsertChar => ActionType::InsertChar, - UserActionType::InsertSelection => ActionType::InsertSelection, - UserActionType::DeleteChar => ActionType::DeleteChar, - UserActionType::DeleteSelection => ActionType::DeleteSelection, - UserActionType::CursorMovement => ActionType::CursorMovement, - }, - line_number: record.line_number as usize, - offset: record.offset, - file_path: file_path.to_string(), - timestamp: record.timestamp_epoch_ms, - } -} - -#[derive(Debug, Clone, Deserialize)] -struct AutocompleteResponse { - pub autocomplete_id: String, - pub start_index: usize, - pub end_index: usize, - pub completion: String, - #[allow(dead_code)] - pub confidence: f64, - #[allow(dead_code)] - pub logprobs: Option, - #[allow(dead_code)] - pub finish_reason: Option, - #[allow(dead_code)] - pub elapsed_time_ms: u64, - #[allow(dead_code)] - #[serde(default, rename = "completions")] - pub additional_completions: Vec, -} - -#[allow(dead_code)] -#[derive(Debug, Clone, Deserialize)] -struct AdditionalCompletion { - pub start_index: usize, - pub end_index: usize, - pub completion: String, - pub confidence: f64, - pub autocomplete_id: String, - pub logprobs: Option, - pub finish_reason: Option, -} - -fn write_event(event: &zeta_prompt::Event, f: &mut impl fmt::Write) -> fmt::Result { - match event { - zeta_prompt::Event::BufferChange { - old_path, - path, - diff, - .. - } => { - if old_path != path { - // TODO confirm how to do this for sweep - // writeln!(f, "User renamed {:?} to {:?}\n", old_path, new_path)?; - } - - if !diff.is_empty() { - write!(f, "File: {}:\n{}\n", path.display(), diff)? - } - - fmt::Result::Ok(()) - } - } -} - -fn debug_info(cx: &gpui::App) -> Arc { - format!( - "Zed v{version} ({sha}) - OS: {os} - Zed v{version}", - version = release_channel::AppVersion::global(cx), - sha = release_channel::AppCommitSha::try_global(cx) - .map_or("unknown".to_string(), |sha| sha.full()), - os = client::telemetry::os_name(), - ) - .into() -} - -#[derive(Debug, Clone, Copy, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum SweepEventType { - AutocompleteSuggestionShown, - AutocompleteSuggestionAccepted, -} - -#[derive(Debug, Clone, Copy, Serialize)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum SweepSuggestionType { - GhostText, - Popup, - JumpToEdit, -} - -#[derive(Debug, Clone, Serialize)] -struct AutocompleteMetricsRequest { - event_type: SweepEventType, - suggestion_type: SweepSuggestionType, - additions: u32, - deletions: u32, - autocomplete_id: String, - edit_tracking: String, - edit_tracking_line: Option, - lifespan: Option, - debug_info: Arc, - device_id: String, - privacy_mode_enabled: bool, -} - -fn send_autocomplete_metrics_request( - cx: &App, - client: Arc, - api_token: Arc, - request_body: AutocompleteMetricsRequest, -) { - let http_client = client.http_client(); - cx.background_spawn(async move { - let body: AsyncBody = serde_json::to_string(&request_body)?.into(); - - let request = http_client::Request::builder() - .uri(SWEEP_METRICS_URL) - .header("Content-Type", "application/json") - .header("Authorization", format!("Bearer {}", api_token)) - .method(Method::POST) - .body(body)?; - - let mut response = http_client.send(request).await?; - - if !response.status().is_success() { - let mut body = String::new(); - response.body_mut().read_to_string(&mut body).await?; - anyhow::bail!( - "Failed to send autocomplete metrics for sweep_ai: {:?}\nBody: {}", - response.status(), - body, - ); - } - - Ok(()) - }) - .detach_and_log_err(cx); -} - -pub(crate) fn edit_prediction_accepted( - store: &EditPredictionStore, - current_prediction: CurrentEditPrediction, - cx: &App, -) { - let Some(api_token) = store - .sweep_ai - .api_token - .read(cx) - .key(&SWEEP_CREDENTIALS_URL) - else { - return; - }; - let debug_info = store.sweep_ai.debug_info.clone(); - - let prediction = current_prediction.prediction; - - let (additions, deletions) = compute_edit_metrics(&prediction.edits, &prediction.snapshot); - let autocomplete_id = prediction.id.to_string(); - - let device_id = store - .client - .user_id() - .as_ref() - .map(ToString::to_string) - .unwrap_or_default(); - - let suggestion_type = match current_prediction.shown_with { - Some(SuggestionDisplayType::DiffPopover) => SweepSuggestionType::Popup, - Some(SuggestionDisplayType::Jump) => return, // should'nt happen - Some(SuggestionDisplayType::GhostText) | None => SweepSuggestionType::GhostText, - }; - - let request_body = AutocompleteMetricsRequest { - event_type: SweepEventType::AutocompleteSuggestionAccepted, - suggestion_type, - additions, - deletions, - autocomplete_id, - edit_tracking: String::new(), - edit_tracking_line: None, - lifespan: None, - debug_info, - device_id, - privacy_mode_enabled: false, - }; - - send_autocomplete_metrics_request(cx, store.client.clone(), api_token, request_body); -} - -pub fn edit_prediction_shown( - sweep_ai: &SweepAi, - client: Arc, - prediction: &EditPrediction, - display_type: SuggestionDisplayType, - cx: &App, -) { - let Some(api_token) = sweep_ai.api_token.read(cx).key(&SWEEP_CREDENTIALS_URL) else { - return; - }; - let debug_info = sweep_ai.debug_info.clone(); - - let (additions, deletions) = compute_edit_metrics(&prediction.edits, &prediction.snapshot); - let autocomplete_id = prediction.id.to_string(); - - let suggestion_type = match display_type { - SuggestionDisplayType::GhostText => SweepSuggestionType::GhostText, - SuggestionDisplayType::DiffPopover => SweepSuggestionType::Popup, - SuggestionDisplayType::Jump => SweepSuggestionType::JumpToEdit, - }; - - let request_body = AutocompleteMetricsRequest { - event_type: SweepEventType::AutocompleteSuggestionShown, - suggestion_type, - additions, - deletions, - autocomplete_id, - edit_tracking: String::new(), - edit_tracking_line: None, - lifespan: None, - debug_info, - device_id: String::new(), - privacy_mode_enabled: false, - }; - - send_autocomplete_metrics_request(cx, client, api_token, request_body); -} - -fn compute_edit_metrics( - edits: &[(Range, Arc)], - snapshot: &BufferSnapshot, -) -> (u32, u32) { - let mut additions = 0u32; - let mut deletions = 0u32; - - for (range, new_text) in edits { - let old_text = snapshot.text_for_range(range.clone()); - deletions += old_text - .map(|chunk| chunk.lines().count()) - .sum::() - .max(1) as u32; - additions += new_text.lines().count().max(1) as u32; - } - - (additions, deletions) -} diff --git a/crates/edit_prediction/src/zed_edit_prediction_delegate.rs b/crates/edit_prediction/src/zed_edit_prediction_delegate.rs index b5ae954e84ca84505a47761235be71655477a9f7..c5e97fd87eaad9b98aeb9b946a9a69b1c1071db2 100644 --- a/crates/edit_prediction/src/zed_edit_prediction_delegate.rs +++ b/crates/edit_prediction/src/zed_edit_prediction_delegate.rs @@ -10,7 +10,7 @@ use gpui::{App, Entity, prelude::*}; use language::{Buffer, ToPoint as _}; use project::Project; -use crate::{BufferEditPrediction, EditPredictionModel, EditPredictionStore}; +use crate::{BufferEditPrediction, EditPredictionStore}; pub struct ZedEditPredictionDelegate { store: Entity, @@ -103,14 +103,9 @@ impl EditPredictionDelegate for ZedEditPredictionDelegate { &self, _buffer: &Entity, _cursor_position: language::Anchor, - cx: &App, + _cx: &App, ) -> bool { - let store = self.store.read(cx); - if store.edit_prediction_model == EditPredictionModel::Sweep { - store.has_sweep_api_token(cx) - } else { - true - } + true } fn is_refreshing(&self, cx: &App) -> bool { diff --git a/crates/edit_prediction_cli/Cargo.toml b/crates/edit_prediction_cli/Cargo.toml index 1c8985d1480c3746a71cad2c8394b89b59069597..83a78641bc2b14a9ea92cc0eae674135444ac691 100644 --- a/crates/edit_prediction_cli/Cargo.toml +++ b/crates/edit_prediction_cli/Cargo.toml @@ -65,7 +65,7 @@ rand.workspace = true similar = "2.7.0" flate2 = "1.1.8" toml.workspace = true -rust-embed = { workspace = true, features = ["debug-embed"] } +rust-embed.workspace = true gaoya = "0.2.0" # Wasmtime is included as a dependency in order to enable the same diff --git a/crates/edit_prediction_cli/src/anthropic_client.rs b/crates/edit_prediction_cli/src/anthropic_client.rs index 869635c53a15e5c3f6cdaca7632a3e99f0b0bec1..7841e8a2cc1f5236697cae46f071123607c0b2d7 100644 --- a/crates/edit_prediction_cli/src/anthropic_client.rs +++ b/crates/edit_prediction_cli/src/anthropic_client.rs @@ -292,6 +292,14 @@ impl BatchingLlmClient { self.download_finished_batches().await } + pub fn pending_batch_count(&self) -> Result { + let connection = self.connection.lock().unwrap(); + let counts: Vec = connection.select( + sql!(SELECT COUNT(*) FROM cache WHERE batch_id IS NOT NULL AND response IS NULL), + )?()?; + Ok(counts.into_iter().next().unwrap_or(0) as usize) + } + /// Import batch results from external batch IDs (useful for recovering after database loss) pub async fn import_batches(&self, batch_ids: &[String]) -> Result<()> { for batch_id in batch_ids { @@ -831,6 +839,16 @@ impl AnthropicClient { } } + pub fn pending_batch_count(&self) -> Result { + match self { + AnthropicClient::Plain(_) => Ok(0), + AnthropicClient::Batch(batching_llm_client) => { + batching_llm_client.pending_batch_count() + } + AnthropicClient::Dummy => panic!("Dummy LLM client is not expected to be used"), + } + } + pub async fn import_batches(&self, batch_ids: &[String]) -> Result<()> { match self { AnthropicClient::Plain(_) => { diff --git a/crates/edit_prediction_cli/src/example.rs b/crates/edit_prediction_cli/src/example.rs index 196f4f96d99b64aed2ff3ae2d7a9897295a60b29..4827337d37a211056d04cf9ca13f8d49fb91c392 100644 --- a/crates/edit_prediction_cli/src/example.rs +++ b/crates/edit_prediction_cli/src/example.rs @@ -1,4 +1,5 @@ use crate::PredictionProvider; +use crate::metrics::ClassificationMetrics; use crate::paths::WORKTREES_DIR; use crate::qa::QaResult; use anyhow::{Context as _, Result}; @@ -150,6 +151,18 @@ where #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ExampleScore { pub delta_chr_f: f32, + #[serde(default)] + pub delta_chr_f_true_positives: usize, + #[serde(default)] + pub delta_chr_f_false_positives: usize, + #[serde(default)] + pub delta_chr_f_false_negatives: usize, + #[serde(default)] + pub delta_chr_f_precision: f64, + #[serde(default)] + pub delta_chr_f_recall: f64, + #[serde(default)] + pub delta_chr_f_beta: f64, pub braces_disbalance: usize, #[serde(default)] pub exact_lines_tp: usize, @@ -176,6 +189,24 @@ pub struct ExampleScore { pub avg_logprob: Option, } +impl ExampleScore { + pub fn delta_chr_f_counts(&self) -> ClassificationMetrics { + ClassificationMetrics { + true_positives: self.delta_chr_f_true_positives, + false_positives: self.delta_chr_f_false_positives, + false_negatives: self.delta_chr_f_false_negatives, + } + } + + pub fn exact_lines_counts(&self) -> ClassificationMetrics { + ClassificationMetrics { + true_positives: self.exact_lines_tp, + false_positives: self.exact_lines_fp, + false_negatives: self.exact_lines_fn, + } + } +} + impl Example { pub fn repo_name(&self) -> Result> { // git@github.com:owner/repo.git diff --git a/crates/edit_prediction_cli/src/filter_languages.rs b/crates/edit_prediction_cli/src/filter_languages.rs index 355b5708d43c35c74bf62608726309389a1bfe32..989a112a50aa2dd2d922df6895be275a58ff6336 100644 --- a/crates/edit_prediction_cli/src/filter_languages.rs +++ b/crates/edit_prediction_cli/src/filter_languages.rs @@ -13,7 +13,7 @@ //! //! Language is detected based on file extension of the `cursor_path` field. //! The extension-to-language mapping is built from the embedded language -//! config files in the `languages` crate. +//! config files in the `grammars` crate. use anyhow::{Context as _, Result, bail}; use clap::Args; @@ -29,7 +29,7 @@ mod language_configs_embedded { use rust_embed::RustEmbed; #[derive(RustEmbed)] - #[folder = "../languages/src/"] + #[folder = "../grammars/src/"] #[include = "*/config.toml"] pub struct LanguageConfigs; } @@ -123,7 +123,7 @@ fn build_extension_to_language_map() -> HashMap { #[cfg(feature = "dynamic_prompts")] fn build_extension_to_language_map() -> HashMap { - const LANGUAGES_SRC_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../languages/src"); + const LANGUAGES_SRC_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../grammars/src"); let mut map = HashMap::default(); diff --git a/crates/edit_prediction_cli/src/main.rs b/crates/edit_prediction_cli/src/main.rs index 1dcd1d4aa3ad34df853e9d7b193c246f151a61b2..cf9232a04a40df507c187d53becfedcd8db03188 100644 --- a/crates/edit_prediction_cli/src/main.rs +++ b/crates/edit_prediction_cli/src/main.rs @@ -26,6 +26,7 @@ mod split_dataset; mod synthesize; mod truncate_expected_patch; mod word_diff; +use anyhow::Context as _; use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum}; use collections::{HashMap, HashSet}; use edit_prediction::EditPredictionStore; @@ -294,6 +295,9 @@ struct PredictArgs { /// Only use cached responses, don't queue new requests for batching #[clap(long)] cache_only: bool, + /// Wait for all batches to complete before exiting (only applies to batched providers like teacher) + #[clap(long)] + wait: bool, } #[derive(Debug, Args, Clone)] @@ -354,7 +358,6 @@ impl TeacherBackend { #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] enum PredictionProvider { - Sweep, Mercury, Zeta1, Zeta2(ZetaFormat), @@ -375,7 +378,6 @@ impl Default for PredictionProvider { impl std::fmt::Display for PredictionProvider { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - PredictionProvider::Sweep => write!(f, "sweep"), PredictionProvider::Mercury => write!(f, "mercury"), PredictionProvider::Zeta1 => write!(f, "zeta1"), PredictionProvider::Zeta2(format) => write!(f, "zeta2:{format}"), @@ -403,7 +405,6 @@ impl std::str::FromStr for PredictionProvider { let provider_lower = provider.to_lowercase(); match provider_lower.as_str() { - "sweep" => Ok(PredictionProvider::Sweep), "mercury" => Ok(PredictionProvider::Mercury), "zeta1" => Ok(PredictionProvider::Zeta1), "zeta2" => { @@ -448,7 +449,7 @@ impl std::str::FromStr for PredictionProvider { } _ => { anyhow::bail!( - "unknown provider `{provider}`. Valid options: sweep, mercury, zeta1, zeta2, zeta2:, teacher, teacher:, teacher-multi-region, teacher-multi-region:, teacher-non-batching, teacher-multi-region-non-batching, repair\n\ + "unknown provider `{provider}`. Valid options: mercury, zeta1, zeta2, zeta2:, teacher, teacher:, teacher-multi-region, teacher-multi-region:, teacher-non-batching, teacher-multi-region-non-batching, repair\n\ For zeta2, you can optionally specify a version like `zeta2:ordered` or `zeta2:V0113_Ordered`.\n\ For teacher providers, you can specify a backend like `teacher:sonnet46`, `teacher-multi-region:sonnet46`, `teacher-multi-region-non-batching:sonnet46`, or `teacher:gpt52`.\n\ Available zeta versions:\n{}", @@ -762,7 +763,7 @@ async fn load_examples( "skipping Snowflake inputs because --limit is already satisfied by example files" ); } else { - let max_rows_per_timestamp = remaining_limit_for_snowflake.unwrap_or(5000); + let max_rows_per_timestamp = remaining_limit_for_snowflake; if !rejected_after_timestamps.is_empty() { rejected_after_timestamps.sort(); @@ -1339,18 +1340,45 @@ fn main() { Progress::global().finalize(); + let is_markdown = args.markdown; + let write_path = in_place_temp_path.as_ref().or(output.as_ref()); match &command { Command::Predict(args) | Command::Score(args) => { predict::sync_batches(args.provider.as_ref()).await?; + if args.wait { + predict::wait_for_batches(args.provider.as_ref()).await?; + let mut examples = + std::mem::take(&mut *finished_examples.lock().unwrap()); + predict::reprocess_after_batch_wait(&mut examples, args).await?; + rewrite_output(&examples, write_path, is_markdown)?; + *finished_examples.lock().unwrap() = examples; + } } Command::Eval(args) => { predict::sync_batches(args.predict.provider.as_ref()).await?; + if args.predict.wait { + predict::wait_for_batches(args.predict.provider.as_ref()).await?; + let mut examples = + std::mem::take(&mut *finished_examples.lock().unwrap()); + predict::reprocess_after_batch_wait(&mut examples, &args.predict) + .await?; + rewrite_output(&examples, write_path, is_markdown)?; + *finished_examples.lock().unwrap() = examples; + } } Command::Qa(args) => { qa::sync_batches(args).await?; } Command::Repair(args) => { repair::sync_batches(args).await?; + if args.wait { + repair::wait_for_batches(args).await?; + let mut examples = + std::mem::take(&mut *finished_examples.lock().unwrap()); + repair::reprocess_after_batch_wait(&mut examples, args).await?; + rewrite_output(&examples, write_path, is_markdown)?; + *finished_examples.lock().unwrap() = examples; + } } _ => (), } @@ -1391,6 +1419,41 @@ fn main() { }); } +fn rewrite_output( + examples: &[Example], + output_path: Option<&PathBuf>, + markdown: bool, +) -> anyhow::Result<()> { + if markdown { + let dir = output_path.context("--markdown requires -o")?; + for example in examples { + let filename = format!("{}.md", example.spec.filename()); + let path = dir.join(&filename); + let markdown = example.spec.to_markdown(); + std::fs::write(&path, &markdown).context("Failed to write markdown file")?; + } + } else if let Some(path) = output_path { + let file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(path) + .context("Failed to open output file for rewriting")?; + let mut writer = BufWriter::new(file); + for example in examples { + let line = serde_json::to_string(example)?; + writeln!(writer, "{}", line)?; + } + writer.flush()?; + } else { + for example in examples { + let line = serde_json::to_string(example)?; + println!("{}", line); + } + } + Ok(()) +} + async fn handle_error( error: anyhow::Error, args: &EpArgs, diff --git a/crates/edit_prediction_cli/src/metrics.rs b/crates/edit_prediction_cli/src/metrics.rs index 1bfd8e542fa3d74b55f091d2ac13aa22883f6a2f..8037699f4bb6f851fdadb05b435b090b911b010a 100644 --- a/crates/edit_prediction_cli/src/metrics.rs +++ b/crates/edit_prediction_cli/src/metrics.rs @@ -48,6 +48,12 @@ impl ClassificationMetrics { } } + pub fn accumulate(&mut self, other: &ClassificationMetrics) { + self.true_positives += other.true_positives; + self.false_positives += other.false_positives; + self.false_negatives += other.false_negatives; + } + pub fn precision(&self) -> f64 { if self.true_positives + self.false_positives == 0 { 0.0 @@ -89,10 +95,23 @@ enum ChrfWhitespace { } const CHR_F_CHAR_ORDER: usize = 6; -const CHR_F_BETA: f64 = 2.0; +const CHR_F_BETA: f64 = 0.5; const CHR_F_WHITESPACE: ChrfWhitespace = ChrfWhitespace::Collapse; -/// Computes a delta-chrF score that compares two sets of edits. +pub fn delta_chr_f_beta() -> f64 { + CHR_F_BETA +} + +#[derive(Default, Debug, Clone)] +pub struct DeltaChrFMetrics { + pub score: f64, + pub beta: f64, + pub counts: ClassificationMetrics, + pub precision: f64, + pub recall: f64, +} + +/// Computes delta-chrF metrics that compare two sets of edits. /// /// This metric works by: /// 1. Computing n-gram count differences (deltas) between original→expected and original→actual @@ -100,13 +119,17 @@ const CHR_F_WHITESPACE: ChrfWhitespace = ChrfWhitespace::Collapse; /// /// Returns a score from 0.0 to 100.0, where 100.0 means the actual edits perfectly match /// the expected edits. -pub fn delta_chr_f(original: &str, expected: &str, actual: &str) -> f64 { - // Edge case: if all texts are identical, the edits match perfectly +pub fn delta_chr_f(original: &str, expected: &str, actual: &str) -> DeltaChrFMetrics { if original == expected && expected == actual { - return 100.0; + return DeltaChrFMetrics { + score: 100.0, + beta: CHR_F_BETA, + precision: 1.0, + recall: 1.0, + ..DeltaChrFMetrics::default() + }; } - // Pre-filter whitespace once for all texts let orig_chars: Vec = filter_whitespace_chars(original); let exp_chars: Vec = filter_whitespace_chars(expected); let act_chars: Vec = filter_whitespace_chars(actual); @@ -118,9 +141,9 @@ pub fn delta_chr_f(original: &str, expected: &str, actual: &str) -> f64 { let mut total_precision = 0.0; let mut total_recall = 0.0; + let mut total_counts = ClassificationMetrics::default(); for order in 1..=CHR_F_CHAR_ORDER { - // Compute n-grams only on the affected regions let orig_ngrams_for_exp = count_ngrams_from_chars(&orig_for_exp, order); let exp_ngrams = count_ngrams_from_chars(&exp_region, order); let expected_delta = compute_ngram_delta(&exp_ngrams, &orig_ngrams_for_exp); @@ -138,28 +161,43 @@ pub fn delta_chr_f(original: &str, expected: &str, actual: &str) -> f64 { let expected_counts = ngram_delta_to_counts(&expected_delta); let actual_counts = ngram_delta_to_counts(&actual_delta); - let score = ClassificationMetrics::from_counts(&expected_counts, &actual_counts); - total_precision += score.precision(); - total_recall += score.recall(); + let counts = ClassificationMetrics::from_counts(&expected_counts, &actual_counts); + total_precision += counts.precision(); + total_recall += counts.recall(); + total_counts.accumulate(&counts); } - let prec = total_precision / CHR_F_CHAR_ORDER as f64; - let recall = total_recall / CHR_F_CHAR_ORDER as f64; - let f_score = if prec + recall == 0.0 { + let average_precision = total_precision / CHR_F_CHAR_ORDER as f64; + let average_recall = total_recall / CHR_F_CHAR_ORDER as f64; + let score = if average_precision + average_recall == 0.0 { 0.0 } else { - (1.0 + CHR_F_BETA * CHR_F_BETA) * prec * recall / (CHR_F_BETA * CHR_F_BETA * prec + recall) + (1.0 + CHR_F_BETA * CHR_F_BETA) * average_precision * average_recall + / (CHR_F_BETA * CHR_F_BETA * average_precision + average_recall) + * 100.0 }; - f_score * 100.0 + DeltaChrFMetrics { + score, + beta: CHR_F_BETA, + counts: total_counts, + precision: average_precision, + recall: average_recall, + } } -/// Reference implementation of delta_chr_f (original, non-optimized version). +/// Reference implementation of delta-chrF metrics (original, non-optimized version). /// Used for testing that the optimized version produces identical results. #[cfg(test)] -fn delta_chr_f_reference(original: &str, expected: &str, actual: &str) -> f64 { +fn delta_chr_f_reference(original: &str, expected: &str, actual: &str) -> DeltaChrFMetrics { if original == expected && expected == actual { - return 100.0; + return DeltaChrFMetrics { + score: 100.0, + beta: CHR_F_BETA, + precision: 1.0, + recall: 1.0, + ..DeltaChrFMetrics::default() + }; } let original_ngrams = chr_f_ngram_counts(original); @@ -168,6 +206,7 @@ fn delta_chr_f_reference(original: &str, expected: &str, actual: &str) -> f64 { let mut total_precision = 0.0; let mut total_recall = 0.0; + let mut total_counts = ClassificationMetrics::default(); for order in 0..CHR_F_CHAR_ORDER { let expected_delta = compute_ngram_delta(&expected_ngrams[order], &original_ngrams[order]); @@ -182,20 +221,29 @@ fn delta_chr_f_reference(original: &str, expected: &str, actual: &str) -> f64 { let expected_counts = ngram_delta_to_counts(&expected_delta); let actual_counts = ngram_delta_to_counts(&actual_delta); - let score = ClassificationMetrics::from_counts(&expected_counts, &actual_counts); - total_precision += score.precision(); - total_recall += score.recall(); + let counts = ClassificationMetrics::from_counts(&expected_counts, &actual_counts); + total_precision += counts.precision(); + total_recall += counts.recall(); + total_counts.accumulate(&counts); } - let prec = total_precision / CHR_F_CHAR_ORDER as f64; - let recall = total_recall / CHR_F_CHAR_ORDER as f64; - let f_score = if prec + recall == 0.0 { + let average_precision = total_precision / CHR_F_CHAR_ORDER as f64; + let average_recall = total_recall / CHR_F_CHAR_ORDER as f64; + let score = if average_precision + average_recall == 0.0 { 0.0 } else { - (1.0 + CHR_F_BETA * CHR_F_BETA) * prec * recall / (CHR_F_BETA * CHR_F_BETA * prec + recall) + (1.0 + CHR_F_BETA * CHR_F_BETA) * average_precision * average_recall + / (CHR_F_BETA * CHR_F_BETA * average_precision + average_recall) + * 100.0 }; - f_score * 100.0 + DeltaChrFMetrics { + score, + beta: CHR_F_BETA, + counts: total_counts, + precision: average_precision, + recall: average_recall, + } } /// Filter whitespace from a string and return as Vec @@ -664,7 +712,7 @@ mod test_optimization { ]; for (original, expected, actual) in test_cases { - let score = delta_chr_f(original, expected, actual); + let score = delta_chr_f(original, expected, actual).score; // Just verify it produces a reasonable score (0-100) assert!( score >= 0.0 && score <= 100.0, @@ -733,20 +781,51 @@ mod test_optimization { ]; for (original, expected, actual) in test_cases { - let optimized_score = delta_chr_f(original, expected, actual); - let reference_score = delta_chr_f_reference(original, expected, actual); + let optimized_metrics = delta_chr_f(original, expected, actual); + let reference_metrics = delta_chr_f_reference(original, expected, actual); assert!( - (optimized_score - reference_score).abs() < 1e-10, - "Mismatch for ({:?}, {:?}, {:?}):\n optimized: {}\n reference: {}", + (optimized_metrics.score - reference_metrics.score).abs() < 1e-10, + "Score mismatch for ({:?}, {:?}, {:?}):\n optimized: {}\n reference: {}", original, expected, actual, - optimized_score, - reference_score + optimized_metrics.score, + reference_metrics.score + ); + assert_eq!( + optimized_metrics.counts.true_positives, + reference_metrics.counts.true_positives + ); + assert_eq!( + optimized_metrics.counts.false_positives, + reference_metrics.counts.false_positives ); + assert_eq!( + optimized_metrics.counts.false_negatives, + reference_metrics.counts.false_negatives + ); + assert!((optimized_metrics.precision - reference_metrics.precision).abs() < 1e-10); + assert!((optimized_metrics.recall - reference_metrics.recall).abs() < 1e-10); } } + + #[test] + fn test_delta_chr_f_metrics_include_counts_and_rates() { + let original = "one two three"; + let expected = "one three"; + let actual = "one two four"; + + let metrics = delta_chr_f(original, expected, actual); + + assert!(metrics.score > 20.0 && metrics.score < 40.0); + assert!(metrics.counts.true_positives > 0); + assert!(metrics.counts.false_positives > 0); + assert!(metrics.counts.false_negatives > 0); + assert!(metrics.precision > 0.0 && metrics.precision < 1.0); + assert!(metrics.recall > 0.0 && metrics.recall < 1.0); + assert_eq!(metrics.beta, CHR_F_BETA); + } } #[cfg(test)] @@ -770,7 +849,7 @@ mod test { let original = "fn main() { println!(\"Hello\");}"; let expected = "fn main() { println!(\"Hello, World!\");}"; - let score = delta_chr_f(original, expected, expected); + let score = delta_chr_f(original, expected, expected).score; assert!((score - 100.0).abs() < 1e-2); } @@ -782,7 +861,7 @@ mod test { let actual = "one two four"; // deleted "three", added "four" // Then the score should be low - let score = delta_chr_f(original, expected, actual); + let score = delta_chr_f(original, expected, actual).score; assert!(score > 20.0 && score < 40.0); } @@ -794,7 +873,7 @@ mod test { // We got the edit location right, but the replacement text is wrong. // Deleted ngrams will match, bringing the score somewhere in the middle. - let score = delta_chr_f(original, expected, actual); + let score = delta_chr_f(original, expected, actual).score; assert!(score > 40.0 && score < 60.0); } @@ -806,7 +885,7 @@ mod test { let actual = "prefix old suffix"; // no change // Then the score should be low (all expected changes are false negatives) - let score = delta_chr_f(original, expected, actual); + let score = delta_chr_f(original, expected, actual).score; assert!(score < 20.0); } @@ -818,14 +897,14 @@ mod test { let actual = "helloextraworld"; // added "extra" // Then the score should be low (all actual changes are false positives) - let score = delta_chr_f(original, expected, actual); + let score = delta_chr_f(original, expected, actual).score; assert!(score < 20.0); } #[test] fn test_delta_chr_f_no_changes() { let text = "unchanged text"; - let score = delta_chr_f(text, text, text); + let score = delta_chr_f(text, text, text).score; assert!((score - 100.0).abs() < 1e-2); } diff --git a/crates/edit_prediction_cli/src/openai_client.rs b/crates/edit_prediction_cli/src/openai_client.rs index 6bc9c2d77c0d6be6e2955182ebbce096be422945..e35848aa1ccbd46d29f88a6c9a0ccfd35309114a 100644 --- a/crates/edit_prediction_cli/src/openai_client.rs +++ b/crates/edit_prediction_cli/src/openai_client.rs @@ -214,6 +214,14 @@ impl BatchingOpenAiClient { self.download_finished_batches().await } + pub fn pending_batch_count(&self) -> Result { + let connection = self.connection.lock().unwrap(); + let counts: Vec = connection.select( + sql!(SELECT COUNT(*) FROM openai_cache WHERE batch_id IS NOT NULL AND response IS NULL), + )?()?; + Ok(counts.into_iter().next().unwrap_or(0) as usize) + } + pub async fn import_batches(&self, batch_ids: &[String]) -> Result<()> { for batch_id in batch_ids { log::info!("Importing OpenAI batch {}", batch_id); @@ -672,6 +680,14 @@ impl OpenAiClient { } } + pub fn pending_batch_count(&self) -> Result { + match self { + OpenAiClient::Plain(_) => Ok(0), + OpenAiClient::Batch(batching_client) => batching_client.pending_batch_count(), + OpenAiClient::Dummy => panic!("Dummy OpenAI client is not expected to be used"), + } + } + pub async fn import_batches(&self, batch_ids: &[String]) -> Result<()> { match self { OpenAiClient::Plain(_) => { diff --git a/crates/edit_prediction_cli/src/predict.rs b/crates/edit_prediction_cli/src/predict.rs index df797b0abaa4933e73e40b746797ffb5581d7f79..f2a55455b36326b58daa0adada7ec39124ffc317 100644 --- a/crates/edit_prediction_cli/src/predict.rs +++ b/crates/edit_prediction_cli/src/predict.rs @@ -8,7 +8,7 @@ use crate::{ openai_client::OpenAiClient, parse_output::parse_prediction_output, paths::{LATEST_EXAMPLE_RUN_DIR, RUN_DIR}, - progress::{ExampleProgress, InfoStyle, Step, StepProgress}, + progress::{ExampleProgress, InfoStyle, Progress, Step, StepProgress}, retrieve_context::run_context_retrieval, }; use anyhow::Context as _; @@ -137,7 +137,6 @@ pub async fn run_prediction( let model = match provider { PredictionProvider::Zeta1 => edit_prediction::EditPredictionModel::Zeta, PredictionProvider::Zeta2(_) => edit_prediction::EditPredictionModel::Zeta, - PredictionProvider::Sweep => edit_prediction::EditPredictionModel::Sweep, PredictionProvider::Mercury => edit_prediction::EditPredictionModel::Mercury, PredictionProvider::Teacher(..) | PredictionProvider::TeacherMultiRegion(..) @@ -699,3 +698,86 @@ pub async fn sync_batches(provider: Option<&PredictionProvider>) -> anyhow::Resu }; Ok(()) } + +pub async fn reprocess_after_batch_wait( + examples: &mut [Example], + args: &PredictArgs, +) -> anyhow::Result<()> { + let Some(PredictionProvider::Teacher(backend)) = args.provider else { + return Ok(()); + }; + + let mut reprocessed = 0; + for example in examples.iter_mut() { + let has_prediction = example + .predictions + .iter() + .any(|p| p.actual_patch.is_some() || !p.actual_output.is_empty()); + if has_prediction || example.prompt.is_none() { + continue; + } + + let example_progress = Progress::global().start_group(&example.spec.name); + let step_progress = example_progress.start(Step::Predict); + predict_teacher( + example, + backend, + true, + args.repetitions, + false, + &step_progress, + ) + .await?; + reprocessed += 1; + } + + if reprocessed > 0 { + eprintln!("Reprocessed {} example(s) with batch results", reprocessed); + } + + Ok(()) +} + +pub async fn wait_for_batches(provider: Option<&PredictionProvider>) -> anyhow::Result<()> { + let poll_interval = std::time::Duration::from_secs(30); + + loop { + let pending = pending_batch_count(provider)?; + if pending == 0 { + break; + } + + eprintln!( + "Waiting for {} pending batch request(s) to complete... (polling every {}s)", + pending, + poll_interval.as_secs() + ); + std::thread::sleep(poll_interval); + + sync_batches(provider).await?; + } + + Ok(()) +} + +fn pending_batch_count(provider: Option<&PredictionProvider>) -> anyhow::Result { + match provider { + Some(PredictionProvider::Teacher(backend)) => match backend { + TeacherBackend::Sonnet45 | TeacherBackend::Sonnet46 => { + let llm_client = ANTHROPIC_CLIENT.get_or_init(|| { + AnthropicClient::batch(&crate::paths::LLM_CACHE_DB) + .expect("Failed to create Anthropic client") + }); + llm_client.pending_batch_count() + } + TeacherBackend::Gpt52 => { + let llm_client = OPENAI_CLIENT.get_or_init(|| { + OpenAiClient::batch(&crate::paths::LLM_CACHE_DB) + .expect("Failed to create OpenAI client") + }); + llm_client.pending_batch_count() + } + }, + _ => Ok(0), + } +} diff --git a/crates/edit_prediction_cli/src/pull_examples.rs b/crates/edit_prediction_cli/src/pull_examples.rs index 15591ae03ccd7b0d537b437c1da2c0898e7e9446..9ea8ac3bda1fa17295dab29bb3d5c78eaa54d765 100644 --- a/crates/edit_prediction_cli/src/pull_examples.rs +++ b/crates/edit_prediction_cli/src/pull_examples.rs @@ -5,6 +5,7 @@ use http_client::{AsyncBody, HttpClient, Method, Request}; use indoc::indoc; use serde::Deserialize; use serde_json::{Value as JsonValue, json}; +use std::collections::HashMap; use std::fmt::Write as _; use std::io::Read; use std::sync::Arc; @@ -13,17 +14,14 @@ use telemetry_events::EditPredictionRating; use zeta_prompt::{ZetaFormat, ZetaPromptInput, excerpt_range_for_format}; -use crate::example::Example; +use crate::PredictionProvider; +use crate::example::{Example, ExamplePrompt}; use crate::progress::{InfoStyle, Progress, Step}; -const EDIT_PREDICTION_DEPLOYMENT_EVENT: &str = "Edit Prediction Deployment"; use edit_prediction::example_spec::{ExampleSpec, TelemetrySource}; pub(crate) const SNOWFLAKE_SUCCESS_CODE: &str = "090001"; pub(crate) const SNOWFLAKE_ASYNC_IN_PROGRESS_CODE: &str = "333334"; -const PREDICTIVE_EDIT_REQUESTED_EVENT: &str = "Predictive Edit Requested"; -const PREDICTIVE_EDIT_REJECTED_EVENT: &str = "Predictive Edit Rejected"; -const EDIT_PREDICTION_RATED_EVENT: &str = "Edit Prediction Rated"; -const EDIT_PREDICTION_SETTLED_EVENT: &str = "Edit Prediction Settled"; +const SNOWFLAKE_TIMEOUT_CODE: &str = "000630"; /// Minimum Zed version for filtering captured examples. /// For example, `MinCaptureVersion { minor: 224, patch: 1 }` means only pull examples @@ -34,10 +32,13 @@ pub struct MinCaptureVersion { pub patch: u32, } -const DEFAULT_STATEMENT_TIMEOUT_SECONDS: u64 = 240; -const SETTLED_STATEMENT_TIMEOUT_SECONDS: u64 = 240; pub(crate) const POLL_INTERVAL: Duration = Duration::from_secs(2); -pub(crate) const MAX_POLL_ATTEMPTS: usize = 120; +const PARTITION_FETCH_MAX_RETRIES: usize = 3; +const PARTITION_FETCH_RETRY_DELAYS: [Duration; PARTITION_FETCH_MAX_RETRIES] = [ + Duration::from_millis(500), + Duration::from_secs(1), + Duration::from_secs(2), +]; /// Parse an input token of the form `captured-after:{timestamp}`. pub fn parse_captured_after_input(input: &str) -> Option<&str> { @@ -127,26 +128,25 @@ async fn run_sql_with_polling( .context("async query response missing statementHandle")? .clone(); - for attempt in 1..=MAX_POLL_ATTEMPTS { + for attempt in 0.. { step_progress.set_substatus(format!("polling ({attempt})")); background_executor.timer(POLL_INTERVAL).await; - response = - fetch_partition(http_client.clone(), base_url, token, &statement_handle, 0).await?; + response = fetch_partition_with_retries( + http_client.clone(), + base_url, + token, + &statement_handle, + 0, + background_executor.clone(), + ) + .await?; if response.code.as_deref() != Some(SNOWFLAKE_ASYNC_IN_PROGRESS_CODE) { break; } } - - if response.code.as_deref() == Some(SNOWFLAKE_ASYNC_IN_PROGRESS_CODE) { - anyhow::bail!( - "query still running after {} poll attempts ({} seconds)", - MAX_POLL_ATTEMPTS, - MAX_POLL_ATTEMPTS as u64 * POLL_INTERVAL.as_secs() - ); - } } Ok(response) @@ -158,19 +158,29 @@ struct SnowflakeConfig { role: Option, } -async fn fetch_examples_with_query( +#[derive(Clone)] +struct QueryRetryState { + resume_after: String, + remaining_limit: Option, + offset: usize, +} + +async fn fetch_examples_with_query( http_client: Arc, step_progress: &crate::progress::StepProgress, background_executor: BackgroundExecutor, statement: &str, - bindings: JsonValue, - timeout_seconds: u64, + initial_retry_state: QueryRetryState, + make_bindings: MakeBindings, required_columns: &[&str], parse_response: for<'a> fn( &'a SnowflakeStatementResponse, - &'a std::collections::HashMap, + &'a HashMap, ) -> Result + 'a>>, -) -> Result> { +) -> Result> +where + MakeBindings: Fn(&QueryRetryState) -> JsonValue, +{ let snowflake = SnowflakeConfig { token: std::env::var("EP_SNOWFLAKE_API_KEY") .context("missing required environment variable EP_SNOWFLAKE_API_KEY")?, @@ -179,74 +189,153 @@ async fn fetch_examples_with_query( )?, role: std::env::var("EP_SNOWFLAKE_ROLE").ok(), }; - let request = json!({ - "statement": statement, - "timeout": timeout_seconds, - "database": "EVENTS", - "schema": "PUBLIC", - "warehouse": "DBT", - "role": snowflake.role.as_deref(), - "bindings": bindings - }); - let response = run_sql_with_polling( - http_client.clone(), - &snowflake.base_url, - &snowflake.token, - &request, - step_progress, - background_executor, - ) - .await?; - - let total_rows = response - .result_set_meta_data - .as_ref() - .and_then(|meta| meta.num_rows) - .unwrap_or(response.data.len() as i64); - let partition_count = response - .result_set_meta_data - .as_ref() - .map(|meta| meta.partition_info.len()) - .unwrap_or(1) - .max(1); - - step_progress.set_info(format!("{} rows", total_rows), InfoStyle::Normal); - step_progress.set_substatus("parsing"); - - let column_indices = get_column_indices(&response.result_set_meta_data, required_columns); - - let mut parsed_examples = Vec::with_capacity(total_rows as usize); - parsed_examples.extend(parse_response(&response, &column_indices)?); - - if partition_count > 1 { - let statement_handle = response - .statement_handle + let mut requested_columns = required_columns.to_vec(); + if !requested_columns.contains(&"continuation_time") { + requested_columns.push("continuation_time"); + } + + let mut parsed_examples = Vec::new(); + let mut retry_state = initial_retry_state; + let mut retry_count = 0usize; + + loop { + let bindings = make_bindings(&retry_state); + let request = json!({ + "statement": statement, + "database": "EVENTS", + "schema": "PUBLIC", + "warehouse": "DBT", + "role": snowflake.role.as_deref(), + "bindings": bindings + }); + + let response = match run_sql_with_polling( + http_client.clone(), + &snowflake.base_url, + &snowflake.token, + &request, + step_progress, + background_executor.clone(), + ) + .await + { + Ok(response) => response, + Err(error) => { + if is_snowflake_timeout_error(&error) && !parsed_examples.is_empty() { + retry_count += 1; + step_progress.set_substatus(format!( + "retrying from {} ({retry_count})", + retry_state.resume_after + )); + continue; + } + + return Err(error); + } + }; + + let total_rows = response + .result_set_meta_data + .as_ref() + .and_then(|meta| meta.num_rows) + .unwrap_or(response.data.len() as i64); + let partition_count = response + .result_set_meta_data .as_ref() - .context("response has multiple partitions but no statementHandle")?; + .map(|meta| meta.partition_info.len()) + .unwrap_or(1) + .max(1); - for partition in 1..partition_count { - step_progress.set_substatus(format!( - "fetching partition {}/{}", - partition + 1, - partition_count - )); + step_progress.set_info(format!("{} rows", total_rows), InfoStyle::Normal); + step_progress.set_substatus("parsing"); - let partition_response = fetch_partition( - http_client.clone(), - &snowflake.base_url, - &snowflake.token, - statement_handle, - partition, - ) - .await?; + let column_indices = get_column_indices(&response.result_set_meta_data, &requested_columns); + let mut rows_fetched_this_attempt = 0usize; + let mut timed_out_fetching_partition = false; + + parsed_examples.extend(parse_response(&response, &column_indices)?); + rows_fetched_this_attempt += response.data.len(); + let mut last_continuation_time_this_attempt = + last_continuation_timestamp_from_response(&response, &column_indices); - parsed_examples.extend(parse_response(&partition_response, &column_indices)?); + if partition_count > 1 { + let statement_handle = response + .statement_handle + .as_ref() + .context("response has multiple partitions but no statementHandle")?; + + for partition in 1..partition_count { + step_progress.set_substatus(format!( + "fetching partition {}/{}", + partition + 1, + partition_count + )); + + let partition_response = match fetch_partition_with_retries( + http_client.clone(), + &snowflake.base_url, + &snowflake.token, + statement_handle, + partition, + background_executor.clone(), + ) + .await + { + Ok(response) => response, + Err(error) => { + if is_snowflake_timeout_error(&error) && rows_fetched_this_attempt > 0 { + timed_out_fetching_partition = true; + break; + } + + return Err(error); + } + }; + + parsed_examples.extend(parse_response(&partition_response, &column_indices)?); + rows_fetched_this_attempt += partition_response.data.len(); + + if let Some(partition_continuation_time) = + last_continuation_timestamp_from_response(&partition_response, &column_indices) + { + last_continuation_time_this_attempt = Some(partition_continuation_time); + } + } } - } - step_progress.set_substatus("done"); - Ok(parsed_examples) + if rows_fetched_this_attempt == 0 { + step_progress.set_substatus("done"); + return Ok(parsed_examples); + } + + if let Some(remaining_limit_value) = &mut retry_state.remaining_limit { + *remaining_limit_value = + remaining_limit_value.saturating_sub(rows_fetched_this_attempt); + if *remaining_limit_value == 0 { + step_progress.set_substatus("done"); + return Ok(parsed_examples); + } + } + + if !timed_out_fetching_partition { + step_progress.set_substatus("done"); + return Ok(parsed_examples); + } + + let Some(last_continuation_time_this_attempt) = last_continuation_time_this_attempt else { + step_progress.set_substatus("done"); + return Ok(parsed_examples); + }; + + retry_state.resume_after = last_continuation_time_this_attempt; + retry_state.offset = 0; + retry_count += 1; + step_progress.set_substatus(format!( + "retrying from {} ({retry_count})", + retry_state.resume_after + )); + } } pub(crate) async fn fetch_partition( @@ -338,6 +427,57 @@ pub(crate) async fn fetch_partition( }) } +async fn fetch_partition_with_retries( + http_client: Arc, + base_url: &str, + token: &str, + statement_handle: &str, + partition: usize, + background_executor: BackgroundExecutor, +) -> Result { + let mut last_error = None; + + for retry_attempt in 0..=PARTITION_FETCH_MAX_RETRIES { + match fetch_partition( + http_client.clone(), + base_url, + token, + statement_handle, + partition, + ) + .await + { + Ok(response) => return Ok(response), + Err(error) => { + if retry_attempt == PARTITION_FETCH_MAX_RETRIES + || !is_transient_partition_fetch_error(&error) + { + return Err(error); + } + + last_error = Some(error); + background_executor + .timer(PARTITION_FETCH_RETRY_DELAYS[retry_attempt]) + .await; + } + } + } + + match last_error { + Some(error) => Err(error), + None => anyhow::bail!("partition fetch retry loop exited without a result"), + } +} + +fn is_transient_partition_fetch_error(error: &anyhow::Error) -> bool { + error.chain().any(|cause| { + let message = cause.to_string(); + message.contains("failed to read Snowflake SQL API partition response body") + || message.contains("unexpected EOF") + || message.contains("peer closed connection without sending TLS close_notify") + }) +} + pub(crate) async fn run_sql( http_client: Arc, base_url: &str, @@ -379,19 +519,32 @@ pub(crate) async fn run_sql( bytes }; - if !status.is_success() && status.as_u16() != 202 { + let snowflake_response = serde_json::from_slice::(&body_bytes) + .context("failed to parse Snowflake SQL API response JSON")?; + + if !status.is_success() && status.as_u16() != 202 && !is_timeout_response(&snowflake_response) { let body_text = String::from_utf8_lossy(&body_bytes); anyhow::bail!("snowflake sql api http {}: {}", status.as_u16(), body_text); } - serde_json::from_slice::(&body_bytes) - .context("failed to parse Snowflake SQL API response JSON") + if is_timeout_response(&snowflake_response) { + anyhow::bail!( + "snowflake sql api timed out code={} message={}", + snowflake_response.code.as_deref().unwrap_or(""), + snowflake_response + .message + .as_deref() + .unwrap_or("") + ); + } + + Ok(snowflake_response) } pub async fn fetch_rejected_examples_after( http_client: Arc, after_timestamps: &[String], - max_rows_per_timestamp: usize, + max_rows_per_timestamp: Option, offset: usize, background_executor: BackgroundExecutor, min_capture_version: Option, @@ -416,55 +569,53 @@ pub async fn fetch_rejected_examples_after( let statement = indoc! {r#" SELECT - req.event_properties:request_id::string AS request_id, - req.device_id::string AS device_id, - req.time::string AS time, - req.event_properties:input AS input, - req.event_properties:prompt::string AS prompt, - req.event_properties:output::string AS output, - rej.event_properties:was_shown::boolean AS was_shown, - rej.event_properties:reason::string AS reason, - req.event_properties:zed_version::string AS zed_version - FROM events req - INNER JOIN events rej - ON req.event_properties:request_id = rej.event_properties:request_id - WHERE req.event_type = ? - AND rej.event_type = ? - AND req.event_properties:version = 'V3' - AND rej.event_properties:was_shown = true - AND req.event_properties:input:can_collect_data = true - AND req.time > TRY_TO_TIMESTAMP_NTZ(?) + ep_request_id AS request_id, + device_id AS device_id, + requested_at::string AS continuation_time, + requested_at::string AS time, + input_payload AS input, + prompt AS prompt, + requested_output AS output, + is_ep_shown_before_rejected AS was_shown, + ep_rejected_reason AS reason, + zed_version AS zed_version + FROM ZED_DBT.DBT_PROD.fct_edit_prediction_examples + WHERE ep_outcome LIKE 'Rejected%' + AND is_ep_shown_before_rejected = true + AND requested_at > TRY_TO_TIMESTAMP_NTZ(?) AND (? IS NULL OR ( - TRY_CAST(SPLIT_PART(req.event_properties:zed_version::string, '.', 2) AS INTEGER) > ? + TRY_CAST(SPLIT_PART(zed_version, '.', 2) AS INTEGER) > ? OR ( - TRY_CAST(SPLIT_PART(req.event_properties:zed_version::string, '.', 2) AS INTEGER) = ? - AND TRY_CAST(SPLIT_PART(SPLIT_PART(req.event_properties:zed_version::string, '.', 3), '+', 1) AS INTEGER) >= ? + TRY_CAST(SPLIT_PART(zed_version, '.', 2) AS INTEGER) = ? + AND TRY_CAST(SPLIT_PART(SPLIT_PART(zed_version, '.', 3), '+', 1) AS INTEGER) >= ? ) )) - ORDER BY req.time ASC + ORDER BY requested_at ASC LIMIT ? OFFSET ? "#}; - let bindings = json!({ - "1": { "type": "TEXT", "value": PREDICTIVE_EDIT_REQUESTED_EVENT }, - "2": { "type": "TEXT", "value": PREDICTIVE_EDIT_REJECTED_EVENT }, - "3": { "type": "TEXT", "value": after_date }, - "4": { "type": "FIXED", "value": min_minor_str_ref }, - "5": { "type": "FIXED", "value": min_minor_str_ref }, - "6": { "type": "FIXED", "value": min_minor_str_ref }, - "7": { "type": "FIXED", "value": min_patch_str_ref }, - "8": { "type": "FIXED", "value": max_rows_per_timestamp.to_string() }, - "9": { "type": "FIXED", "value": offset.to_string() } - }); - let examples = fetch_examples_with_query( http_client.clone(), &step_progress, background_executor.clone(), statement, - bindings, - DEFAULT_STATEMENT_TIMEOUT_SECONDS, + QueryRetryState { + resume_after: after_date.clone(), + remaining_limit: max_rows_per_timestamp, + offset, + }, + |retry_state| { + json!({ + "1": { "type": "TEXT", "value": retry_state.resume_after }, + "2": { "type": "FIXED", "value": min_minor_str_ref }, + "3": { "type": "FIXED", "value": min_minor_str_ref }, + "4": { "type": "FIXED", "value": min_minor_str_ref }, + "5": { "type": "FIXED", "value": min_patch_str_ref }, + "6": { "type": "FIXED", "value": format_limit(retry_state.remaining_limit) }, + "7": { "type": "FIXED", "value": retry_state.offset.to_string() } + }) + }, &[ "request_id", "device_id", @@ -486,10 +637,14 @@ pub async fn fetch_rejected_examples_after( Ok(all_examples) } +fn format_limit(limit: Option) -> String { + return limit.map(|l| l.to_string()).unwrap_or("NULL".to_string()); +} + pub async fn fetch_requested_examples_after( http_client: Arc, after_timestamps: &[String], - max_rows_per_timestamp: usize, + max_rows_per_timestamp: Option, offset: usize, background_executor: BackgroundExecutor, min_capture_version: Option, @@ -514,46 +669,47 @@ pub async fn fetch_requested_examples_after( let statement = indoc! {r#" SELECT - req.event_properties:request_id::string AS request_id, - req.device_id::string AS device_id, - req.time::string AS time, - req.event_properties:input AS input, - req.event_properties:zed_version::string AS zed_version - FROM events req - WHERE req.event_type = ? - AND req.event_properties:version = 'V3' - AND req.event_properties:input:can_collect_data = true - AND req.time > TRY_TO_TIMESTAMP_NTZ(?) + ep_request_id AS request_id, + device_id AS device_id, + requested_at::string AS continuation_time, + requested_at::string AS time, + input_payload AS input, + zed_version AS zed_version + FROM ZED_DBT.DBT_PROD.fct_edit_prediction_examples + WHERE requested_at > TRY_TO_TIMESTAMP_NTZ(?) AND (? IS NULL OR ( - TRY_CAST(SPLIT_PART(req.event_properties:zed_version::string, '.', 2) AS INTEGER) > ? + TRY_CAST(SPLIT_PART(zed_version, '.', 2) AS INTEGER) > ? OR ( - TRY_CAST(SPLIT_PART(req.event_properties:zed_version::string, '.', 2) AS INTEGER) = ? - AND TRY_CAST(SPLIT_PART(SPLIT_PART(req.event_properties:zed_version::string, '.', 3), '+', 1) AS INTEGER) >= ? + TRY_CAST(SPLIT_PART(zed_version, '.', 2) AS INTEGER) = ? + AND TRY_CAST(SPLIT_PART(SPLIT_PART(zed_version, '.', 3), '+', 1) AS INTEGER) >= ? ) )) - ORDER BY req.time ASC + ORDER BY requested_at ASC LIMIT ? OFFSET ? "#}; - let bindings = json!({ - "1": { "type": "TEXT", "value": PREDICTIVE_EDIT_REQUESTED_EVENT }, - "2": { "type": "TEXT", "value": after_date }, - "3": { "type": "FIXED", "value": min_minor_str_ref }, - "4": { "type": "FIXED", "value": min_minor_str_ref }, - "5": { "type": "FIXED", "value": min_minor_str_ref }, - "6": { "type": "FIXED", "value": min_patch_str_ref }, - "7": { "type": "FIXED", "value": max_rows_per_timestamp.to_string() }, - "8": { "type": "FIXED", "value": offset.to_string() } - }); - let examples = fetch_examples_with_query( http_client.clone(), &step_progress, background_executor.clone(), statement, - bindings, - DEFAULT_STATEMENT_TIMEOUT_SECONDS, + QueryRetryState { + resume_after: after_date.clone(), + remaining_limit: max_rows_per_timestamp, + offset, + }, + |retry_state| { + json!({ + "1": { "type": "TEXT", "value": retry_state.resume_after }, + "2": { "type": "FIXED", "value": min_minor_str_ref }, + "3": { "type": "FIXED", "value": min_minor_str_ref }, + "4": { "type": "FIXED", "value": min_minor_str_ref }, + "5": { "type": "FIXED", "value": min_patch_str_ref }, + "6": { "type": "FIXED", "value": format_limit(retry_state.remaining_limit) }, + "7": { "type": "FIXED", "value": retry_state.offset.to_string() } + }) + }, &["request_id", "device_id", "time", "input", "zed_version"], requested_examples_from_response, ) @@ -568,7 +724,7 @@ pub async fn fetch_requested_examples_after( pub async fn fetch_captured_examples_after( http_client: Arc, after_timestamps: &[String], - max_rows_per_timestamp: usize, + max_rows_per_timestamp: Option, offset: usize, background_executor: BackgroundExecutor, min_capture_version: Option, @@ -593,54 +749,51 @@ pub async fn fetch_captured_examples_after( let statement = indoc! {r#" SELECT - settled.event_properties:request_id::string AS request_id, - settled.device_id::string AS device_id, - settled.time::string AS time, - req.event_properties:input AS input, - settled.event_properties:settled_editable_region::string AS settled_editable_region, - settled.event_properties:example AS example, - req.event_properties:zed_version::string AS zed_version - FROM events settled - INNER JOIN events req - ON settled.event_properties:request_id::string = req.event_properties:request_id::string - WHERE settled.event_type = ? - AND req.event_type = ? - AND req.event_properties:version = 'V3' - AND req.event_properties:input:can_collect_data = true - AND settled.event_properties:example IS NOT NULL - AND TYPEOF(settled.event_properties:example) != 'NULL_VALUE' - AND settled.time > TRY_TO_TIMESTAMP_NTZ(?) + ep_request_id AS request_id, + device_id AS device_id, + requested_at::string AS continuation_time, + requested_at::string AS time, + input_payload AS input, + settled_editable_region AS settled_editable_region, + example_payload AS example, + zed_version AS zed_version + FROM ZED_DBT.DBT_PROD.fct_edit_prediction_examples + WHERE settled_editable_region IS NOT NULL + AND example_payload IS NOT NULL + AND requested_at > TRY_TO_TIMESTAMP_NTZ(?) AND (? IS NULL OR ( - TRY_CAST(SPLIT_PART(req.event_properties:zed_version::string, '.', 2) AS INTEGER) > ? + TRY_CAST(SPLIT_PART(zed_version, '.', 2) AS INTEGER) > ? OR ( - TRY_CAST(SPLIT_PART(req.event_properties:zed_version::string, '.', 2) AS INTEGER) = ? - AND TRY_CAST(SPLIT_PART(SPLIT_PART(req.event_properties:zed_version::string, '.', 3), '+', 1) AS INTEGER) >= ? + TRY_CAST(SPLIT_PART(zed_version, '.', 2) AS INTEGER) = ? + AND TRY_CAST(SPLIT_PART(SPLIT_PART(zed_version, '.', 3), '+', 1) AS INTEGER) >= ? ) )) - ORDER BY settled.time ASC + ORDER BY requested_at ASC LIMIT ? OFFSET ? "#}; - let bindings = json!({ - "1": { "type": "TEXT", "value": EDIT_PREDICTION_SETTLED_EVENT }, - "2": { "type": "TEXT", "value": PREDICTIVE_EDIT_REQUESTED_EVENT }, - "3": { "type": "TEXT", "value": after_date }, - "4": { "type": "FIXED", "value": min_minor_str_ref }, - "5": { "type": "FIXED", "value": min_minor_str_ref }, - "6": { "type": "FIXED", "value": min_minor_str_ref }, - "7": { "type": "FIXED", "value": min_patch_str_ref }, - "8": { "type": "FIXED", "value": max_rows_per_timestamp.to_string() }, - "9": { "type": "FIXED", "value": offset.to_string() } - }); - let examples = fetch_examples_with_query( http_client.clone(), &step_progress, background_executor.clone(), statement, - bindings, - DEFAULT_STATEMENT_TIMEOUT_SECONDS, + QueryRetryState { + resume_after: after_date.clone(), + remaining_limit: max_rows_per_timestamp, + offset, + }, + |retry_state| { + json!({ + "1": { "type": "TEXT", "value": retry_state.resume_after }, + "2": { "type": "FIXED", "value": min_minor_str_ref }, + "3": { "type": "FIXED", "value": min_minor_str_ref }, + "4": { "type": "FIXED", "value": min_minor_str_ref }, + "5": { "type": "FIXED", "value": min_patch_str_ref }, + "6": { "type": "FIXED", "value": format_limit(retry_state.remaining_limit) }, + "7": { "type": "FIXED", "value": retry_state.offset.to_string() } + }) + }, &[ "request_id", "device_id", @@ -663,7 +816,7 @@ pub async fn fetch_captured_examples_after( pub async fn fetch_settled_examples_after( http_client: Arc, after_timestamps: &[String], - max_rows_per_timestamp: usize, + max_rows_per_timestamp: Option, offset: usize, background_executor: BackgroundExecutor, min_capture_version: Option, @@ -684,55 +837,41 @@ pub async fn fetch_settled_examples_after( let _ = min_capture_version; let statement = indoc! {r#" - WITH requested AS ( - SELECT - req.event_properties:request_id::string AS request_id, - req.device_id::string AS device_id, - req.time AS req_time, - req.time::string AS time, - req.event_properties:input AS input, - req.event_properties:format::string AS requested_format, - req.event_properties:output::string AS requested_output, - req.event_properties:zed_version::string AS zed_version - FROM events req - WHERE req.event_type = ? - AND req.event_properties:version = 'V3' - AND req.event_properties:input:can_collect_data = true - AND req.time > TRY_TO_TIMESTAMP_NTZ(?) - ) SELECT - req.request_id AS request_id, - req.device_id AS device_id, - req.time AS time, - req.input AS input, - req.requested_output AS requested_output, - settled.event_properties:settled_editable_region::string AS settled_editable_region, - req.requested_format AS requested_format, - req.zed_version AS zed_version - FROM requested req - INNER JOIN events settled - ON req.request_id = settled.event_properties:request_id::string - WHERE settled.event_type = ? - ORDER BY req.req_time ASC + ep_request_id AS request_id, + device_id AS device_id, + requested_at::string AS continuation_time, + requested_at::string AS time, + input_payload AS input, + requested_output AS requested_output, + settled_editable_region AS settled_editable_region, + requested_format AS requested_format, + zed_version AS zed_version + FROM ZED_DBT.DBT_PROD.fct_edit_prediction_examples + WHERE settled_editable_region IS NOT NULL + AND requested_at > TRY_TO_TIMESTAMP_NTZ(?) + ORDER BY requested_at ASC LIMIT ? OFFSET ? "#}; - let bindings = json!({ - "1": { "type": "TEXT", "value": PREDICTIVE_EDIT_REQUESTED_EVENT }, - "2": { "type": "TEXT", "value": after_date }, - "3": { "type": "TEXT", "value": EDIT_PREDICTION_SETTLED_EVENT }, - "4": { "type": "FIXED", "value": max_rows_per_timestamp.to_string() }, - "5": { "type": "FIXED", "value": offset.to_string() } - }); - let examples = fetch_examples_with_query( http_client.clone(), &step_progress, background_executor.clone(), statement, - bindings, - SETTLED_STATEMENT_TIMEOUT_SECONDS, + QueryRetryState { + resume_after: after_date.clone(), + remaining_limit: max_rows_per_timestamp, + offset, + }, + |retry_state| { + json!({ + "1": { "type": "TEXT", "value": retry_state.resume_after }, + "2": { "type": "FIXED", "value": format_limit(retry_state.remaining_limit) }, + "3": { "type": "FIXED", "value": retry_state.offset.to_string() } + }) + }, &[ "request_id", "device_id", @@ -756,7 +895,7 @@ pub async fn fetch_settled_examples_after( pub async fn fetch_rated_examples_after( http_client: Arc, inputs: &[(String, Option)], - max_rows_per_timestamp: usize, + max_rows_per_timestamp: Option, offset: usize, background_executor: BackgroundExecutor, _min_capture_version: Option, @@ -786,54 +925,48 @@ pub async fn fetch_rated_examples_after( let statement = indoc! {r#" SELECT - rated.event_properties:request_id::string AS request_id, - rated.event_properties:inputs AS inputs, - rated.event_properties:output::string AS output, - rated.event_properties:rating::string AS rating, - rated.event_properties:feedback::string AS feedback, - rated.device_id::string AS device_id, - rated.time::string AS time, - deploy.event_properties:experiment_name::string AS experiment_name, - deploy.event_properties:environment::string AS environment, - rated.event_properties:zed_version::string AS zed_version - FROM events rated - LEFT JOIN events req - ON rated.event_properties:request_id::string = req.event_properties:request_id::string - AND req.event_type = ? - LEFT JOIN events deploy - ON req.event_properties:headers:x_baseten_model_id::string = deploy.event_properties:model_id::string - AND req.event_properties:headers:x_baseten_model_version_id::string = deploy.event_properties:model_version_id::string - AND deploy.event_type = ? - WHERE rated.event_type = ? - AND (? IS NULL OR rated.event_properties:rating::string = ?) - AND rated.time > TRY_TO_TIMESTAMP_NTZ(?) - AND rated.event_properties:inputs IS NOT NULL - AND rated.event_properties:inputs:cursor_excerpt IS NOT NULL - AND rated.event_properties:output IS NOT NULL - AND rated.event_properties:inputs:can_collect_data = true - ORDER BY rated.time ASC + ep_request_id AS request_id, + rated_inputs AS inputs, + rated_output AS output, + rating AS rating, + feedback AS feedback, + device_id AS device_id, + requested_at::string AS continuation_time, + requested_at::string AS time, + NULL AS experiment_name, + NULL AS environment, + zed_version AS zed_version + FROM ZED_DBT.DBT_PROD.fct_edit_prediction_examples + WHERE rating IS NOT NULL + AND (? IS NULL OR rating = ?) + AND requested_at > TRY_TO_TIMESTAMP_NTZ(?) + AND rated_inputs IS NOT NULL + AND rated_inputs:cursor_excerpt IS NOT NULL + AND rated_output IS NOT NULL + ORDER BY requested_at ASC LIMIT ? OFFSET ? "#}; - let bindings = json!({ - "1": { "type": "TEXT", "value": PREDICTIVE_EDIT_REQUESTED_EVENT }, - "2": { "type": "TEXT", "value": EDIT_PREDICTION_DEPLOYMENT_EVENT }, - "3": { "type": "TEXT", "value": EDIT_PREDICTION_RATED_EVENT }, - "4": { "type": "TEXT", "value": rating_value }, - "5": { "type": "TEXT", "value": rating_value }, - "6": { "type": "TEXT", "value": after_date }, - "7": { "type": "FIXED", "value": max_rows_per_timestamp.to_string() }, - "8": { "type": "FIXED", "value": offset.to_string() } - }); - let examples = fetch_examples_with_query( http_client.clone(), &step_progress, background_executor.clone(), statement, - bindings, - DEFAULT_STATEMENT_TIMEOUT_SECONDS, + QueryRetryState { + resume_after: after_date.clone(), + remaining_limit: max_rows_per_timestamp, + offset, + }, + |retry_state| { + json!({ + "1": { "type": "TEXT", "value": rating_value }, + "2": { "type": "TEXT", "value": rating_value }, + "3": { "type": "TEXT", "value": retry_state.resume_after }, + "4": { "type": "FIXED", "value": format_limit(retry_state.remaining_limit) }, + "5": { "type": "FIXED", "value": retry_state.offset.to_string() } + }) + }, &[ "request_id", "inputs", @@ -1473,6 +1606,7 @@ fn rejected_examples_from_response<'a>( let input_json = get_json("input"); let input: Option = input_json.clone().and_then(|v| serde_json::from_value(v).ok()); + let prompt = get_string("prompt"); let output = get_string("output"); let was_shown = get_bool("was_shown"); let reason = get_string("reason"); @@ -1485,6 +1619,7 @@ fn rejected_examples_from_response<'a>( device_id, time, input, + prompt, output, was_shown, reason, @@ -1515,6 +1650,7 @@ fn build_rejected_example( device_id: String, time: String, input: ZetaPromptInput, + prompt: Option, output: String, was_shown: bool, reason: String, @@ -1536,6 +1672,13 @@ fn build_rejected_example( zed_version, ); example.spec.rejected_patch = Some(rejected_patch); + example.prompt = prompt.map(|prompt| ExamplePrompt { + input: prompt, + expected_output: String::new(), + rejected_output: Some(output), + prefill: None, + provider: PredictionProvider::default(), + }); example } @@ -1635,11 +1778,42 @@ fn build_output_patch( patch } +fn is_timeout_response(response: &SnowflakeStatementResponse) -> bool { + response.code.as_deref() == Some(SNOWFLAKE_TIMEOUT_CODE) + && response + .message + .as_deref() + .map(|message| message.to_ascii_lowercase().contains("timeout")) + .unwrap_or(false) +} + +fn is_snowflake_timeout_error(error: &anyhow::Error) -> bool { + error + .chain() + .any(|cause| cause.to_string().contains(SNOWFLAKE_TIMEOUT_CODE)) +} + +fn last_continuation_timestamp_from_response( + response: &SnowflakeStatementResponse, + column_indices: &HashMap, +) -> Option { + let continuation_time_index = column_indices.get("continuation_time").copied()?; + response + .data + .iter() + .rev() + .find_map(|row| match row.get(continuation_time_index)? { + JsonValue::String(value) => Some(value.clone()), + JsonValue::Null => None, + other => Some(other.to_string()), + }) +} + pub(crate) fn get_column_indices( meta: &Option, names: &[&str], -) -> std::collections::HashMap { - let mut indices = std::collections::HashMap::new(); +) -> HashMap { + let mut indices = HashMap::new(); if let Some(meta) = meta { for (index, col) in meta.row_type.iter().enumerate() { for &name in names { diff --git a/crates/edit_prediction_cli/src/repair.rs b/crates/edit_prediction_cli/src/repair.rs index 58da6c47e91491cc785804c7f4c2aab30887a741..e8fb36eae28bc65a3f2c865bb95a22175b1d7ad0 100644 --- a/crates/edit_prediction_cli/src/repair.rs +++ b/crates/edit_prediction_cli/src/repair.rs @@ -15,7 +15,7 @@ use crate::{ openai_client::OpenAiClient, parse_output::run_parse_output, paths::LLM_CACHE_DB, - progress::{ExampleProgress, Step}, + progress::{ExampleProgress, Progress, Step}, word_diff::unified_to_word_diff, }; use anyhow::{Context as _, Result}; @@ -75,6 +75,9 @@ pub struct RepairArgs { /// Which LLM provider to use (anthropic or openai) #[clap(long, default_value = "anthropic")] pub backend: BatchProvider, + /// Wait for all batches to complete before exiting + #[clap(long)] + pub wait: bool, } fn model_for_backend(backend: BatchProvider) -> &'static str { @@ -454,6 +457,68 @@ pub async fn sync_batches(args: &RepairArgs) -> Result<()> { Ok(()) } +pub async fn reprocess_after_batch_wait(examples: &mut [Example], args: &RepairArgs) -> Result<()> { + let mut reprocessed = 0; + for example in examples.iter_mut() { + if has_successful_repair(example) || !needs_repair(example, args.confidence_threshold) { + continue; + } + + let example_progress = Progress::global().start_group(&example.spec.name); + run_repair(example, args, &example_progress).await?; + reprocessed += 1; + } + + if reprocessed > 0 { + eprintln!("Reprocessed {} example(s) with batch results", reprocessed); + } + + Ok(()) +} + +pub async fn wait_for_batches(args: &RepairArgs) -> Result<()> { + if args.no_batch { + return Ok(()); + } + + let poll_interval = std::time::Duration::from_secs(30); + + loop { + let pending = pending_batch_count(args)?; + if pending == 0 { + break; + } + + eprintln!( + "Waiting for {} pending repair batch request(s) to complete... (polling every {}s)", + pending, + poll_interval.as_secs() + ); + std::thread::sleep(poll_interval); + + sync_batches(args).await?; + } + + Ok(()) +} + +fn pending_batch_count(args: &RepairArgs) -> Result { + match args.backend { + BatchProvider::Anthropic => { + let client = ANTHROPIC_CLIENT_BATCH.get_or_init(|| { + AnthropicClient::batch(&LLM_CACHE_DB).expect("Failed to create Anthropic client") + }); + client.pending_batch_count() + } + BatchProvider::Openai => { + let client = OPENAI_CLIENT_BATCH.get_or_init(|| { + OpenAiClient::batch(&LLM_CACHE_DB).expect("Failed to create OpenAI client") + }); + client.pending_batch_count() + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/edit_prediction_cli/src/score.rs b/crates/edit_prediction_cli/src/score.rs index d75cf55e85b198bc28469e83d8f9209a8a59a83f..be9b185809e6e0cd49e0befbeecec0f317339342 100644 --- a/crates/edit_prediction_cli/src/score.rs +++ b/crates/edit_prediction_cli/src/score.rs @@ -67,6 +67,12 @@ pub async fn run_scoring( let zero_scores = ExampleScore { delta_chr_f: 0.0, + delta_chr_f_true_positives: 0, + delta_chr_f_false_positives: 0, + delta_chr_f_false_negatives: 0, + delta_chr_f_precision: 0.0, + delta_chr_f_recall: 0.0, + delta_chr_f_beta: metrics::delta_chr_f_beta(), braces_disbalance: 0, exact_lines_tp: 0, exact_lines_fp: 0, @@ -111,14 +117,14 @@ pub async fn run_scoring( } }; - let mut best_delta_chr_f = 0.0f32; + let mut best_delta_chr_f_metrics = metrics::DeltaChrFMetrics::default(); let mut best_expected_cursor: Option = None; let mut best_patch_idx: Option = None; for (idx, expected) in expected_texts.iter().enumerate() { - let delta_chr_f = metrics::delta_chr_f(original_text, expected, &actual_text) as f32; - if delta_chr_f > best_delta_chr_f { - best_delta_chr_f = delta_chr_f; + let delta_chr_f_metrics = metrics::delta_chr_f(original_text, expected, &actual_text); + if delta_chr_f_metrics.score > best_delta_chr_f_metrics.score { + best_delta_chr_f_metrics = delta_chr_f_metrics; best_patch_idx = Some(idx); } } @@ -179,7 +185,13 @@ pub async fn run_scoring( ); scores.push(ExampleScore { - delta_chr_f: best_delta_chr_f, + delta_chr_f: best_delta_chr_f_metrics.score as f32, + delta_chr_f_true_positives: best_delta_chr_f_metrics.counts.true_positives, + delta_chr_f_false_positives: best_delta_chr_f_metrics.counts.false_positives, + delta_chr_f_false_negatives: best_delta_chr_f_metrics.counts.false_negatives, + delta_chr_f_precision: best_delta_chr_f_metrics.precision, + delta_chr_f_recall: best_delta_chr_f_metrics.recall, + delta_chr_f_beta: best_delta_chr_f_metrics.beta, braces_disbalance, exact_lines_tp: best_exact_lines.true_positives, exact_lines_fp: best_exact_lines.false_positives, @@ -238,6 +250,10 @@ pub fn print_report(examples: &[Example], verbose: bool) { let mut all_delta_chr_f_scores = Vec::new(); let mut all_reversal_ratios = Vec::new(); let mut braces_disbalance_sum: usize = 0; + let mut total_delta_chr_f = ClassificationMetrics::default(); + let mut total_delta_chr_f_precision = 0.0; + let mut total_delta_chr_f_recall = 0.0; + let mut delta_chr_f_beta = 0.0; let mut total_exact_lines = ClassificationMetrics::default(); let mut total_scores: usize = 0; let mut qa_reverts_count: usize = 0; @@ -260,11 +276,7 @@ pub fn print_report(examples: &[Example], verbose: bool) { for example in examples { for (score_idx, score) in example.score.iter().enumerate() { - let exact_lines = ClassificationMetrics { - true_positives: score.exact_lines_tp, - false_positives: score.exact_lines_fp, - false_negatives: score.exact_lines_fn, - }; + let exact_lines = score.exact_lines_counts(); // Get QA results for this prediction if available let qa_result = example.qa.get(score_idx).and_then(|q| q.as_ref()); @@ -314,9 +326,11 @@ pub fn print_report(examples: &[Example], verbose: bool) { all_reversal_ratios.push(score.reversal_ratio); total_scores += 1; braces_disbalance_sum += score.braces_disbalance; - total_exact_lines.true_positives += score.exact_lines_tp; - total_exact_lines.false_positives += score.exact_lines_fp; - total_exact_lines.false_negatives += score.exact_lines_fn; + total_delta_chr_f.accumulate(&score.delta_chr_f_counts()); + total_delta_chr_f_precision += score.delta_chr_f_precision; + total_delta_chr_f_recall += score.delta_chr_f_recall; + delta_chr_f_beta = score.delta_chr_f_beta; + total_exact_lines.accumulate(&score.exact_lines_counts()); // Accumulate QA metrics if let Some(qa) = qa_result { @@ -448,6 +462,15 @@ pub fn print_report(examples: &[Example], verbose: bool) { wrong_er_str ); println!("{}", separator); + println!( + "Delta chrF (β={:.1}): TP={}, FP={}, FN={}, P={:.1}%, R={:.1}%", + delta_chr_f_beta, + total_delta_chr_f.true_positives, + total_delta_chr_f.false_positives, + total_delta_chr_f.false_negatives, + total_delta_chr_f_precision / total_scores as f64 * 100.0, + total_delta_chr_f_recall / total_scores as f64 * 100.0 + ); // Print additional cursor metrics if available if let Some(avg_dist) = avg_cursor_distance { @@ -540,6 +563,12 @@ fn truncate_name(name: &str, max_len: usize) -> String { pub struct SummaryJson { pub total_examples: usize, pub avg_delta_chr_f: f32, + pub delta_chr_f_beta: f64, + pub delta_chr_f_true_positives: usize, + pub delta_chr_f_false_positives: usize, + pub delta_chr_f_false_negatives: usize, + pub delta_chr_f_precision: f64, + pub delta_chr_f_recall: f64, pub avg_braces_disbalance: f32, pub exact_lines_true_positives: usize, pub exact_lines_false_positives: usize, @@ -569,6 +598,10 @@ pub fn compute_summary(examples: &[Example]) -> SummaryJson { let mut all_delta_chr_f_scores = Vec::new(); let mut all_reversal_ratios = Vec::new(); let mut braces_disbalance_sum: usize = 0; + let mut total_delta_chr_f = ClassificationMetrics::default(); + let mut total_delta_chr_f_precision = 0.0; + let mut total_delta_chr_f_recall = 0.0; + let mut delta_chr_f_beta = 0.0; let mut total_exact_lines = ClassificationMetrics::default(); let mut total_scores: usize = 0; let mut qa_reverts_count: usize = 0; @@ -589,9 +622,11 @@ pub fn compute_summary(examples: &[Example]) -> SummaryJson { all_reversal_ratios.push(score.reversal_ratio); total_scores += 1; braces_disbalance_sum += score.braces_disbalance; - total_exact_lines.true_positives += score.exact_lines_tp; - total_exact_lines.false_positives += score.exact_lines_fp; - total_exact_lines.false_negatives += score.exact_lines_fn; + total_delta_chr_f.accumulate(&score.delta_chr_f_counts()); + total_delta_chr_f_precision += score.delta_chr_f_precision; + total_delta_chr_f_recall += score.delta_chr_f_recall; + delta_chr_f_beta = score.delta_chr_f_beta; + total_exact_lines.accumulate(&score.exact_lines_counts()); // Accumulate QA metrics if let Some(Some(qa)) = example.qa.get(score_idx) { @@ -697,6 +732,20 @@ pub fn compute_summary(examples: &[Example]) -> SummaryJson { SummaryJson { total_examples: total_scores, avg_delta_chr_f, + delta_chr_f_beta, + delta_chr_f_true_positives: total_delta_chr_f.true_positives, + delta_chr_f_false_positives: total_delta_chr_f.false_positives, + delta_chr_f_false_negatives: total_delta_chr_f.false_negatives, + delta_chr_f_precision: if total_scores == 0 { + 0.0 + } else { + total_delta_chr_f_precision / total_scores as f64 + }, + delta_chr_f_recall: if total_scores == 0 { + 0.0 + } else { + total_delta_chr_f_recall / total_scores as f64 + }, avg_braces_disbalance, exact_lines_true_positives: total_exact_lines.true_positives, exact_lines_false_positives: total_exact_lines.false_positives, 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 01c4c76e82eb0851b7552b3d9117af1212a8b3da..32dc37b953207e4d034287642f0e91fa400867dd 100644 --- a/crates/edit_prediction_context/src/edit_prediction_context_tests.rs +++ b/crates/edit_prediction_context/src/edit_prediction_context_tests.rs @@ -160,7 +160,7 @@ async fn test_edit_prediction_context(cx: &mut TestAppContext) { } #[gpui::test] -fn test_assemble_excerpts(cx: &mut TestAppContext) { +async fn test_assemble_excerpts(cx: &mut TestAppContext) { let table = [ ( indoc! {r#" @@ -289,6 +289,9 @@ fn test_assemble_excerpts(cx: &mut TestAppContext) { for (input, expected_output) in table { let (input, ranges) = marked_text_ranges(&input, false); let buffer = cx.new(|cx| Buffer::local(input, cx).with_language(rust_lang(), cx)); + buffer + .read_with(cx, |buffer, _| buffer.parsing_idle()) + .await; buffer.read_with(cx, |buffer, _cx| { let ranges: Vec<(Range, usize)> = ranges .into_iter() @@ -1028,6 +1031,7 @@ fn assert_related_files_impl( pretty_assertions::assert_eq!(actual, expected) } +#[track_caller] fn assert_definitions(definitions: &[LocationLink], first_lines: &[&str], cx: &mut TestAppContext) { let actual_first_lines = definitions .iter() diff --git a/crates/edit_prediction_context/src/fake_definition_lsp.rs b/crates/edit_prediction_context/src/fake_definition_lsp.rs index 6b6d93469b9a1fbeb856e189f4fe79da06135045..5b9e528b63f6709ce2966d2fd54d39eeeb195a36 100644 --- a/crates/edit_prediction_context/src/fake_definition_lsp.rs +++ b/crates/edit_prediction_context/src/fake_definition_lsp.rs @@ -174,7 +174,7 @@ pub fn register_fake_definition_server( struct DefinitionIndex { language: Arc, definitions: HashMap>, - type_annotations: HashMap, + type_annotations_by_file: HashMap>, files: HashMap, } @@ -189,7 +189,7 @@ impl DefinitionIndex { Self { language, definitions: HashMap::default(), - type_annotations: HashMap::default(), + type_annotations_by_file: HashMap::default(), files: HashMap::default(), } } @@ -199,6 +199,7 @@ impl DefinitionIndex { locations.retain(|loc| &loc.uri != uri); !locations.is_empty() }); + self.type_annotations_by_file.remove(uri); self.files.remove(uri); } @@ -243,11 +244,11 @@ impl DefinitionIndex { .push(location); } - for (identifier_name, type_name) in extract_type_annotations(content) { - self.type_annotations - .entry(identifier_name) - .or_insert(type_name); - } + let type_annotations = extract_type_annotations(content) + .into_iter() + .collect::>(); + self.type_annotations_by_file + .insert(uri.clone(), type_annotations); self.files.insert( uri, @@ -279,7 +280,11 @@ impl DefinitionIndex { let entry = self.files.get(&uri)?; let name = word_at_position(&entry.contents, position)?; - if let Some(type_name) = self.type_annotations.get(name) { + if let Some(type_name) = self + .type_annotations_by_file + .get(&uri) + .and_then(|annotations| annotations.get(name)) + { if let Some(locations) = self.definitions.get(type_name) { return Some(lsp::GotoDefinitionResponse::Array(locations.clone())); } @@ -367,6 +372,20 @@ fn extract_base_type_name(type_str: &str) -> String { return outer.to_string(); } + if let Some(call_start) = trimmed.find("::") { + let outer = &trimmed[..call_start]; + if matches!(outer, "Arc" | "Box" | "Rc" | "Option" | "Vec" | "Cow") { + let rest = trimmed[call_start + 2..].trim_start(); + if let Some(paren_start) = rest.find('(') { + let inner = &rest[paren_start + 1..]; + let inner = inner.trim(); + if !inner.is_empty() { + return extract_base_type_name(inner); + } + } + } + } + trimmed .split(|c: char| !c.is_alphanumeric() && c != '_') .next() diff --git a/crates/edit_prediction_ui/Cargo.toml b/crates/edit_prediction_ui/Cargo.toml index b6b6473bafa0222a670e1c541e03d255ee0d2d5a..29c53bbaf8c82f3b0c2769af80d44f17250a0506 100644 --- a/crates/edit_prediction_ui/Cargo.toml +++ b/crates/edit_prediction_ui/Cargo.toml @@ -42,7 +42,7 @@ regex.workspace = true settings.workspace = true telemetry.workspace = true text.workspace = true -theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true diff --git a/crates/edit_prediction_ui/src/edit_prediction_button.rs b/crates/edit_prediction_ui/src/edit_prediction_button.rs index 2d50e7fa2321750634500925b0b6ec2b6989163d..e7aff1271f0505d9c87899cc8b555e377ca3fbd0 100644 --- a/crates/edit_prediction_ui/src/edit_prediction_button.rs +++ b/crates/edit_prediction_ui/src/edit_prediction_button.rs @@ -18,7 +18,9 @@ use gpui::{ use indoc::indoc; use language::{ EditPredictionsMode, File, Language, - language_settings::{self, AllLanguageSettings, EditPredictionProvider, all_language_settings}, + language_settings::{ + AllLanguageSettings, EditPredictionProvider, LanguageSettings, all_language_settings, + }, }; use project::{DisableAiSettings, Project}; use regex::Regex; @@ -323,7 +325,6 @@ impl Render for EditPredictionButton { } provider @ (EditPredictionProvider::Experimental(_) | EditPredictionProvider::Zed - | EditPredictionProvider::Sweep | EditPredictionProvider::Mercury) => { let enabled = self.editor_enabled.unwrap_or(true); let file = self.file.clone(); @@ -347,16 +348,6 @@ impl Render for EditPredictionButton { let mut missing_token = false; match provider { - EditPredictionProvider::Sweep => { - missing_token = edit_prediction::EditPredictionStore::try_global(cx) - .is_some_and(|ep_store| !ep_store.read(cx).has_sweep_api_token(cx)); - ep_icon = if enabled { icons.base } else { icons.disabled }; - tooltip_meta = if missing_token { - "Missing API key for Sweep" - } else { - "Powered by Sweep" - }; - } EditPredictionProvider::Mercury => { ep_icon = if enabled { icons.base } else { icons.disabled }; let mercury_has_error = @@ -546,17 +537,12 @@ impl EditPredictionButton { .detach(); edit_prediction::ollama::ensure_authenticated(cx); - 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); let open_ai_compatible_api_token_task = edit_prediction::open_ai_compatible::load_open_ai_compatible_api_token(cx); cx.spawn(async move |this, cx| { - _ = futures::join!( - sweep_api_token_task, - mercury_api_token_task, - open_ai_compatible_api_token_task - ); + _ = futures::join!(mercury_api_token_task, open_ai_compatible_api_token_task); this.update(cx, |_, cx| { cx.notify(); }) @@ -674,8 +660,7 @@ impl EditPredictionButton { let language_state = self.language.as_ref().map(|language| { ( language.clone(), - language_settings::language_settings(Some(language.name()), None, cx) - .show_edit_predictions, + LanguageSettings::resolve(None, Some(&language.name()), cx).show_edit_predictions, ) }); @@ -1372,14 +1357,19 @@ async fn open_disabled_globs_setting_in_editor( let settings = cx.global::(); // Ensure that we always have "edit_predictions { "disabled_globs": [] }" - let edits = settings.edits_for_update(&text, |file| { - file.project - .all_languages - .edit_predictions - .get_or_insert_with(Default::default) - .disabled_globs - .get_or_insert_with(Vec::new); - }); + let Some(edits) = settings + .edits_for_update(&text, |file| { + file.project + .all_languages + .edit_predictions + .get_or_insert_with(Default::default) + .disabled_globs + .get_or_insert_with(Vec::new); + }) + .log_err() + else { + return; + }; if !edits.is_empty() { item.edit( @@ -1433,9 +1423,9 @@ pub fn get_available_providers(cx: &mut App) -> Vec { providers.push(EditPredictionProvider::Zed); - if let Some(app_state) = workspace::AppState::global(cx).upgrade() - && copilot::GlobalCopilotAuth::try_get_or_init(app_state, cx) - .is_some_and(|copilot| copilot.0.read(cx).is_authenticated()) + let app_state = workspace::AppState::global(cx); + if copilot::GlobalCopilotAuth::try_get_or_init(app_state, cx) + .is_some_and(|copilot| copilot.0.read(cx).is_authenticated()) { providers.push(EditPredictionProvider::Copilot); }; @@ -1456,13 +1446,6 @@ pub fn get_available_providers(cx: &mut App) -> Vec { providers.push(EditPredictionProvider::OpenAiCompatibleApi); } - if edit_prediction::sweep_ai::sweep_api_token(cx) - .read(cx) - .has_key() - { - providers.push(EditPredictionProvider::Sweep); - } - if edit_prediction::mercury::mercury_api_token(cx) .read(cx) .has_key() @@ -1599,8 +1582,7 @@ fn emit_edit_prediction_menu_opened( ) { let language_name = language.as_ref().map(|l| l.name()); let edit_predictions_enabled_for_language = - language_settings::language_settings(language_name, file.as_ref(), cx) - .show_edit_predictions; + LanguageSettings::resolve(None, language_name.as_ref(), cx).show_edit_predictions; let file_extension = file .as_ref() .and_then(|f| { diff --git a/crates/edit_prediction_ui/src/rate_prediction_modal.rs b/crates/edit_prediction_ui/src/rate_prediction_modal.rs index 15cccc777feb0a999724f2b4405fc11df8c5f252..1fb6c36bc9503e0a2fea7b3f77d1515747d1363c 100644 --- a/crates/edit_prediction_ui/src/rate_prediction_modal.rs +++ b/crates/edit_prediction_ui/src/rate_prediction_modal.rs @@ -14,7 +14,7 @@ use project::{ use settings::Settings as _; use std::rc::Rc; use std::{fmt::Write, sync::Arc}; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ ContextMenu, DropdownMenu, KeyBinding, List, ListItem, ListItemSpacing, PopoverMenuHandle, Tooltip, prelude::*, diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index 22a9b8effbe52caa67812619d254076493210e68..1b2e32f19896df4863d6fd12d02b5eea6579bc97 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -83,6 +83,7 @@ telemetry.workspace = true text.workspace = true time.workspace = true theme.workspace = true +theme_settings.workspace = true tree-sitter-c = { workspace = true, optional = true } tree-sitter-html = { workspace = true, optional = true } tree-sitter-rust = { workspace = true, optional = true } diff --git a/crates/editor/benches/editor_render.rs b/crates/editor/benches/editor_render.rs index f527ddea45574720e7f86a177333f7e3ab3b919f..e93c94e1ae6e6cd44a65537172ffafe48455f3a3 100644 --- a/crates/editor/benches/editor_render.rs +++ b/crates/editor/benches/editor_render.rs @@ -122,7 +122,7 @@ pub fn benches() { let store = SettingsStore::test(cx); cx.set_global(store); assets::Assets.load_test_fonts(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); // release_channel::init(semver::Version::new(0,0,0), cx); editor::init(cx); }); diff --git a/crates/editor/src/bracket_colorization.rs b/crates/editor/src/bracket_colorization.rs index ad2fc1bd8b9666dfa5e2c4b0367984c6398c98f8..0c9fa29ae6a19ad81ec265cc832a5d3ec15cec51 100644 --- a/crates/editor/src/bracket_colorization.rs +++ b/crates/editor/src/bracket_colorization.rs @@ -8,7 +8,7 @@ use crate::{Editor, HighlightKey}; use collections::{HashMap, HashSet}; use gpui::{AppContext as _, Context, HighlightStyle}; use itertools::Itertools; -use language::{BufferRow, BufferSnapshot, language_settings}; +use language::{BufferRow, BufferSnapshot, language_settings::LanguageSettings}; use multi_buffer::{Anchor, ExcerptId}; use ui::{ActiveTheme, utils::ensure_minimum_contrast}; @@ -29,14 +29,9 @@ impl Editor { let excerpt_data: Vec<(ExcerptId, BufferSnapshot, Range)> = visible_excerpts .into_iter() .filter_map(|(excerpt_id, (buffer, _, buffer_range))| { - let buffer_snapshot = buffer.read(cx).snapshot(); - if language_settings::language_settings( - buffer_snapshot.language().map(|language| language.name()), - buffer_snapshot.file(), - cx, - ) - .colorize_brackets - { + let buffer = buffer.read(cx); + let buffer_snapshot = buffer.snapshot(); + if LanguageSettings::for_buffer(&buffer, cx).colorize_brackets { Some((excerpt_id, buffer_snapshot, buffer_range)) } else { None @@ -231,7 +226,7 @@ mod tests { use serde_json::json; use settings::{AccentContent, SettingsStore}; use text::{Bias, OffsetRangeExt, ToOffset}; - use theme::ThemeStyleContent; + use theme_settings::ThemeStyleContent; use util::{path, post_inc}; diff --git a/crates/editor/src/code_context_menus.rs b/crates/editor/src/code_context_menus.rs index e1ea2822168c836caffd9ef6f59b263c6432d10b..3fc6080b4da8ca85d258d04de29d603ea7097623 100644 --- a/crates/editor/src/code_context_menus.rs +++ b/crates/editor/src/code_context_menus.rs @@ -287,14 +287,9 @@ impl Drop for CompletionsMenu { } } +#[derive(Default)] struct CompletionMenuScrollBarSetting; -impl ui::scrollbars::GlobalSetting for CompletionMenuScrollBarSetting { - fn get_value(_cx: &App) -> &Self { - &Self - } -} - impl ui::scrollbars::ScrollbarVisibility for CompletionMenuScrollBarSetting { fn visibility(&self, cx: &App) -> ui::scrollbars::ShowScrollbar { EditorSettings::get_global(cx).completion_menu_scrollbar diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 271e6b0afc56ba8c8a799027d14672d3497c46c6..933f0e6e18e57c38b6bcc3636f60bd1ae671d3a6 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -97,7 +97,11 @@ use gpui::{ App, Context, Entity, EntityId, Font, HighlightStyle, LineLayout, Pixels, UnderlineStyle, WeakEntity, }; -use language::{Point, Subscription as BufferSubscription, language_settings::language_settings}; +use language::{ + Point, Subscription as BufferSubscription, + language_settings::{AllLanguageSettings, LanguageSettings}, +}; + use multi_buffer::{ Anchor, AnchorRangeExt, ExcerptId, MultiBuffer, MultiBufferOffset, MultiBufferOffsetUtf16, MultiBufferPoint, MultiBufferRow, MultiBufferSnapshot, RowInfo, ToOffset, ToPoint, @@ -105,6 +109,7 @@ use multi_buffer::{ use project::project_settings::DiagnosticSeverity; use project::{InlayId, lsp_store::LspFoldingRange, lsp_store::TokenType}; use serde::Deserialize; +use settings::Settings; use smallvec::SmallVec; use sum_tree::{Bias, TreeMap}; use text::{BufferId, LineIndent, Patch}; @@ -1443,12 +1448,11 @@ impl DisplayMap { #[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 - .and_then(|buffer| buffer.language()) - .map(|l| l.name()); - let file = buffer.and_then(|buffer| buffer.file()); - language_settings(language, file, cx).tab_size + if let Some(buffer) = buffer.read(cx).as_singleton().map(|buffer| buffer.read(cx)) { + LanguageSettings::for_buffer(buffer, cx).tab_size + } else { + AllLanguageSettings::get_global(cx).defaults.tab_size + } } #[cfg(test)] @@ -1667,11 +1671,7 @@ impl DisplaySnapshot { else { return false; }; - let settings = language_settings( - buffer_snapshot.language().map(|l| l.name()), - buffer_snapshot.file(), - cx, - ); + let settings = LanguageSettings::for_buffer_snapshot(&buffer_snapshot, None, cx); settings.semantic_tokens.use_tree_sitter() } @@ -1906,7 +1906,7 @@ impl DisplaySnapshot { .flat_map(|chunk| { let syntax_highlight_style = chunk .syntax_highlight_id - .and_then(|id| id.style(&editor_style.syntax)); + .and_then(|id| editor_style.syntax.get(id).cloned()); let chunk_highlight = chunk.highlight_style.map(|chunk_highlight| { HighlightStyle { @@ -2000,7 +2000,8 @@ impl DisplaySnapshot { let syntax_style = chunk .syntax_highlight_id - .and_then(|id| id.style(syntax_theme)); + .and_then(|id| syntax_theme.get(id).cloned()); + let overlay_style = chunk.highlight_style; let combined = match (syntax_style, overlay_style) { @@ -2269,6 +2270,29 @@ impl DisplaySnapshot { .unwrap_or(false) } + /// Returns the indent length of `row` if it starts with a closing bracket. + fn closing_bracket_indent_len(&self, row: u32) -> Option { + let snapshot = self.buffer_snapshot(); + let indent_len = self + .line_indent_for_buffer_row(MultiBufferRow(row)) + .raw_len(); + let content_start = Point::new(row, indent_len); + let line_text: String = snapshot + .chars_at(content_start) + .take_while(|ch| *ch != '\n') + .collect(); + + let scope = snapshot.language_scope_at(Point::new(row, 0))?; + if scope + .brackets() + .any(|(pair, _)| line_text.starts_with(&pair.end)) + { + return Some(indent_len); + } + + None + } + #[instrument(skip_all)] pub fn crease_for_buffer_row(&self, buffer_row: MultiBufferRow) -> Option> { let start = @@ -2313,7 +2337,7 @@ impl DisplaySnapshot { { let start_line_indent = self.line_indent_for_buffer_row(buffer_row); let max_point = self.buffer_snapshot().max_point(); - let mut end = None; + let mut closing_row = None; for row in (buffer_row.0 + 1)..=max_point.row { let line_indent = self.line_indent_for_buffer_row(MultiBufferRow(row)); @@ -2333,32 +2357,33 @@ impl DisplaySnapshot { continue; } - let prev_row = row - 1; - end = Some(Point::new( - prev_row, - self.buffer_snapshot().line_len(MultiBufferRow(prev_row)), - )); + closing_row = Some(row); break; } } - let mut row_before_line_breaks = end.unwrap_or(max_point); - while row_before_line_breaks.row > start.row - && self - .buffer_snapshot() - .is_line_blank(MultiBufferRow(row_before_line_breaks.row)) - { - row_before_line_breaks.row -= 1; - } + let last_non_blank_row = |from_row: u32| -> Point { + let mut row = from_row; + while row > start.row && self.buffer_snapshot().is_line_blank(MultiBufferRow(row)) { + row -= 1; + } + Point::new(row, self.buffer_snapshot().line_len(MultiBufferRow(row))) + }; - row_before_line_breaks = Point::new( - row_before_line_breaks.row, - self.buffer_snapshot() - .line_len(MultiBufferRow(row_before_line_breaks.row)), - ); + let end = if let Some(row) = closing_row { + if let Some(indent_len) = self.closing_bracket_indent_len(row) { + // Include newline and whitespace before closing delimiter, + // so it appears on the same display line as the fold placeholder + Point::new(row, indent_len) + } else { + last_non_blank_row(row - 1) + } + } else { + last_non_blank_row(max_point.row) + }; Some(Crease::Inline { - range: start..row_before_line_breaks, + range: start..end, placeholder: self.fold_placeholder.clone(), render_toggle: None, render_trailer: None, @@ -3992,7 +4017,8 @@ pub mod tests { for chunk in snapshot.chunks(rows, true, HighlightStyles::default()) { let syntax_color = chunk .syntax_highlight_id - .and_then(|id| id.style(theme)?.color); + .and_then(|id| theme.get(id)?.color); + let highlight_color = chunk.highlight_style.and_then(|style| style.color); if let Some((last_chunk, last_syntax_color, last_highlight_color)) = chunks.last_mut() && syntax_color == *last_syntax_color @@ -4010,7 +4036,7 @@ pub mod tests { let settings = SettingsStore::test(cx); cx.set_global(settings); crate::init(cx); - theme::init(LoadThemes::JustBase, cx); + theme_settings::init(LoadThemes::JustBase, cx); cx.update_global::(|store, cx| { store.update_user_settings(cx, f); }); diff --git a/crates/editor/src/display_map/block_map.rs b/crates/editor/src/display_map/block_map.rs index d45165660d92170ecc176ebd8e038b890933bd57..531de6da49e375a4f7ba2833106e1716de551ff2 100644 --- a/crates/editor/src/display_map/block_map.rs +++ b/crates/editor/src/display_map/block_map.rs @@ -4830,7 +4830,7 @@ mod tests { fn init_test(cx: &mut gpui::App) { let settings = SettingsStore::test(cx); cx.set_global(settings); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); assets::Assets.load_test_fonts(cx); } diff --git a/crates/editor/src/display_map/fold_map.rs b/crates/editor/src/display_map/fold_map.rs index efb7abad6a169546c0d13de29870f939ced93eaa..95479e297cb82adcf8c3eb1f73e95f8b557eef43 100644 --- a/crates/editor/src/display_map/fold_map.rs +++ b/crates/editor/src/display_map/fold_map.rs @@ -57,7 +57,8 @@ impl FoldPlaceholder { pub fn fold_element(fold_id: FoldId, cx: &App) -> Stateful { use gpui::{InteractiveElement as _, StatefulInteractiveElement as _, Styled as _}; use settings::Settings as _; - use theme::{ActiveTheme as _, ThemeSettings}; + use theme::ActiveTheme as _; + use theme_settings::ThemeSettings; let settings = ThemeSettings::get_global(cx); gpui::div() .id(fold_id) diff --git a/crates/editor/src/display_map/inlay_map.rs b/crates/editor/src/display_map/inlay_map.rs index 122ca6f698115c2f5e6c194246f6a378825e5675..9c05a182ef56eb803ff545a1c9d3914b505767aa 100644 --- a/crates/editor/src/display_map/inlay_map.rs +++ b/crates/editor/src/display_map/inlay_map.rs @@ -2227,7 +2227,7 @@ mod tests { fn init_test(cx: &mut App) { let store = SettingsStore::test(cx); cx.set_global(store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); } /// Helper to create test highlights for an inlay diff --git a/crates/editor/src/display_map/wrap_map.rs b/crates/editor/src/display_map/wrap_map.rs index 650ee99918e9c9f7a95a367db7e4d4f01b02d6ed..d21642977ed923e15a583dfe767fd566e78c5de9 100644 --- a/crates/editor/src/display_map/wrap_map.rs +++ b/crates/editor/src/display_map/wrap_map.rs @@ -1664,7 +1664,7 @@ mod tests { cx.update(|cx| { let settings = SettingsStore::test(cx); cx.set_global(settings); - theme::init(LoadThemes::JustBase, cx); + theme_settings::init(LoadThemes::JustBase, cx); }); } diff --git a/crates/editor/src/document_symbols.rs b/crates/editor/src/document_symbols.rs index 0228bbd917ad96b94778b2fc01d3a66e81224296..0668a034c8755a8702e31ec3a060b7f3b79c6829 100644 --- a/crates/editor/src/document_symbols.rs +++ b/crates/editor/src/document_symbols.rs @@ -5,7 +5,7 @@ use futures::FutureExt; use futures::future::join_all; use gpui::{App, Context, HighlightStyle, Task}; use itertools::Itertools as _; -use language::language_settings::language_settings; +use language::language_settings::LanguageSettings; use language::{Buffer, OutlineItem}; use multi_buffer::{ Anchor, AnchorRangeExt as _, MultiBufferOffset, MultiBufferRow, MultiBufferSnapshot, @@ -239,7 +239,7 @@ impl Editor { } fn lsp_symbols_enabled(buffer: &Buffer, cx: &App) -> bool { - language_settings(buffer.language().map(|l| l.name()), buffer.file(), cx) + LanguageSettings::for_buffer(buffer, cx) .document_symbols .lsp_enabled() } diff --git a/crates/editor/src/edit_prediction_tests.rs b/crates/editor/src/edit_prediction_tests.rs index 684213e481762d7fb09a0bd6d8b7a0b9fc6d4a36..52939a9e5a8fd1a35a3a3c0bcd2a04b893bd6628 100644 --- a/crates/editor/src/edit_prediction_tests.rs +++ b/crates/editor/src/edit_prediction_tests.rs @@ -954,7 +954,7 @@ async fn test_cursor_popover_edit_prediction_keybind_cases(cx: &mut gpui::TestAp cx.update_editor(|editor, _window, cx| { assert!(editor.active_edit_prediction.is_some()); assert!(editor.stale_edit_prediction_in_menu.is_none()); - editor.take_active_edit_prediction(cx); + editor.take_active_edit_prediction(true, cx); assert!(editor.active_edit_prediction.is_none()); assert!(editor.stale_edit_prediction_in_menu.is_some()); }); @@ -1054,6 +1054,33 @@ fn assert_editor_active_move_completion( }) } +#[gpui::test] +async fn test_cancel_clears_stale_edit_prediction_in_menu(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + load_default_keymap(cx); + + let mut cx = EditorTestContext::new(cx).await; + let provider = cx.new(|_| FakeEditPredictionDelegate::default()); + assign_editor_completion_provider(provider.clone(), &mut cx); + cx.set_state("let x = ˇ;"); + + propose_edits(&provider, vec![(8..8, "42")], &mut cx); + cx.update_editor(|editor, window, cx| editor.update_visible_edit_prediction(window, cx)); + + cx.update_editor(|editor, _window, _cx| { + assert!(editor.active_edit_prediction.is_some()); + assert!(editor.stale_edit_prediction_in_menu.is_none()); + }); + + cx.simulate_keystroke("escape"); + cx.run_until_parked(); + + cx.update_editor(|editor, _window, _cx| { + assert!(editor.active_edit_prediction.is_none()); + assert!(editor.stale_edit_prediction_in_menu.is_none()); + }); +} + fn accept_completion(cx: &mut EditorTestContext) { cx.update_editor(|editor, window, cx| { editor.accept_edit_prediction(&crate::AcceptEditPrediction, window, cx) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 3fb832b3ac7b2ebb6aa3f47b2dcd590d7eb081df..6d9ee235f01782d43bca485d50272bddf306b837 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -61,8 +61,8 @@ pub use display_map::{ pub use edit_prediction_types::Direction; pub use editor_settings::{ CompletionDetailAlignment, CurrentLineHighlight, DiffViewStyle, DocumentColorsRenderMode, - EditorSettings, HideMouseMode, ScrollBeyondLastLine, ScrollbarAxes, SearchSettings, - ShowMinimap, + EditorSettings, EditorSettingsScrollbarProxy, HideMouseMode, ScrollBeyondLastLine, + ScrollbarAxes, SearchSettings, ShowMinimap, ui_scrollbar_settings_from_raw, }; pub use element::{ CursorLayout, EditorElement, HighlightedRange, HighlightedRangeLine, PointForPosition, @@ -136,8 +136,8 @@ use language::{ OutlineItem, Point, Selection, SelectionGoal, TextObject, TransactionId, TreeSitterOptions, WordsQuery, language_settings::{ - self, LanguageSettings, LspInsertMode, RewrapBehavior, WordsCompletionMode, - all_language_settings, language_settings, + self, AllLanguageSettings, LanguageSettings, LspInsertMode, RewrapBehavior, + WordsCompletionMode, all_language_settings, }, point_from_lsp, point_to_lsp, text_diff_with_options, }; @@ -204,8 +204,8 @@ use task::TaskVariables; use text::{BufferId, FromAnchor, OffsetUtf16, Rope, ToOffset as _, ToPoint as _}; use theme::{ AccentColors, ActiveTheme, GlobalTheme, PlayerColor, StatusColors, SyntaxTheme, Theme, - ThemeSettings, observe_buffer_font_size_adjustment, }; +use theme_settings::{ThemeSettings, observe_buffer_font_size_adjustment}; use ui::{ Avatar, ButtonSize, ButtonStyle, ContextMenu, Disclosure, IconButton, IconButtonShape, IconName, IconSize, Indicator, Key, Tooltip, h_flex, prelude::*, scrollbars::ScrollbarAutoHide, @@ -354,32 +354,26 @@ pub fn init(cx: &mut App) { cx.on_action(move |_: &workspace::NewFile, cx| { let app_state = workspace::AppState::global(cx); - if let Some(app_state) = app_state.upgrade() { - workspace::open_new( - Default::default(), - app_state, - cx, - |workspace, window, cx| { - Editor::new_file(workspace, &Default::default(), window, cx) - }, - ) - .detach_and_log_err(cx); - } + workspace::open_new( + Default::default(), + app_state, + cx, + |workspace, window, cx| Editor::new_file(workspace, &Default::default(), window, cx), + ) + .detach_and_log_err(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( - Default::default(), - app_state, - cx, - |workspace, window, cx| { - cx.activate(true); - Editor::new_file(workspace, &Default::default(), window, cx) - }, - ) - .detach_and_log_err(cx); - } + workspace::open_new( + Default::default(), + app_state, + cx, + |workspace, window, cx| { + cx.activate(true); + Editor::new_file(workspace, &Default::default(), window, cx) + }, + ) + .detach_and_log_err(cx); }); _ = ui_input::ERASED_EDITOR_FACTORY.set(|window, cx| { Arc::new(ErasedEditorImpl( @@ -596,11 +590,16 @@ impl Default for EditorStyle { } pub fn make_inlay_hints_style(cx: &App) -> HighlightStyle { - let show_background = language_settings::language_settings(None, None, cx) + let show_background = AllLanguageSettings::get_global(cx) + .defaults .inlay_hints .show_background; - let mut style = cx.theme().syntax().get("hint"); + let mut style = cx + .theme() + .syntax() + .style_for_name("hint") + .unwrap_or_default(); if style.color.is_none() { style.color = Some(cx.theme().status().hint); @@ -5977,14 +5976,7 @@ impl Editor { .read(cx) .text_anchor_for_position(position, cx)?; - let settings = language_settings::language_settings( - buffer - .read(cx) - .language_at(buffer_position) - .map(|l| l.name()), - buffer.read(cx).file(), - cx, - ); + let settings = LanguageSettings::for_buffer_at(&buffer.read(cx), buffer_position, cx); if !settings.use_on_type_format { return None; } @@ -6098,8 +6090,7 @@ impl Editor { let language = buffer_snapshot .language_at(buffer_position.text_anchor) .map(|language| language.name()); - - let language_settings = language_settings(language.clone(), buffer_snapshot.file(), cx); + let language_settings = multibuffer_snapshot.language_settings_at(buffer_position, cx); let completion_settings = language_settings.completions.clone(); let show_completions_on_input = self @@ -6693,7 +6684,7 @@ impl Editor { text: new_text[common_prefix_len..].into(), }); - self.transact(window, cx, |editor, window, cx| { + let tx_id = self.transact(window, cx, |editor, window, cx| { if let Some(mut snippet) = snippet { snippet.text = new_text.to_string(); editor @@ -6775,7 +6766,7 @@ impl Editor { } Some(cx.spawn_in(window, async move |editor, cx| { - apply_edits.await?; + let additional_edits_tx = apply_edits.await?; if let Some((lsp_store, command)) = lsp_store.zip(command) { let title = command.lsp_action.title().to_owned(); @@ -6797,6 +6788,18 @@ impl Editor { } } + if let Some(tx_id) = tx_id + && let Some(additional_edits_tx) = additional_edits_tx + { + editor + .update(cx, |editor, cx| { + editor.buffer.update(cx, |buffer, cx| { + buffer.merge_transactions(additional_edits_tx.id, tx_id, cx) + }); + }) + .context("merge transactions")?; + } + Ok(()) })) } @@ -6967,8 +6970,7 @@ impl Editor { let resolved_tasks = resolved_tasks.as_ref()?; let buffer = buffer.read(cx); let language = buffer.language()?; - let file = buffer.file(); - let debug_adapter = language_settings(language.name().into(), file, cx) + let debug_adapter = LanguageSettings::for_buffer(&buffer, cx) .debuggers .first() .map(SharedString::from) @@ -8066,11 +8068,7 @@ impl Editor { return EditPredictionSettings::Disabled; } - let buffer = buffer.read(cx); - - let file = buffer.file(); - - if !language_settings(buffer.language().map(|l| l.name()), file, cx).show_edit_predictions { + if !LanguageSettings::for_buffer(&buffer.read(cx), cx).show_edit_predictions { return EditPredictionSettings::Disabled; }; @@ -8085,6 +8083,7 @@ impl Editor { .as_ref() .is_some_and(|provider| provider.provider.show_predictions_in_menu()); + let file = buffer.read(cx).file(); let preview_requires_modifier = all_language_settings(file, cx).edit_predictions_mode() == EditPredictionsMode::Subtle; @@ -8427,7 +8426,7 @@ impl Editor { provider.discard(reason, cx); } - self.take_active_edit_prediction(cx) + self.take_active_edit_prediction(reason == EditPredictionDiscardReason::Ignored, cx) } fn report_edit_prediction_event(&self, id: Option, accepted: bool, cx: &App) { @@ -8495,14 +8494,22 @@ impl Editor { self.active_edit_prediction.is_some() } - fn take_active_edit_prediction(&mut self, cx: &mut Context) -> bool { + fn take_active_edit_prediction( + &mut self, + preserve_stale_in_menu: bool, + cx: &mut Context, + ) -> bool { let Some(active_edit_prediction) = self.active_edit_prediction.take() else { + if !preserve_stale_in_menu { + self.stale_edit_prediction_in_menu = None; + } return false; }; self.splice_inlays(&active_edit_prediction.inlay_ids, Default::default(), cx); self.clear_highlights(HighlightKey::EditPredictionHighlight, cx); - self.stale_edit_prediction_in_menu = Some(active_edit_prediction); + self.stale_edit_prediction_in_menu = + preserve_stale_in_menu.then_some(active_edit_prediction); true } @@ -8715,7 +8722,7 @@ impl Editor { return None; } - self.take_active_edit_prediction(cx); + self.take_active_edit_prediction(true, cx); let Some(provider) = self.edit_prediction_provider() else { self.edit_prediction_settings = EditPredictionSettings::Disabled; return None; @@ -8834,6 +8841,7 @@ impl Editor { let target = first_edit_start; EditPrediction::MoveWithin { target, snapshot } } else { + let show_completions_in_menu = self.has_visible_completions_menu(); let show_completions_in_buffer = !self.edit_prediction_visible_in_cursor_popover(true) && !self.edit_predictions_hidden_for_vim_mode; @@ -8847,16 +8855,26 @@ impl Editor { EditDisplayMode::DiffPopover }; - if show_completions_in_buffer { - if let Some(provider) = &self.edit_prediction_provider { - let suggestion_display_type = match display_mode { - EditDisplayMode::DiffPopover => SuggestionDisplayType::DiffPopover, - EditDisplayMode::Inline | EditDisplayMode::TabAccept => { - SuggestionDisplayType::GhostText - } - }; - provider.provider.did_show(suggestion_display_type, cx); + let report_shown = match display_mode { + EditDisplayMode::DiffPopover | EditDisplayMode::Inline => { + show_completions_in_buffer || show_completions_in_menu + } + EditDisplayMode::TabAccept => { + show_completions_in_menu || self.edit_prediction_preview_is_active() } + }; + + if report_shown && let Some(provider) = &self.edit_prediction_provider { + let suggestion_display_type = match display_mode { + EditDisplayMode::DiffPopover => SuggestionDisplayType::DiffPopover, + EditDisplayMode::Inline | EditDisplayMode::TabAccept => { + SuggestionDisplayType::GhostText + } + }; + provider.provider.did_show(suggestion_display_type, cx); + } + + if show_completions_in_buffer { if edits .iter() .all(|(range, _)| range.to_offset(&multibuffer).is_empty()) @@ -9924,7 +9942,11 @@ impl Editor { h_flex() .px_0p5() .when(is_platform_style_mac, |parent| parent.gap_0p5()) - .font(theme::ThemeSettings::get_global(cx).buffer_font.clone()) + .font( + theme_settings::ThemeSettings::get_global(cx) + .buffer_font + .clone(), + ) .text_size(TextSize::XSmall.rems(cx)) .child(h_flex().children(ui::render_modifiers( keystroke.modifiers(), @@ -9938,7 +9960,7 @@ impl Editor { }) .when(!is_platform_style_mac, |parent| { parent.child( - Key::new(util::capitalize(keystroke.key()), Some(Color::Default)) + Key::new(ui::utils::capitalize(keystroke.key()), Some(Color::Default)) .size(Some(IconSize::XSmall.rems().into())), ) }) @@ -9955,7 +9977,11 @@ impl Editor { if keystroke.modifiers().modified() { h_flex() - .font(theme::ThemeSettings::get_global(cx).buffer_font.clone()) + .font( + theme_settings::ThemeSettings::get_global(cx) + .buffer_font + .clone(), + ) .when(is_platform_style_mac, |parent| parent.gap_1()) .child(h_flex().children(ui::render_modifiers( keystroke.modifiers(), @@ -9966,7 +9992,7 @@ impl Editor { ))) .into_any() } else { - Key::new(util::capitalize(keystroke.key()), Some(color)) + Key::new(ui::utils::capitalize(keystroke.key()), Some(color)) .size(Some(IconSize::XSmall.rems().into())) .into_any_element() } @@ -10461,7 +10487,11 @@ impl Editor { .gap_2() .pr_1() .overflow_x_hidden() - .font(theme::ThemeSettings::get_global(cx).buffer_font.clone()) + .font( + theme_settings::ThemeSettings::get_global(cx) + .buffer_font + .clone(), + ) .child(left) .child(preview), ) @@ -12254,15 +12284,15 @@ impl Editor { if hunk.is_created_file() { return None; } - let buffer = self.buffer.read(cx); - let diff = buffer.diff_for(hunk.buffer_id)?; - let buffer = buffer.buffer(hunk.buffer_id)?; - let buffer = buffer.read(cx); - let original_text = diff - .read(cx) - .base_text(cx) + let multi_buffer = self.buffer.read(cx); + let multi_buffer_snapshot = multi_buffer.snapshot(cx); + let diff_snapshot = multi_buffer_snapshot.diff_for_buffer_id(hunk.buffer_id)?; + let original_text = diff_snapshot + .base_text() .as_rope() .slice(hunk.diff_base_byte_range.start.0..hunk.diff_base_byte_range.end.0); + let buffer = multi_buffer.buffer(hunk.buffer_id)?; + let buffer = buffer.read(cx); let buffer_snapshot = buffer.snapshot(); let buffer_revert_changes = revert_changes.entry(buffer.remote_id()).or_default(); if let Err(i) = buffer_revert_changes.binary_search_by(|probe| { @@ -14000,6 +14030,8 @@ impl Editor { return; } + self.finalize_last_transaction(cx); + let clipboard_text = Cow::Borrowed(text.as_str()); self.transact(window, cx, |this, window, cx| { @@ -15545,7 +15577,8 @@ impl Editor { } } - nav_history.push(Some(data), cx); + let cursor_row = data.cursor_position.row; + nav_history.push(Some(data), Some(cursor_row), cx); cx.emit(EditorEvent::PushedToNavHistory { anchor: cursor_anchor, is_deactivate, @@ -16106,11 +16139,8 @@ impl Editor { }; let mut new_selections = Vec::new(); - - let reversed = self - .selections - .oldest::(&display_map) - .reversed; + let initial_selection = self.selections.oldest::(&display_map); + let reversed = initial_selection.reversed; let buffer = display_map.buffer_snapshot(); let query_matches = select_next_state .query @@ -16124,21 +16154,33 @@ impl Editor { MultiBufferOffset(query_match.start())..MultiBufferOffset(query_match.end()) }; - if !select_next_state.wordwise - || (!buffer.is_inside_word(offset_range.start, None) - && !buffer.is_inside_word(offset_range.end, None)) - { - new_selections.push(offset_range.start..offset_range.end); - } - } + let is_partial_word_match = select_next_state.wordwise + && (buffer.is_inside_word(offset_range.start, None) + || buffer.is_inside_word(offset_range.end, None)); - select_next_state.done = true; + let is_initial_selection = MultiBufferOffset(query_match.start()) + == initial_selection.start + && MultiBufferOffset(query_match.end()) == initial_selection.end; - if new_selections.is_empty() { - log::error!("bug: new_selections is empty in select_all_matches"); - return Ok(()); + if !is_partial_word_match && !is_initial_selection { + new_selections.push(offset_range); + } } + // Ensure that the initial range is the last selection, as + // `MutableSelectionsCollection::select_ranges` makes the last selection + // the newest selection, which the editor then relies on as the primary + // cursor for scroll targeting. Without this, the last match would then + // be automatically focused when the user started editing the selected + // matches. + let initial_directed_range = if reversed { + initial_selection.end..initial_selection.start + } else { + initial_selection.start..initial_selection.end + }; + new_selections.push(initial_directed_range); + + select_next_state.done = true; self.unfold_ranges(&new_selections, false, false, cx); self.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { selections.select_ranges(new_selections) @@ -18189,6 +18231,20 @@ impl Editor { for ranges in locations.values_mut() { ranges.sort_by_key(|range| (range.start, Reverse(range.end))); ranges.dedup(); + // Merge overlapping or contained ranges. After sorting by + // (start, Reverse(end)), we can merge in a single pass: + // if the next range starts before the current one ends, + // extend the current range's end if needed. + let mut i = 0; + while i + 1 < ranges.len() { + if ranges[i + 1].start <= ranges[i].end { + let merged_end = ranges[i].end.max(ranges[i + 1].end); + ranges[i].end = merged_end; + ranges.remove(i + 1); + } else { + i += 1; + } + } let fits_in_one_excerpt = ranges .iter() .tuple_windows() @@ -19147,7 +19203,7 @@ impl Editor { move |cx: &mut BlockContext| { let mut text_style = cx.editor_style.text.clone(); if let Some(highlight_style) = old_highlight_id - .and_then(|h| h.style(&cx.editor_style.syntax)) + .and_then(|h| cx.editor_style.syntax.get(h).cloned()) { text_style = text_style.highlight(highlight_style); } @@ -24424,7 +24480,7 @@ impl Editor { return None; } - let theme_settings = theme::ThemeSettings::get_global(cx); + let theme_settings = theme_settings::ThemeSettings::get_global(cx); let theme = cx.theme(); let accent_colors = theme.accents().clone(); @@ -24464,9 +24520,8 @@ impl Editor { |mut acc, buffer| { let buffer = buffer.read(cx); let language = buffer.language().map(|language| language.name()); - if let hash_map::Entry::Vacant(v) = acc.entry(language.clone()) { - let file = buffer.file(); - v.insert(language_settings(language, file, cx).into_owned()); + if let hash_map::Entry::Vacant(v) = acc.entry(language) { + v.insert(LanguageSettings::for_buffer(&buffer, cx).into_owned()); } acc }, @@ -25027,7 +25082,8 @@ impl Editor { for chunk in chunks { let highlight = chunk .syntax_highlight_id - .and_then(|id| id.name(&style.syntax)); + .and_then(|id| style.syntax.get_capture_name(id)); + let mut chunk_lines = chunk.text.split('\n').peekable(); while let Some(text) = chunk_lines.next() { let mut merged_with_last_token = false; @@ -25205,7 +25261,7 @@ impl Editor { { self.hide_context_menu(window, cx); } - self.take_active_edit_prediction(cx); + self.take_active_edit_prediction(true, cx); cx.emit(EditorEvent::Blurred); cx.notify(); } @@ -25968,10 +26024,9 @@ fn process_completion_for_edit( CompletionIntent::CompleteWithInsert => false, CompletionIntent::CompleteWithReplace => true, CompletionIntent::Complete | CompletionIntent::Compose => { - let insert_mode = - language_settings(buffer.language().map(|l| l.name()), buffer.file(), cx) - .completions - .lsp_insert_mode; + let insert_mode = LanguageSettings::for_buffer(&buffer, cx) + .completions + .lsp_insert_mode; match insert_mode { LspInsertMode::Insert => false, LspInsertMode::Replace => true, @@ -28836,49 +28891,58 @@ pub fn styled_runs_for_code_label<'a>( ..Default::default() }; + if label.runs.is_empty() { + let desc_start = label.filter_range.end; + let fade_run = + (desc_start < label.text.len()).then(|| (desc_start..label.text.len(), fade_out)); + return Either::Left(fade_run.into_iter()); + } + let mut prev_end = label.filter_range.end; - label - .runs - .iter() - .enumerate() - .flat_map(move |(ix, (range, highlight_id))| { - let style = if *highlight_id == language::HighlightId::TABSTOP_INSERT_ID { - HighlightStyle { - color: Some(local_player.cursor), - ..Default::default() - } - } else if *highlight_id == language::HighlightId::TABSTOP_REPLACE_ID { - HighlightStyle { - background_color: Some(local_player.selection), - ..Default::default() - } - } else if let Some(style) = highlight_id.style(syntax_theme) { - style - } else { - return Default::default(); - }; - let muted_style = style.highlight(fade_out); + Either::Right( + label + .runs + .iter() + .enumerate() + .flat_map(move |(ix, (range, highlight_id))| { + let style = if *highlight_id == language::HighlightId::TABSTOP_INSERT_ID { + HighlightStyle { + color: Some(local_player.cursor), + ..Default::default() + } + } else if *highlight_id == language::HighlightId::TABSTOP_REPLACE_ID { + HighlightStyle { + background_color: Some(local_player.selection), + ..Default::default() + } + } else if let Some(style) = syntax_theme.get(*highlight_id).cloned() { + style + } else { + return Default::default(); + }; - let mut runs = SmallVec::<[(Range, HighlightStyle); 3]>::new(); - if range.start >= label.filter_range.end { - if range.start > prev_end { - runs.push((prev_end..range.start, fade_out)); + let mut runs = SmallVec::<[(Range, HighlightStyle); 3]>::new(); + let muted_style = style.highlight(fade_out); + if range.start >= label.filter_range.end { + if range.start > prev_end { + runs.push((prev_end..range.start, fade_out)); + } + runs.push((range.clone(), muted_style)); + } else if range.end <= label.filter_range.end { + runs.push((range.clone(), style)); + } else { + runs.push((range.start..label.filter_range.end, style)); + runs.push((label.filter_range.end..range.end, muted_style)); } - runs.push((range.clone(), muted_style)); - } else if range.end <= label.filter_range.end { - runs.push((range.clone(), style)); - } else { - runs.push((range.start..label.filter_range.end, style)); - runs.push((label.filter_range.end..range.end, muted_style)); - } - prev_end = cmp::max(prev_end, range.end); + prev_end = cmp::max(prev_end, range.end); - if ix + 1 == label.runs.len() && label.text.len() > prev_end { - runs.push((prev_end..label.text.len(), fade_out)); - } + if ix + 1 == label.runs.len() && label.text.len() > prev_end { + runs.push((prev_end..label.text.len(), fade_out)); + } - runs - }) + runs + }), + ) } pub(crate) fn split_words(text: &str) -> impl std::iter::Iterator + '_ { diff --git a/crates/editor/src/editor_settings.rs b/crates/editor/src/editor_settings.rs index 98283f045853e1df0dab7ffd0955aa52249377e6..e4a20476419578ff646952c84b399e2333f0a411 100644 --- a/crates/editor/src/editor_settings.rs +++ b/crates/editor/src/editor_settings.rs @@ -10,7 +10,7 @@ pub use settings::{ ScrollbarDiagnostics, SeedQuerySetting, ShowMinimap, SnippetSortOrder, }; use settings::{RegisterSetting, RelativeLineNumbers, Settings}; -use ui::scrollbars::{ScrollbarVisibility, ShowScrollbar}; +use ui::scrollbars::ShowScrollbar; /// Imports from the VSCode settings at /// https://code.visualstudio.com/docs/reference/default-settings @@ -60,6 +60,7 @@ pub struct EditorSettings { pub completion_menu_scrollbar: ShowScrollbar, pub completion_detail_alignment: CompletionDetailAlignment, pub diff_view_style: DiffViewStyle, + pub minimum_split_diff_width: f32, } #[derive(Debug, Clone)] pub struct Jupyter { @@ -183,12 +184,6 @@ impl EditorSettings { } } -impl ScrollbarVisibility for EditorSettings { - fn visibility(&self, _cx: &App) -> ShowScrollbar { - self.scrollbar.show - } -} - impl Settings for EditorSettings { fn from_settings(content: &settings::SettingsContent) -> Self { let editor = content.editor.clone(); @@ -217,7 +212,7 @@ impl Settings for EditorSettings { code_actions: toolbar.code_actions.unwrap(), }, scrollbar: Scrollbar { - show: scrollbar.show.map(Into::into).unwrap(), + show: scrollbar.show.map(ui_scrollbar_settings_from_raw).unwrap(), git_diff: scrollbar.git_diff.unwrap() && content .git @@ -294,9 +289,33 @@ impl Settings for EditorSettings { }, lsp_document_colors: editor.lsp_document_colors.unwrap(), minimum_contrast_for_highlights: editor.minimum_contrast_for_highlights.unwrap().0, - completion_menu_scrollbar: editor.completion_menu_scrollbar.map(Into::into).unwrap(), + completion_menu_scrollbar: editor + .completion_menu_scrollbar + .map(ui_scrollbar_settings_from_raw) + .unwrap(), completion_detail_alignment: editor.completion_detail_alignment.unwrap(), diff_view_style: editor.diff_view_style.unwrap(), + minimum_split_diff_width: editor.minimum_split_diff_width.unwrap(), } } } + +#[derive(Default)] +pub struct EditorSettingsScrollbarProxy; + +impl ui::scrollbars::ScrollbarVisibility for EditorSettingsScrollbarProxy { + fn visibility(&self, cx: &App) -> ShowScrollbar { + EditorSettings::get_global(cx).scrollbar.show + } +} + +pub fn ui_scrollbar_settings_from_raw( + value: settings::ShowScrollbar, +) -> ui::scrollbars::ShowScrollbar { + match value { + settings::ShowScrollbar::Auto => ShowScrollbar::Auto, + settings::ShowScrollbar::System => ShowScrollbar::System, + settings::ShowScrollbar::Always => ShowScrollbar::Always, + settings::ShowScrollbar::Never => ShowScrollbar::Never, + } +} diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 5360757ffe42c5c8d85dd1e8632c7bca62f467a8..3fcd0f08fd5faef55f4c77df674259cd30728c2b 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -23,7 +23,7 @@ use gpui::{ }; use indoc::indoc; use language::{ - BracketPairConfig, + BracketPair, BracketPairConfig, Capability::ReadWrite, DiagnosticSourceKind, FakeLspAdapter, IndentGuideSettings, LanguageConfig, LanguageConfigOverride, LanguageMatcher, LanguageName, LanguageQueries, Override, Point, @@ -49,7 +49,8 @@ use serde_json::{self, json}; use settings::{ AllLanguageSettingsContent, DelayMs, EditorSettingsContent, GlobalLspSettingsContent, IndentGuideBackgroundColoring, IndentGuideColoring, InlayHintSettingsContent, - ProjectSettingsContent, SearchSettingsContent, SettingsContent, SettingsStore, + ProjectSettingsContent, ScrollBeyondLastLine, SearchSettingsContent, SettingsContent, + SettingsStore, }; use std::borrow::Cow; use std::{cell::RefCell, future::Future, rc::Rc, sync::atomic::AtomicBool, time::Instant}; @@ -1121,7 +1122,93 @@ fn test_cancel(cx: &mut TestAppContext) { } #[gpui::test] -fn test_fold_action(cx: &mut TestAppContext) { +async fn test_fold_action(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.update_buffer(|buffer, cx| buffer.set_language(Some(rust_lang()), cx)); + cx.set_state(indoc! {" + impl Foo { + // Hello! + + fn a() { + 1 + } + + fn b() { + 2 + } + + fn c() { + 3 + } + }ˇ + "}); + + cx.update_editor(|editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(7), 0)..DisplayPoint::new(DisplayRow(12), 0) + ]); + }); + editor.fold(&Fold, window, cx); + assert_eq!( + editor.display_text(cx), + " + impl Foo { + // Hello! + + fn a() { + 1 + } + + fn b() {⋯} + + fn c() {⋯} + } + " + .unindent(), + ); + + editor.fold(&Fold, window, cx); + assert_eq!( + editor.display_text(cx), + " + impl Foo {⋯} + " + .unindent(), + ); + + editor.unfold_lines(&UnfoldLines, window, cx); + assert_eq!( + editor.display_text(cx), + " + impl Foo { + // Hello! + + fn a() { + 1 + } + + fn b() {⋯} + + fn c() {⋯} + } + " + .unindent(), + ); + + editor.unfold_lines(&UnfoldLines, window, cx); + assert_eq!( + editor.display_text(cx), + editor.buffer.read(cx).read(cx).text() + ); + }); +} + +#[gpui::test] +fn test_fold_action_without_language(cx: &mut TestAppContext) { init_test(cx, |_| {}); let editor = cx.add_window(|window, cx| { @@ -1440,6 +1527,36 @@ async fn test_fold_with_unindented_multiline_raw_string(cx: &mut TestAppContext) }); } +#[gpui::test] +async fn test_fold_with_unindented_multiline_raw_string_includes_closing_bracket( + cx: &mut TestAppContext, +) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.update_buffer(|buffer, cx| buffer.set_language(Some(rust_lang()), cx)); + cx.set_state(indoc! {" + ˇfn main() { + let s = r#\" + a + b + c + \"#; + } + "}); + + cx.update_editor(|editor, window, cx| { + editor.fold_at_level(&FoldAtLevel(1), window, cx); + assert_eq!( + editor.display_text(cx), + indoc! {" + fn main() {⋯} + "}, + ); + }); +} + #[gpui::test] async fn test_fold_with_unindented_multiline_block_comment(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -1489,6 +1606,35 @@ async fn test_fold_with_unindented_multiline_block_comment(cx: &mut TestAppConte }); } +#[gpui::test] +async fn test_fold_with_unindented_multiline_block_comment_includes_closing_bracket( + cx: &mut TestAppContext, +) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.update_buffer(|buffer, cx| buffer.set_language(Some(rust_lang()), cx)); + cx.set_state(indoc! {" + ˇfn main() { + let x = 1; + /* + unindented comment line + */ + } + "}); + + cx.update_editor(|editor, window, cx| { + editor.fold_at_level(&FoldAtLevel(1), window, cx); + assert_eq!( + editor.display_text(cx), + indoc! {" + fn main() {⋯} + "}, + ); + }); +} + #[gpui::test] fn test_fold_at_level(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -2639,6 +2785,60 @@ async fn test_autoscroll(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_exclude_overscroll_margin_clamps_scroll_position(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + update_test_editor_settings(cx, &|settings| { + settings.scroll_beyond_last_line = Some(ScrollBeyondLastLine::OnePage); + }); + + let mut cx = EditorTestContext::new(cx).await; + + let line_height = cx.update_editor(|editor, window, cx| { + editor.set_mode(EditorMode::Full { + scale_ui_elements_with_buffer_font_size: false, + show_active_line_background: false, + sizing_behavior: SizingBehavior::ExcludeOverscrollMargin, + }); + editor + .style(cx) + .text + .line_height_in_pixels(window.rem_size()) + }); + let window = cx.window; + cx.simulate_window_resize(window, size(px(1000.), 6. * line_height)); + cx.set_state( + &r#" + ˇone + two + three + four + five + six + seven + eight + nine + ten + eleven + "# + .unindent(), + ); + + cx.update_editor(|editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + let max_scroll_top = + (snapshot.max_point().row().as_f64() - editor.visible_line_count().unwrap() + 1.) + .max(0.); + + editor.set_scroll_position(gpui::Point::new(0., max_scroll_top + 10.), window, cx); + + assert_eq!( + editor.snapshot(window, cx).scroll_position(), + gpui::Point::new(0., max_scroll_top) + ); + }); +} + #[gpui::test] async fn test_move_page_up_page_down(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -8694,6 +8894,36 @@ async fn test_paste_multiline(cx: &mut TestAppContext) { )ˇ"}); } +#[gpui::test] +async fn test_paste_undo_does_not_include_preceding_edits(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.update_editor(|e, _, cx| { + e.buffer().update(cx, |buffer, cx| { + buffer.set_group_interval(Duration::from_secs(10), cx) + }) + }); + // Type some text + cx.set_state("ˇ"); + cx.update_editor(|e, window, cx| e.insert("hello", window, cx)); + // cx.assert_editor_state("helloˇ"); + + // Paste some text immediately after typing + cx.write_to_clipboard(ClipboardItem::new_string(" world".into())); + cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); + cx.assert_editor_state("hello worldˇ"); + + // Undo should only undo the paste, not the preceding typing + cx.update_editor(|e, window, cx| e.undo(&Undo, window, cx)); + cx.assert_editor_state("helloˇ"); + + // Undo again should undo the typing + cx.update_editor(|e, window, cx| e.undo(&Undo, window, cx)); + cx.assert_editor_state("ˇ"); +} + #[gpui::test] async fn test_paste_content_from_other_app(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -9823,7 +10053,6 @@ async fn test_select_all_matches_does_not_scroll(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; - let large_body_1 = "\nd".repeat(200); let large_body_2 = "\ne".repeat(200); @@ -9836,17 +10065,62 @@ async fn test_select_all_matches_does_not_scroll(cx: &mut TestAppContext) { scroll_position }); - cx.update_editor(|e, window, cx| e.select_all_matches(&SelectAllMatches, window, cx)) + cx.update_editor(|editor, window, cx| editor.select_all_matches(&SelectAllMatches, window, cx)) .unwrap(); cx.assert_editor_state(&format!( "«ˇa»bc\n«ˇa»bc{large_body_1} «ˇa»bc{large_body_2}\nef«ˇa»bc\n«ˇa»bc" )); - let scroll_position_after_selection = - cx.update_editor(|editor, _, cx| editor.scroll_position(cx)); - assert_eq!( - initial_scroll_position, scroll_position_after_selection, - "Scroll position should not change after selecting all matches" - ); + cx.update_editor(|editor, _, cx| { + assert_eq!( + editor.scroll_position(cx), + initial_scroll_position, + "Scroll position should not change after selecting all matches" + ) + }); + + // Simulate typing while the selections are active, as that is where the + // editor would attempt to actually scroll to the newest selection, which + // should have been set as the original selection to avoid scrolling to the + // last match. + cx.simulate_keystroke("x"); + cx.update_editor(|editor, _, cx| { + assert_eq!( + editor.scroll_position(cx), + initial_scroll_position, + "Scroll position should not change after editing all matches" + ) + }); + + cx.set_state(&format!( + "abc\nabc{large_body_1} «aˇ»bc{large_body_2}\nefabc\nabc" + )); + let initial_scroll_position = cx.update_editor(|editor, _, cx| { + let scroll_position = editor.scroll_position(cx); + assert!(scroll_position.y > 0.0, "Initial selection is between two large bodies and should have the editor scrolled to it"); + scroll_position + }); + + cx.update_editor(|editor, window, cx| editor.select_all_matches(&SelectAllMatches, window, cx)) + .unwrap(); + cx.assert_editor_state(&format!( + "«aˇ»bc\n«aˇ»bc{large_body_1} «aˇ»bc{large_body_2}\nef«aˇ»bc\n«aˇ»bc" + )); + cx.update_editor(|editor, _, cx| { + assert_eq!( + editor.scroll_position(cx), + initial_scroll_position, + "Scroll position should not change after selecting all matches" + ) + }); + + cx.simulate_keystroke("x"); + cx.update_editor(|editor, _, cx| { + assert_eq!( + editor.scroll_position(cx), + initial_scroll_position, + "Scroll position should not change after editing all matches" + ) + }); } #[gpui::test] @@ -20497,6 +20771,103 @@ async fn test_completions_with_additional_edits(cx: &mut TestAppContext) { cx.assert_editor_state("fn main() { let a = Some(2)ˇ; }"); } +#[gpui::test] +async fn test_completions_with_additional_edits_undo(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + trigger_characters: Some(vec![".".to_string()]), + resolve_provider: Some(true), + ..Default::default() + }), + ..Default::default() + }, + cx, + ) + .await; + + cx.set_state("fn main() { let a = 2ˇ; }"); + cx.simulate_keystroke("."); + let completion_item = lsp::CompletionItem { + label: "some".into(), + kind: Some(lsp::CompletionItemKind::SNIPPET), + detail: Some("Wrap the expression in an `Option::Some`".to_string()), + documentation: Some(lsp::Documentation::MarkupContent(lsp::MarkupContent { + kind: lsp::MarkupKind::Markdown, + value: "```rust\nSome(2)\n```".to_string(), + })), + deprecated: Some(false), + sort_text: Some("fffffff2".to_string()), + filter_text: Some("some".to_string()), + insert_text_format: Some(lsp::InsertTextFormat::SNIPPET), + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: lsp::Range { + start: lsp::Position { + line: 0, + character: 22, + }, + end: lsp::Position { + line: 0, + character: 22, + }, + }, + new_text: "Some(2)".to_string(), + })), + additional_text_edits: Some(vec![lsp::TextEdit { + range: lsp::Range { + start: lsp::Position { + line: 0, + character: 20, + }, + end: lsp::Position { + line: 0, + character: 22, + }, + }, + new_text: "".to_string(), + }]), + ..Default::default() + }; + + let closure_completion_item = completion_item.clone(); + let mut request = cx.set_request_handler::(move |_, _, _| { + let task_completion_item = closure_completion_item.clone(); + async move { + Ok(Some(lsp::CompletionResponse::Array(vec![ + task_completion_item, + ]))) + } + }); + + request.next().await; + + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + let apply_additional_edits = cx.update_editor(|editor, window, cx| { + editor + .confirm_completion(&ConfirmCompletion::default(), window, cx) + .unwrap() + }); + cx.assert_editor_state("fn main() { let a = 2.Some(2)ˇ; }"); + + cx.set_request_handler::(move |_, _, _| { + let task_completion_item = completion_item.clone(); + async move { Ok(task_completion_item) } + }) + .next() + .await + .unwrap(); + apply_additional_edits.await.unwrap(); + cx.assert_editor_state("fn main() { let a = Some(2)ˇ; }"); + + cx.update_editor(|editor, window, cx| { + editor.undo(&crate::Undo, window, cx); + }); + cx.assert_editor_state("fn main() { let a = 2.ˇ; }"); +} + #[gpui::test] async fn test_completions_with_additional_edits_and_multiple_cursors(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -23761,8 +24132,7 @@ async fn test_indent_guide_with_folds(cx: &mut TestAppContext) { " fn main() { if a { - b(⋯ - ) + b(⋯) } else { e( f @@ -25185,6 +25555,55 @@ async fn test_goto_definition_far_ranges_open_multibuffer(cx: &mut TestAppContex }); } +#[gpui::test] +async fn test_goto_definition_contained_ranges(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + definition_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + + // The LSP returns two single-line definitions on the same row where one + // range contains the other. Both are on the same line so the + // `fits_in_one_excerpt` check won't underflow, and the code reaches + // `change_selections`. + cx.set_state( + &r#"fn caller() { + let _ = ˇtarget(); + } + fn target_outer() { fn target_inner() {} } + "# + .unindent(), + ); + + // Return two definitions on the same line: an outer range covering the + // whole line and an inner range for just the inner function name. + cx.set_request_handler::(move |url, _, _| async move { + Ok(Some(lsp::GotoDefinitionResponse::Array(vec![ + // Inner range: just "target_inner" (cols 23..35) + lsp::Location { + uri: url.clone(), + range: lsp::Range::new(lsp::Position::new(3, 23), lsp::Position::new(3, 35)), + }, + // Outer range: the whole line (cols 0..48) + lsp::Location { + uri: url, + range: lsp::Range::new(lsp::Position::new(3, 0), lsp::Position::new(3, 48)), + }, + ]))) + }); + + let navigated = cx + .update_editor(|editor, window, cx| editor.go_to_definition(&GoToDefinition, window, cx)) + .await + .expect("Failed to navigate to definitions"); + assert_eq!(navigated, Navigated::Yes); +} + #[gpui::test] async fn test_find_all_references_editor_reuse(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -29533,7 +29952,7 @@ pub(crate) fn init_test(cx: &mut TestAppContext, f: fn(&mut AllLanguageSettingsC assets::Assets.load_test_fonts(cx); let store = SettingsStore::test(cx); cx.set_global(store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); release_channel::init(semver::Version::new(0, 0, 0), cx); crate::init(cx); }); @@ -32581,10 +33000,12 @@ async fn test_local_worktree_trust(cx: &mut TestAppContext) { 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, + language::language_settings::LanguageSettings::for_buffer( + buffer_before_approval.read(cx), + cx + ) + .language_servers, ["...".to_string()], "local .zed/settings.json must not apply before trust approval" ) @@ -32612,10 +33033,12 @@ async fn test_local_worktree_trust(cx: &mut TestAppContext) { 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, + language::language_settings::LanguageSettings::for_buffer( + buffer_before_approval.read(cx), + cx + ) + .language_servers, ["override-rust-analyzer".to_string()], "local .zed/settings.json should apply after trust approval" ) @@ -34670,6 +35093,95 @@ async fn test_restore_and_next(cx: &mut TestAppContext) { ); } +#[gpui::test] +async fn test_restore_hunk_with_stale_base_text(cx: &mut TestAppContext) { + // Regression test: prepare_restore_change must read base_text from the same + // snapshot the hunk came from, not from the live BufferDiff entity. The live + // entity's base_text may have already been updated asynchronously (e.g. + // because git HEAD changed) while the MultiBufferSnapshot still holds the + // old hunk byte ranges — using both together causes Rope::slice to panic + // when the old range exceeds the new base text length. + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + let long_base_text = "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n"; + cx.set_state("ˇONE\ntwo\nTHREE\nfour\nFIVE\nsix\nseven\neight\nnine\nten\n"); + cx.set_head_text(long_base_text); + + let buffer_id = cx.update_buffer(|buffer, _| buffer.remote_id()); + + // Verify we have hunks from the initial diff. + let has_hunks = cx.update_editor(|editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + let hunks = snapshot + .buffer_snapshot() + .diff_hunks_in_range(MultiBufferOffset(0)..snapshot.buffer_snapshot().len()); + hunks.count() > 0 + }); + assert!(has_hunks, "should have diff hunks before restoring"); + + // Now trigger a git HEAD change to a much shorter base text. + // After this, the live BufferDiff entity's base_text buffer will be + // updated synchronously (inside set_snapshot_with_secondary_inner), + // but DiffChanged is deferred until parsing_idle completes. + // We step the executor tick-by-tick to find the window where the + // live base_text is already short but the MultiBuffer snapshot is + // still stale (old hunks + old base_text). + let short_base_text = "short\n"; + let fs = cx.update_editor(|editor, _, cx| editor.project().unwrap().read(cx).fs().as_fake()); + let path = cx.update_buffer(|buffer, _| buffer.file().unwrap().path().clone()); + fs.set_head_for_repo( + &Path::new(path!("/root")).join(".git"), + &[(path.as_unix_str(), short_base_text.to_string())], + "newcommit", + ); + + // Step the executor tick-by-tick. At each step, check whether the + // race condition exists: live BufferDiff has short base text but + // the MultiBuffer snapshot still has old (long) hunks. + let mut found_race = false; + for _ in 0..200 { + cx.executor().tick(); + + let race_exists = cx.update_editor(|editor, _window, cx| { + let multi_buffer = editor.buffer().read(cx); + let diff_entity = match multi_buffer.diff_for(buffer_id) { + Some(d) => d, + None => return false, + }; + let live_base_len = diff_entity.read(cx).base_text(cx).len(); + let snapshot = multi_buffer.snapshot(cx); + let snapshot_base_len = snapshot + .diff_for_buffer_id(buffer_id) + .map(|d| d.base_text().len()); + // Race: live base text is shorter than what the snapshot knows. + live_base_len < long_base_text.len() && snapshot_base_len == Some(long_base_text.len()) + }); + + if race_exists { + found_race = true; + // The race window is open: the live entity has new (short) base + // text but the MultiBuffer snapshot still has old hunks with byte + // ranges computed against the old long base text. Attempt restore. + // Without the fix, this panics with "cannot summarize past end of + // rope". With the fix, it reads base_text from the stale snapshot + // (consistent with the stale hunks) and succeeds. + cx.update_editor(|editor, window, cx| { + editor.select_all(&SelectAll, window, cx); + editor.git_restore(&Default::default(), window, cx); + }); + break; + } + } + + assert!( + found_race, + "failed to observe the race condition between \ + live BufferDiff base_text and stale MultiBuffer snapshot; \ + the test may need adjustment if the async diff pipeline changed" + ); +} + #[gpui::test] async fn test_align_selections(cx: &mut TestAppContext) { init_test(cx, |_| {}); diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 59b474b1c91c0ad62eb9c260facb2ab46ef4f9c6..968048f68513a09c460bb06789103923bbbca828 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -83,7 +83,8 @@ use std::{ }; use sum_tree::Bias; use text::{BufferId, SelectionGoal}; -use theme::{ActiveTheme, Appearance, BufferLineHeight, PlayerColor}; +use theme::{ActiveTheme, Appearance, PlayerColor}; +use theme_settings::BufferLineHeight; use ui::utils::ensure_minimum_contrast; use ui::{ ButtonLike, ContextMenu, Indicator, KeyBinding, POPOVER_Y_PADDING, Tooltip, prelude::*, @@ -8448,7 +8449,7 @@ pub(crate) fn render_buffer_header( el.child(Icon::new(IconName::FileLock).color(Color::Muted)) }) .when_some(breadcrumbs, |then, breadcrumbs| { - let font = theme::ThemeSettings::get_global(cx) + let font = theme_settings::ThemeSettings::get_global(cx) .buffer_font .clone(); then.child(render_breadcrumb_text( @@ -9771,26 +9772,14 @@ impl Element for EditorElement { 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, - EditorMode::SingleLine - | EditorMode::AutoHeight { .. } - | EditorMode::Full { - sizing_behavior: SizingBehavior::ExcludeOverscrollMargin - | SizingBehavior::SizeByContent, - .. - } - ) { - (max_row - height_in_lines + 1.).max(0.) - } else { - let settings = EditorSettings::get_global(cx); - match settings.scroll_beyond_last_line { - ScrollBeyondLastLine::OnePage => max_row, - ScrollBeyondLastLine::Off => (max_row - height_in_lines + 1.).max(0.), - ScrollBeyondLastLine::VerticalScrollMargin => { - (max_row - height_in_lines + 1. + settings.vertical_scroll_margin) - .max(0.) - } + let scroll_beyond_last_line = self.editor.read(cx).scroll_beyond_last_line(cx); + let max_scroll_top = match scroll_beyond_last_line { + ScrollBeyondLastLine::OnePage => max_row, + ScrollBeyondLastLine::Off => (max_row - height_in_lines + 1.).max(0.), + ScrollBeyondLastLine::VerticalScrollMargin => { + let settings = EditorSettings::get_global(cx); + (max_row - height_in_lines + 1. + settings.vertical_scroll_margin) + .max(0.) } }; @@ -10308,6 +10297,7 @@ impl Element for EditorElement { ), longest_line_blame_width, EditorSettings::get_global(cx), + scroll_beyond_last_line, ); let mut scroll_width = scrollbar_layout_information.scroll_range.width; @@ -11186,8 +11176,9 @@ impl ScrollbarLayoutInformation { document_size: Size, longest_line_blame_width: Pixels, settings: &EditorSettings, + scroll_beyond_last_line: ScrollBeyondLastLine, ) -> Self { - let vertical_overscroll = match settings.scroll_beyond_last_line { + let vertical_overscroll = match scroll_beyond_last_line { ScrollBeyondLastLine::OnePage => editor_bounds.size.height, ScrollBeyondLastLine::Off => glyph_grid_cell.height, ScrollBeyondLastLine::VerticalScrollMargin => { diff --git a/crates/editor/src/folding_ranges.rs b/crates/editor/src/folding_ranges.rs index 745fdcbe30a0aede4f364afd5c58958c74b3da79..de32f481d52e501eea8f7814f4b114fbdbbd0458 100644 --- a/crates/editor/src/folding_ranges.rs +++ b/crates/editor/src/folding_ranges.rs @@ -1,6 +1,6 @@ use futures::future::join_all; use itertools::Itertools; -use language::language_settings::language_settings; +use language::language_settings::LanguageSettings; use text::BufferId; use ui::{Context, Window}; @@ -29,13 +29,9 @@ impl Editor { let id = buffer.read(cx).remote_id(); (for_buffer.is_none_or(|target| target == id)) && self.registered_buffers.contains_key(&id) - && language_settings( - buffer.read(cx).language().map(|l| l.name()), - buffer.read(cx).file(), - cx, - ) - .document_folding_ranges - .enabled() + && LanguageSettings::for_buffer(buffer.read(cx), cx) + .document_folding_ranges + .enabled() }) .unique_by(|buffer| buffer.read(cx).remote_id()) .collect::>(); @@ -104,7 +100,7 @@ impl Editor { .into_iter() .filter(|buffer| { let buffer = buffer.read(cx); - !language_settings(buffer.language().map(|l| l.name()), buffer.file(), cx) + !LanguageSettings::for_buffer(&buffer, cx) .document_folding_ranges .enabled() }) @@ -538,7 +534,7 @@ mod tests { snapshot.is_line_folded(MultiBufferRow(0)), "Indentation-based fold should work on the function" ); - assert_eq!(editor.display_text(cx), "fn main() {⋯\n}\n",); + assert_eq!(editor.display_text(cx), "fn main() {⋯}\n",); }); cx.update_editor(|editor, window, cx| { @@ -666,7 +662,7 @@ mod tests { snapshot.is_line_folded(MultiBufferRow(0)), "Indentation-based fold should work again after switching back" ); - assert_eq!(editor.display_text(cx), "fn main() {⋯\n}\n",); + assert_eq!(editor.display_text(cx), "fn main() {⋯}\n",); }); } diff --git a/crates/editor/src/git/blame.rs b/crates/editor/src/git/blame.rs index c705eb3996ace228f915303f049853bb2364aa2e..827d182a0f11508ae301691f832e7ec04a728364 100644 --- a/crates/editor/src/git/blame.rs +++ b/crates/editor/src/git/blame.rs @@ -746,7 +746,7 @@ mod tests { let settings = SettingsStore::test(cx); cx.set_global(settings); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); crate::init(cx); }); diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index 99069cac6ceeec3983d6713777007876c74c8d19..9b127a8f1bc089d9cee28254c6b8ffc181677765 100644 --- a/crates/editor/src/hover_popover.rs +++ b/crates/editor/src/hover_popover.rs @@ -2,6 +2,7 @@ use crate::{ ActiveDiagnostic, Anchor, AnchorRangeExt, DisplayPoint, DisplayRow, Editor, EditorSettings, EditorSnapshot, GlobalDiagnosticRenderer, HighlightKey, Hover, display_map::{InlayOffset, ToDisplayPoint, is_invisible}, + editor_settings::EditorSettingsScrollbarProxy, hover_links::{InlayHighlight, RangeInEditor}, movement::TextLayoutDetails, scroll::ScrollAmount, @@ -26,7 +27,7 @@ use std::{ }; use std::{ops::Range, sync::Arc, time::Duration}; use std::{path::PathBuf, rc::Rc}; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{CopyButton, Scrollbars, WithScrollbar, prelude::*, theme_is_transparent}; use url::Url; use util::TryFutureExt; @@ -1048,7 +1049,7 @@ impl InfoPopover { ), ) .custom_scrollbars( - Scrollbars::for_settings::() + Scrollbars::for_settings::() .tracked_scroll_handle(&self.scroll_handle), window, cx, @@ -1176,7 +1177,7 @@ impl DiagnosticPopover { CopyButton::new("copy-diagnostic", message).tooltip_label("Copy Diagnostic") })) .custom_scrollbars( - Scrollbars::for_settings::() + Scrollbars::for_settings::() .tracked_scroll_handle(&self.scroll_handle), window, cx, diff --git a/crates/editor/src/indent_guides.rs b/crates/editor/src/indent_guides.rs index 469099c6adf5b42f0d5e976abded4f7f3f639075..17ce3bfef9188912aba7e99644c5ca934fe32a71 100644 --- a/crates/editor/src/indent_guides.rs +++ b/crates/editor/src/indent_guides.rs @@ -2,7 +2,7 @@ use std::{cmp::Ordering, ops::Range, time::Duration}; use collections::HashSet; use gpui::{App, AppContext as _, Context, Task, Window}; -use language::language_settings::language_settings; +use language::language_settings::LanguageSettings; use multi_buffer::{IndentGuide, MultiBufferRow, ToPoint}; use text::{LineIndent, Point}; use util::ResultExt; @@ -37,13 +37,9 @@ impl Editor { ) -> Option> { let show_indent_guides = self.should_show_indent_guides().unwrap_or_else(|| { if let Some(buffer) = self.buffer().read(cx).as_singleton() { - language_settings( - buffer.read(cx).language().map(|l| l.name()), - buffer.read(cx).file(), - cx, - ) - .indent_guides - .enabled + LanguageSettings::for_buffer(buffer.read(cx), cx) + .indent_guides + .enabled } else { true } diff --git a/crates/editor/src/inlays/inlay_hints.rs b/crates/editor/src/inlays/inlay_hints.rs index 414829dc3bbcd89f5f4e4337a955cfff5bb57fca..8422937ab81a392ad7d1187adcab765cc7f6875f 100644 --- a/crates/editor/src/inlays/inlay_hints.rs +++ b/crates/editor/src/inlays/inlay_hints.rs @@ -11,7 +11,7 @@ use gpui::{App, Entity, Pixels, Task}; use itertools::Itertools; use language::{ BufferRow, - language_settings::{InlayHintKind, InlayHintSettings, language_settings}, + language_settings::{InlayHintKind, InlayHintSettings}, }; use lsp::LanguageServerId; use multi_buffer::{Anchor, ExcerptId, MultiBufferSnapshot}; @@ -38,9 +38,7 @@ pub fn inlay_hint_settings( snapshot: &MultiBufferSnapshot, cx: &mut Context, ) -> InlayHintSettings { - let file = snapshot.file_at(location); - let language = snapshot.language_at(location).map(|l| l.name()); - language_settings(language, file, cx).inlay_hints + snapshot.language_settings_at(location, cx).inlay_hints } #[derive(Debug)] @@ -4800,7 +4798,7 @@ let c = 3;"# cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); release_channel::init(semver::Version::new(0, 0, 0), cx); crate::init(cx); }); diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 0cd84ec68257f7ab1e6054ab7f2464fb09113298..d14078e79abdbfe40879da09221bad7bef47475a 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -980,7 +980,9 @@ impl Item for Editor { // In a non-singleton case, the breadcrumbs are actually shown on sticky file headers of the multibuffer. fn breadcrumbs(&self, cx: &App) -> Option<(Vec, Option)> { if self.buffer.read(cx).is_singleton() { - let font = theme::ThemeSettings::get_global(cx).buffer_font.clone(); + let font = theme_settings::ThemeSettings::get_global(cx) + .buffer_font + .clone(); Some((self.breadcrumbs_inner(cx)?, Some(font))) } else { None diff --git a/crates/editor/src/jsx_tag_auto_close.rs b/crates/editor/src/jsx_tag_auto_close.rs index a7c0c5eed2aed44d69bcaa3657894bad4d9deeb1..b91f039aff7cfb8bc7997cfbf63abb8dbe4662e5 100644 --- a/crates/editor/src/jsx_tag_auto_close.rs +++ b/crates/editor/src/jsx_tag_auto_close.rs @@ -5,7 +5,7 @@ use multi_buffer::{BufferOffset, MultiBuffer, ToOffset}; use std::ops::Range; use util::ResultExt as _; -use language::{BufferSnapshot, JsxTagAutoCloseConfig, Node}; +use language::{BufferSnapshot, JsxTagAutoCloseConfig, Node, language_settings::LanguageSettings}; use text::{Anchor, OffsetRangeExt as _}; use crate::{Editor, SelectionEffects}; @@ -322,12 +322,10 @@ pub(crate) fn refresh_enabled_in_any_buffer( if language.config().jsx_tag_auto_close.is_none() { continue; } - let language_settings = language::language_settings::language_settings( - Some(language.name()), - snapshot.file(), - cx, - ); - if language_settings.jsx_tag_auto_close { + let should_auto_close = + LanguageSettings::resolve(Some(buffer), Some(&language.name()), cx) + .jsx_tag_auto_close; + if should_auto_close { found_enabled = true; } } diff --git a/crates/editor/src/lsp_ext.rs b/crates/editor/src/lsp_ext.rs index d9ae428e8ffa62a0bf2756eea834bbc955f6e833..ef0f92de79b0fe7a7e4a495dc29c1305b2f5eefa 100644 --- a/crates/editor/src/lsp_ext.rs +++ b/crates/editor/src/lsp_ext.rs @@ -2,10 +2,9 @@ use std::sync::Arc; use std::time::Duration; use crate::Editor; -use collections::HashMap; +use collections::{HashMap, HashSet}; use gpui::AsyncApp; use gpui::{App, Entity, Task}; -use itertools::Itertools; use language::Buffer; use language::Language; use lsp::LanguageServerId; @@ -33,22 +32,34 @@ where F: Fn(&Language) -> bool, { let project = editor.project.clone()?; + let multi_buffer = editor.buffer(); + let mut seen_buffer_ids = HashSet::default(); editor .selections .disjoint_anchors_arc() .iter() - .filter_map(|selection| Some((selection.head(), selection.head().text_anchor.buffer_id?))) - .unique_by(|(_, buffer_id)| *buffer_id) - .find_map(|(trigger_anchor, buffer_id)| { - let buffer = editor.buffer().read(cx).buffer(buffer_id)?; - let language = buffer.read(cx).language_at(trigger_anchor.text_anchor)?; + .find_map(|selection| { + let multi_buffer = multi_buffer.read(cx); + let (position, buffer) = multi_buffer + .buffer_for_anchor(selection.head(), cx) + .map(|buffer| (selection.head(), buffer)) + .or_else(|| { + multi_buffer + .buffer_for_anchor(selection.tail(), cx) + .map(|buffer| (selection.tail(), buffer)) + })?; + if !seen_buffer_ids.insert(buffer.read(cx).remote_id()) { + return None; + } + + let language = buffer.read(cx).language_at(position.text_anchor)?; if filter_language(&language) { let server_id = buffer.update(cx, |buffer, cx| { project .read(cx) .language_server_id_for_name(buffer, &language_server_name, cx) })?; - Some((trigger_anchor, language, server_id, buffer)) + Some((position, language, server_id, buffer)) } else { None } @@ -173,3 +184,100 @@ pub fn lsp_tasks( .await }) } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use futures::StreamExt as _; + use gpui::{AppContext as _, Entity, TestAppContext}; + use language::{FakeLspAdapter, Language}; + use languages::rust_lang; + use lsp::{LanguageServerId, LanguageServerName}; + use multi_buffer::{Anchor, MultiBuffer}; + use project::{FakeFs, Project}; + use util::path; + + use crate::{MoveToEnd, editor_tests::init_test, test::build_editor_with_project}; + + use super::find_specific_language_server_in_selection; + + #[gpui::test] + async fn test_find_language_server_at_end_of_file(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_file(path!("/file.rs"), "fn main() {}".into()) + .await; + + let project = Project::test(fs, [path!("/file.rs").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_servers = + language_registry.register_fake_lsp("Rust", FakeLspAdapter::default()); + + let underlying_buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/file.rs"), cx) + }) + .await + .unwrap(); + + let buffer = cx.new(|cx| MultiBuffer::singleton(underlying_buffer.clone(), cx)); + let (editor, cx) = cx.add_window_view(|window, cx| { + build_editor_with_project(project.clone(), buffer, window, cx) + }); + + let fake_server = fake_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + let expected_server_id = fake_server.server.server_id(); + let language_server_name = LanguageServerName::new_static("the-fake-language-server"); + let filter = |language: &Language| language.name().as_ref() == "Rust"; + + let assert_result = |result: Option<( + Anchor, + Arc, + LanguageServerId, + Entity, + )>, + message: &str| { + let (_, language, server_id, buffer) = result.expect(message); + assert_eq!( + language.name().as_ref(), + "Rust", + "{message}: wrong language" + ); + assert_eq!(server_id, expected_server_id, "{message}: wrong server ID"); + assert_eq!(buffer, underlying_buffer, "{message}: wrong buffer"); + }; + + editor.update(cx, |editor, cx| { + assert_result( + find_specific_language_server_in_selection( + editor, + cx, + filter, + language_server_name.clone(), + ), + "should find correct language server at beginning of file", + ); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.move_to_end(&MoveToEnd, window, cx); + }); + + editor.update(cx, |editor, cx| { + assert_result( + find_specific_language_server_in_selection( + editor, + cx, + filter, + language_server_name.clone(), + ), + "should find correct language server at end of file", + ); + }); + } +} diff --git a/crates/editor/src/movement.rs b/crates/editor/src/movement.rs index 6bf6449506f1c1eb2a71270546ad3b063f7e9022..955f511577d2cbfede1a4cb4eb6d99e429c879d6 100644 --- a/crates/editor/src/movement.rs +++ b/crates/editor/src/movement.rs @@ -1393,7 +1393,7 @@ mod tests { fn init_test(cx: &mut gpui::App) { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); crate::init(cx); } } diff --git a/crates/editor/src/runnables.rs b/crates/editor/src/runnables.rs index fb234f9bcf9976fa71ac6cac055d3312f96a3d9a..92663ff9a96d1f84e2de387917e2d6a32b16aa00 100644 --- a/crates/editor/src/runnables.rs +++ b/crates/editor/src/runnables.rs @@ -637,17 +637,17 @@ impl Editor { runnable: &mut Runnable, cx: &mut App, ) -> Task> { - let (inventory, worktree_id, file) = project.read_with(cx, |project, cx| { - let (worktree_id, file) = project - .buffer_for_id(runnable.buffer, cx) + let (inventory, worktree_id, buffer) = project.read_with(cx, |project, cx| { + let buffer = project.buffer_for_id(runnable.buffer, cx); + let worktree_id = buffer + .as_ref() .and_then(|buffer| buffer.read(cx).file()) - .map(|file| (file.worktree_id(cx), file.clone())) - .unzip(); + .map(|file| file.worktree_id(cx)); ( project.task_store().read(cx).task_inventory().cloned(), worktree_id, - file, + buffer, ) }); @@ -658,7 +658,12 @@ impl Editor { if let Some(inventory) = inventory { for RunnableTag(tag) in tags { let new_tasks = inventory.update(cx, |inventory, cx| { - inventory.list_tasks(file.clone(), Some(language.clone()), worktree_id, cx) + inventory.list_tasks( + buffer.clone(), + Some(language.clone()), + worktree_id, + cx, + ) }); templates_with_tags.extend(new_tasks.await.into_iter().filter( move |(_, template)| { @@ -715,7 +720,7 @@ mod tests { use std::{sync::Arc, time::Duration}; use futures::StreamExt as _; - use gpui::{AppContext as _, Task, TestAppContext}; + use gpui::{AppContext as _, Entity, Task, TestAppContext}; use indoc::indoc; use language::{ContextProvider, FakeLspAdapter}; use languages::rust_lang; @@ -742,7 +747,7 @@ mod tests { impl ContextProvider for TestRustContextProvider { fn associated_tasks( &self, - _: Option>, + _: Option>, _: &gpui::App, ) -> Task> { Task::ready(Some(TaskTemplates(vec![ @@ -769,7 +774,7 @@ mod tests { impl ContextProvider for TestRustContextProviderWithLsp { fn associated_tasks( &self, - _: Option>, + _: Option>, _: &gpui::App, ) -> Task> { Task::ready(Some(TaskTemplates(vec![TaskTemplate { diff --git a/crates/editor/src/scroll.rs b/crates/editor/src/scroll.rs index b10f7650a051c3ad3c31c1426eb98aeee4f9da07..c2280e90f7d30d53c0818119df70b7c32161b78b 100644 --- a/crates/editor/src/scroll.rs +++ b/crates/editor/src/scroll.rs @@ -5,7 +5,7 @@ pub(crate) mod scroll_amount; use crate::editor_settings::ScrollBeyondLastLine; use crate::{ Anchor, DisplayPoint, DisplayRow, Editor, EditorEvent, EditorMode, EditorSettings, - InlayHintRefreshReason, MultiBufferSnapshot, RowExt, ToPoint, + InlayHintRefreshReason, MultiBufferSnapshot, RowExt, SizingBehavior, ToPoint, display_map::{DisplaySnapshot, ToDisplayPoint}, hover_popover::hide_hover, persistence::EditorDb, @@ -372,6 +372,7 @@ impl ScrollManager { &mut self, scroll_position: gpui::Point, map: &DisplaySnapshot, + scroll_beyond_last_line: ScrollBeyondLastLine, local: bool, autoscroll: bool, workspace_id: Option, @@ -379,7 +380,7 @@ impl ScrollManager { cx: &mut Context, ) -> WasScrolled { let scroll_top = scroll_position.y.max(0.); - let scroll_top = match EditorSettings::get_global(cx).scroll_beyond_last_line { + let scroll_top = match scroll_beyond_last_line { ScrollBeyondLastLine::OnePage => scroll_top, ScrollBeyondLastLine::Off => { if let Some(height_in_lines) = self.visible_line_count { @@ -400,7 +401,6 @@ impl ScrollManager { } } }; - let scroll_top_row = DisplayRow(scroll_top as u32); let scroll_top_buffer_point = map .clip_point( @@ -639,6 +639,20 @@ impl Editor { self.scroll_manager.vertical_scroll_margin as usize } + pub(crate) fn scroll_beyond_last_line(&self, cx: &App) -> ScrollBeyondLastLine { + match self.mode { + EditorMode::Minimap { .. } + | EditorMode::Full { + sizing_behavior: SizingBehavior::Default, + .. + } => EditorSettings::get_global(cx).scroll_beyond_last_line, + + EditorMode::Full { .. } | EditorMode::SingleLine | EditorMode::AutoHeight { .. } => { + ScrollBeyondLastLine::Off + } + } + } + pub fn set_vertical_scroll_margin(&mut self, margin_rows: usize, cx: &mut Context) { self.scroll_manager.vertical_scroll_margin = margin_rows as f64; cx.notify(); @@ -776,10 +790,11 @@ impl Editor { } else { scroll_position }; - + let scroll_beyond_last_line = self.scroll_beyond_last_line(cx); self.scroll_manager.set_scroll_position( adjusted_position, &display_map, + scroll_beyond_last_line, local, autoscroll, workspace_id, diff --git a/crates/editor/src/semantic_tokens.rs b/crates/editor/src/semantic_tokens.rs index e95b20aed5a6655d6ae4ccd2c6658cfcfecc2ea4..8408438f17533098f906c75bcc03983edfb7acf8 100644 --- a/crates/editor/src/semantic_tokens.rs +++ b/crates/editor/src/semantic_tokens.rs @@ -6,7 +6,7 @@ use gpui::{ App, Context, FontStyle, FontWeight, HighlightStyle, StrikethroughStyle, Task, UnderlineStyle, }; use itertools::Itertools; -use language::language_settings::language_settings; +use language::language_settings::LanguageSettings; use project::{ lsp_store::{ BufferSemanticToken, BufferSemanticTokens, RefreshForServer, SemanticTokenStylizer, @@ -155,13 +155,9 @@ impl Editor { .filter_map(|editor_buffer| { let editor_buffer_id = editor_buffer.read(cx).remote_id(); if self.registered_buffers.contains_key(&editor_buffer_id) - && language_settings( - editor_buffer.read(cx).language().map(|l| l.name()), - editor_buffer.read(cx).file(), - cx, - ) - .semantic_tokens - .enabled() + && LanguageSettings::for_buffer(editor_buffer.read(cx), cx) + .semantic_tokens + .enabled() { Some((editor_buffer_id, editor_buffer)) } else { @@ -184,7 +180,7 @@ impl Editor { .buffer(*buffer_id) .is_some_and(|buffer| { let buffer = buffer.read(cx); - language_settings(buffer.language().map(|l| l.name()), buffer.file(), cx) + LanguageSettings::for_buffer(&buffer, cx) .semantic_tokens .enabled() }) @@ -381,7 +377,10 @@ fn convert_token( for rule in matching { empty = false; - let style = rule.style.iter().find_map(|style| theme.get_opt(style)); + let style = rule + .style + .iter() + .find_map(|style| theme.style_for_name(style)); macro_rules! overwrite { ( @@ -1384,7 +1383,7 @@ mod tests { async fn test_theme_override_changes_restyle_semantic_tokens(cx: &mut TestAppContext) { use collections::IndexMap; use gpui::{Hsla, Rgba, UpdateGlobal as _}; - use theme::{HighlightStyleContent, ThemeStyleContent}; + use theme_settings::{HighlightStyleContent, ThemeStyleContent}; init_test(cx, |_| {}); @@ -1549,7 +1548,7 @@ mod tests { async fn test_per_theme_overrides_restyle_semantic_tokens(cx: &mut TestAppContext) { use collections::IndexMap; use gpui::{Hsla, Rgba, UpdateGlobal as _}; - use theme::{HighlightStyleContent, ThemeStyleContent}; + use theme_settings::{HighlightStyleContent, ThemeStyleContent}; use ui::ActiveTheme as _; init_test(cx, |_| {}); diff --git a/crates/editor/src/signature_help.rs b/crates/editor/src/signature_help.rs index 8f246089299f6f35bca14867c298e1f159765c6e..27c26d4691686c16bcbafbf74bba6b5f1156b835 100644 --- a/crates/editor/src/signature_help.rs +++ b/crates/editor/src/signature_help.rs @@ -6,13 +6,14 @@ use gpui::{ TextStyle, Window, combine_highlights, }; use language::BufferSnapshot; + use markdown::{Markdown, MarkdownElement}; use multi_buffer::{Anchor, MultiBufferOffset, ToOffset}; use settings::Settings; use std::ops::Range; use std::time::Duration; use text::Rope; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ ActiveTheme, AnyElement, ButtonCommon, ButtonStyle, Clickable, FluentBuilder, IconButton, IconButtonShape, IconName, IconSize, InteractiveElement, IntoElement, Label, LabelCommon, @@ -236,7 +237,7 @@ impl Editor { .highlight_text(&text, 0..signature.label.len()) .into_iter() .flat_map(|(range, highlight_id)| { - Some((range, highlight_id.style(cx.theme().syntax())?)) + Some((range, *cx.theme().syntax().get(highlight_id)?)) }); signature.highlights = combine_highlights(signature.highlights.clone(), highlights) diff --git a/crates/editor/src/split.rs b/crates/editor/src/split.rs index c9668bc35655dfcda62e71884a782b4edecae093..cdb016ea4b612aaae288acd008f745ef2ecf0f1d 100644 --- a/crates/editor/src/split.rs +++ b/crates/editor/src/split.rs @@ -7,7 +7,8 @@ use buffer_diff::{BufferDiff, BufferDiffSnapshot}; use collections::HashMap; use gpui::{ - Action, AppContext as _, Entity, EventEmitter, Focusable, Font, Subscription, WeakEntity, + Action, AppContext as _, Entity, EventEmitter, Focusable, Font, Pixels, Subscription, + WeakEntity, canvas, }; use itertools::Itertools; use language::{Buffer, Capability, HighlightedText}; @@ -17,7 +18,7 @@ use multi_buffer::{ }; use project::Project; use rope::Point; -use settings::DiffViewStyle; +use settings::{DiffViewStyle, Settings}; use text::{Bias, BufferId, OffsetRangeExt as _, Patch, ToPoint as _}; use ui::{ App, Context, InteractiveElement as _, IntoElement as _, ParentElement as _, Render, @@ -36,7 +37,7 @@ use workspace::{ }; use crate::{ - Autoscroll, Editor, EditorEvent, RenderDiffHunkControlsFn, ToggleSoftWrap, + Autoscroll, Editor, EditorEvent, EditorSettings, RenderDiffHunkControlsFn, ToggleSoftWrap, actions::{DisableBreakpoint, EditLogBreakpoint, EnableBreakpoint, ToggleBreakpoint}, display_map::Companion, }; @@ -377,6 +378,12 @@ pub struct SplittableEditor { workspace: WeakEntity, split_state: Entity, searched_side: Option, + /// The preferred diff style. + diff_view_style: DiffViewStyle, + /// True when the current width is below the minimum threshold for split + /// mode, regardless of the current diff view style setting. + too_narrow_for_split: bool, + last_width: Option, _subscriptions: Vec, } @@ -396,6 +403,10 @@ impl SplittableEditor { self.lhs.as_ref().map(|s| &s.editor) } + pub fn diff_view_style(&self) -> DiffViewStyle { + self.diff_view_style + } + pub fn is_split(&self) -> bool { self.lhs.is_some() } @@ -499,12 +510,15 @@ impl SplittableEditor { }); let split_state = cx.new(|cx| SplitEditorState::new(cx)); Self { + diff_view_style: style, rhs_editor, rhs_multibuffer, lhs: None, workspace: workspace.downgrade(), split_state, searched_side: None, + too_narrow_for_split: false, + last_width: None, _subscriptions: subscriptions, } } @@ -826,10 +840,19 @@ impl SplittableEditor { window: &mut Window, cx: &mut Context, ) { - if self.lhs.is_some() { - self.unsplit(window, cx); - } else { - self.split(window, cx); + match self.diff_view_style { + DiffViewStyle::Unified => { + self.diff_view_style = DiffViewStyle::Split; + if !self.too_narrow_for_split { + self.split(window, cx); + } + } + DiffViewStyle::Split => { + self.diff_view_style = DiffViewStyle::Unified; + if self.is_split() { + self.unsplit(window, cx); + } + } } } @@ -1249,6 +1272,35 @@ impl SplittableEditor { } }); } + + fn width_changed(&mut self, width: Pixels, window: &mut Window, cx: &mut Context) { + self.last_width = Some(width); + + let min_ems = EditorSettings::get_global(cx).minimum_split_diff_width; + + let style = self.rhs_editor.read(cx).create_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()); + let em_advance = window + .text_system() + .em_advance(font_id, font_size) + .unwrap_or(font_size); + let min_width = em_advance * min_ems; + let is_split = self.lhs.is_some(); + + self.too_narrow_for_split = min_ems > 0.0 && width < min_width; + + match self.diff_view_style { + DiffViewStyle::Unified => {} + DiffViewStyle::Split => { + if self.too_narrow_for_split && is_split { + self.unsplit(window, cx); + } else if !self.too_narrow_for_split && !is_split { + self.split(window, cx); + } + } + } + } } #[cfg(test)] @@ -2042,30 +2094,23 @@ impl Focusable for SplittableEditor { } } -// impl Item for SplittableEditor { -// type Event = EditorEvent; - -// fn tab_content_text(&self, detail: usize, cx: &App) -> ui::SharedString { -// self.rhs_editor().tab_content_text(detail, cx) -// } - -// fn as_searchable(&self, _this: &Entity, cx: &App) -> Option> { -// Some(Box::new(self.last_selected_editor().clone())) -// } -// } - impl Render for SplittableEditor { fn render( &mut self, _window: &mut ui::Window, cx: &mut ui::Context, ) -> impl ui::IntoElement { - let inner = if self.lhs.is_some() { + let is_split = self.lhs.is_some(); + let inner = if is_split { let style = self.rhs_editor.read(cx).create_style(cx); SplitEditorView::new(cx.entity(), style, self.split_state.clone()).into_any_element() } else { self.rhs_editor.clone().into_any_element() }; + + let this = cx.entity().downgrade(); + let last_width = self.last_width; + div() .id("splittable-editor") .on_action(cx.listener(Self::toggle_split)) @@ -2079,6 +2124,25 @@ impl Render for SplittableEditor { .capture_action(cx.listener(Self::toggle_soft_wrap)) .size_full() .child(inner) + .child( + canvas( + move |bounds, window, cx| { + let width = bounds.size.width; + if last_width == Some(width) { + return; + } + window.defer(cx, move |window, cx| { + this.update(cx, |this, cx| { + this.width_changed(width, window, cx); + }) + .ok(); + }); + }, + |_, _, _, _| {}, + ) + .absolute() + .size_full(), + ) } } @@ -2118,7 +2182,7 @@ mod tests { cx.update(|cx| { let store = SettingsStore::test(cx); cx.set_global(store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); crate::init(cx); }); let project = Project::test(FakeFs::new(cx.executor()), [], cx).await; diff --git a/crates/eval/.gitignore b/crates/eval/.gitignore deleted file mode 100644 index 89fb02c12207ce4e077c5eccd67f9dcad2fe548a..0000000000000000000000000000000000000000 --- a/crates/eval/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -repos/ -worktrees/ -runs/ diff --git a/crates/eval/Cargo.toml b/crates/eval/Cargo.toml deleted file mode 100644 index a8917181a1253dea614a02bfaa799ace0ee6ba64..0000000000000000000000000000000000000000 --- a/crates/eval/Cargo.toml +++ /dev/null @@ -1,70 +0,0 @@ -[package] -name = "eval" -version = "0.1.0" -publish.workspace = true -edition.workspace = true -license = "GPL-3.0-or-later" -default-run = "eval" - -[lints] -workspace = true - -[[bin]] -name = "eval" -path = "src/eval.rs" - -[[bin]] -name = "explorer" -path = "src/explorer.rs" - -[dependencies] -acp_thread.workspace = true -agent = { workspace = true, features = ["eval"] } -agent-client-protocol.workspace = true -agent_settings.workspace = true -agent_ui.workspace = true -anyhow.workspace = true -async-trait.workspace = true -buffer_diff.workspace = true -chrono.workspace = true -clap.workspace = true -client.workspace = true -collections.workspace = true -debug_adapter_extension.workspace = true -dirs.workspace = true -dotenvy.workspace = true -env_logger.workspace = true -extension.workspace = true -fs.workspace = true -futures.workspace = true -gpui.workspace = true -gpui_platform.workspace = true -gpui_tokio.workspace = true -handlebars.workspace = true -language.workspace = true -language_extension.workspace = true -language_model.workspace = true -language_models.workspace = true -languages = { workspace = true, features = ["load-grammars"] } -markdown.workspace = true -node_runtime.workspace = true -pathdiff.workspace = true -paths.workspace = true -pretty_assertions.workspace = true -project.workspace = true -prompt_store.workspace = true -regex.workspace = true -rand.workspace = true -release_channel.workspace = true -reqwest_client.workspace = true -serde.workspace = true -serde_json.workspace = true -settings.workspace = true -shellexpand.workspace = true -telemetry.workspace = true -terminal_view.workspace = true -toml.workspace = true -unindent.workspace = true -util.workspace = true -uuid.workspace = true -watch.workspace = true diff --git a/crates/eval/README.md b/crates/eval/README.md deleted file mode 100644 index c1543734b00f334063b76d6c8fe22b5aac0f9a84..0000000000000000000000000000000000000000 --- a/crates/eval/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# Eval - -This eval assumes the working directory is the root of the repository. Run it with: - -```sh -cargo run -p eval -``` - -The eval will optionally read a `.env` file in `crates/eval` if you need it to set environment variables, such as API keys. - -## Explorer Tool - -The explorer tool generates a self-contained HTML view from one or more thread -JSON file. It provides a visual interface to explore the agent thread, including -tool calls and results. See [./docs/explorer.md](./docs/explorer.md) for more details. - -### Usage - -```sh -cargo run -p eval --bin explorer -- --input --output -``` - -Example: - -```sh -cargo run -p eval --bin explorer -- --input ./runs/2025-04-23_15-53-30/fastmcp_bugifx/*/last.messages.json --output /tmp/explorer.html -``` diff --git a/crates/eval/build.rs b/crates/eval/build.rs deleted file mode 100644 index 9ab40da0fb0ca880cecc3a87d5a9e95172dcb6ec..0000000000000000000000000000000000000000 --- a/crates/eval/build.rs +++ /dev/null @@ -1,14 +0,0 @@ -fn main() { - let cargo_toml = - std::fs::read_to_string("../zed/Cargo.toml").expect("Failed to read crates/zed/Cargo.toml"); - let version = cargo_toml - .lines() - .find(|line| line.starts_with("version = ")) - .expect("Version not found in crates/zed/Cargo.toml") - .split('=') - .nth(1) - .expect("Invalid version format") - .trim() - .trim_matches('"'); - println!("cargo:rustc-env=ZED_PKG_VERSION={}", version); -} diff --git a/crates/eval/docs/explorer.md b/crates/eval/docs/explorer.md deleted file mode 100644 index 2ca3336a23442dace8c6c73f5eec2295cae3a2d6..0000000000000000000000000000000000000000 --- a/crates/eval/docs/explorer.md +++ /dev/null @@ -1,27 +0,0 @@ -# Explorer - -Threads Explorer is a single self-contained HTML file that gives an overview of -evaluation runs, while allowing for some interactivity. - -When you open a file, it gives you a _thread overview_, which looks like this: - -| Turn | Text | Tool | Result | -| ---- | ------------------------------------ | -------------------------------------------- | --------------------------------------------- | -| 1 | [User]: | | | -| | Fix the bug: kwargs not passed... | | | -| 2 | I'll help you fix that bug. | **list_directory**(path="fastmcp") | `fastmcp/src [...]` | -| | | | | -| 3 | Let's examine the code. | **read_file**(path="fastmcp/main.py", [...]) | `def run_application(app, \*\*kwargs): [...]` | -| 4 | I found the issue. | **edit_file**(path="fastmcp/core.py", [...]) | `Made edit to fastmcp/core.py` | -| 5 | Let's check if there are any errors. | **diagnostics**() | `No errors found` | - -### Implementation details - -`src/explorer.html` contains the template. You can open this template in a -browser as is, and it will show some dummy values. But the main use is to set -the `threadsData` variable with real data, which then will be used instead of -the dummy values. - -`src/explorer.rs` takes one or more JSON files as generated by `cargo run -p -eval`, and outputs an HTML file for rendering these threads. Refer dummy data -in `explorer.html` for a sample format. diff --git a/crates/eval/runner_settings.json b/crates/eval/runner_settings.json deleted file mode 100644 index 44a9eb6fc60d5c7e44d945114ab4b71fbb0208c3..0000000000000000000000000000000000000000 --- a/crates/eval/runner_settings.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "agent": { - "tool_permissions": { - "default": "allow" - } - } -} diff --git a/crates/eval/src/assertions.rs b/crates/eval/src/assertions.rs deleted file mode 100644 index 01fac186d33a8b5b156121acf924d37c90c64679..0000000000000000000000000000000000000000 --- a/crates/eval/src/assertions.rs +++ /dev/null @@ -1,170 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::fmt::Write; -use std::fmt::{self}; - -#[derive(Default, Debug, Serialize, Deserialize, Clone)] -pub struct AssertionsReport { - pub ran: Vec, - pub max: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct RanAssertion { - pub id: String, - pub result: Result, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct RanAssertionResult { - pub analysis: Option, - pub passed: bool, -} - -impl AssertionsReport { - pub fn new(max: Option) -> Self { - AssertionsReport { - ran: Vec::new(), - max, - } - } - - pub fn error(msg: String) -> Self { - let assert = RanAssertion { - id: "no-unhandled-errors".into(), - result: Err(msg), - }; - AssertionsReport { - ran: vec![assert], - max: Some(1), - } - } - - pub fn is_empty(&self) -> bool { - self.ran.is_empty() - } - - pub fn total_count(&self) -> usize { - self.run_count().max(self.max.unwrap_or(0)) - } - - pub fn run_count(&self) -> usize { - self.ran.len() - } - - pub fn passed_count(&self) -> usize { - self.ran - .iter() - .filter(|a| a.result.as_ref().is_ok_and(|result| result.passed)) - .count() - } - - pub fn passed_percentage(&self) -> f32 { - if self.total_count() == 0 { - 0.0 - } else { - (self.passed_count() as f32 / self.total_count() as f32) * 100.0 - } - } -} - -const ROUND_WIDTH: usize = "Round".len(); -const ASSERTIONS_WIDTH: usize = 42; -const RESULTS_WIDTH: usize = 8; - -pub fn print_table_header() { - println!( - "┌─{}─┬─{}─┬─{}─┐", - "─".repeat(ROUND_WIDTH), - "─".repeat(ASSERTIONS_WIDTH), - "─".repeat(RESULTS_WIDTH) - ); - - println!( - "│ {:^ROUND_WIDTH$} │ {:^ASSERTIONS_WIDTH$} │ {:^RESULTS_WIDTH$} │", - "Round", "Assertion", "Result" - ); - - println!( - "├─{}─┼─{}─┼─{}─┤", - "─".repeat(ROUND_WIDTH), - "─".repeat(ASSERTIONS_WIDTH), - "─".repeat(RESULTS_WIDTH) - ) -} - -pub fn display_error_row(f: &mut String, round: usize, error: String) -> fmt::Result { - let last_two_columns = ASSERTIONS_WIDTH + RESULTS_WIDTH; - writeln!( - f, - "│ {:^ROUND_WIDTH$} │ {: fmt::Result { - let result = match &assertion.result { - Ok(result) if result.passed => "\x1b[32m✔︎ Passed\x1b[0m", - Ok(_) => "\x1b[31m✗ Failed\x1b[0m", - Err(_) => "\x1b[31m💥 Judge Error\x1b[0m", - }; - - writeln!( - f, - "│ {:^ROUND_WIDTH$} │ {:RESULTS_WIDTH$} │", - round, - truncate(&assertion.id, ASSERTIONS_WIDTH), - result - ) -} - -pub fn print_table_round_summary<'a>( - round: &str, - reports: impl Iterator, -) { - let mut passed = 0; - let mut total = 0; - for report in reports { - passed += report.passed_count(); - total += report.total_count(); - } - - println!( - "│ {:^ROUND_WIDTH$} │ {:RESULTS_WIDTH$} │", - round, - "total", - format!("{}%", (passed as f32 / total as f32 * 100.0).floor()) - ) -} - -pub fn print_table_footer() { - println!( - "└─{}─┴─{}─┴─{}─┘", - "─".repeat(ROUND_WIDTH), - "─".repeat(ASSERTIONS_WIDTH), - "─".repeat(RESULTS_WIDTH) - ) -} - -pub fn print_table_divider() { - println!( - "├─{}─┼─{}─┼─{}─┤", - "─".repeat(ROUND_WIDTH), - "─".repeat(ASSERTIONS_WIDTH), - "─".repeat(RESULTS_WIDTH) - ) -} - -fn truncate(assertion: &str, max_width: usize) -> String { - let is_verbose = std::env::var("VERBOSE").is_ok_and(|v| !v.is_empty()); - - if assertion.len() <= max_width || is_verbose { - assertion.to_string() - } else { - let mut end_ix = max_width - 1; - while !assertion.is_char_boundary(end_ix) { - end_ix -= 1; - } - format!("{}…", &assertion[..end_ix]) - } -} diff --git a/crates/eval/src/eval.rs b/crates/eval/src/eval.rs deleted file mode 100644 index a621cb0dedb3f7cea512329829f7c99bc8803d41..0000000000000000000000000000000000000000 --- a/crates/eval/src/eval.rs +++ /dev/null @@ -1,742 +0,0 @@ -mod assertions; -mod example; -mod examples; -mod explorer; -mod ids; -mod instance; -mod tool_metrics; - -use assertions::{AssertionsReport, display_error_row}; -use instance::{ExampleInstance, JudgeOutput, RunOutput, run_git}; -use language_extension::LspAccess; -pub(crate) use tool_metrics::*; - -use ::fs::RealFs; -use clap::Parser; -use client::{Client, ProxySettings, UserStore}; -use collections::{HashMap, HashSet}; -use extension::ExtensionHostProxy; -use futures::future; -use gpui::http_client::read_proxy_from_env; -use gpui::{App, AppContext, AsyncApp, Entity, UpdateGlobal}; -use gpui_tokio::Tokio; -use language::LanguageRegistry; -use language_model::{ConfiguredModel, LanguageModel, LanguageModelRegistry, SelectedModel}; -use node_runtime::{NodeBinaryOptions, NodeRuntime}; -use project::project_settings::ProjectSettings; -use prompt_store::PromptBuilder; -use release_channel::{AppCommitSha, AppVersion}; -use reqwest_client::ReqwestClient; -use settings::{Settings, SettingsStore}; -use std::cell::RefCell; -use std::collections::VecDeque; -use std::env; -use std::path::{Path, PathBuf}; -use std::rc::Rc; -use std::str::FromStr; -use std::sync::{Arc, LazyLock}; -use util::ResultExt as _; - -static CARGO_MANIFEST_DIR: LazyLock = - LazyLock::new(|| PathBuf::from(env!("CARGO_MANIFEST_DIR"))); - -#[derive(Parser, Debug)] -#[command(name = "eval", disable_version_flag = true)] -struct Args { - /// Runs all examples and threads that contain these substrings. If unspecified, all examples and threads are run. - #[arg(value_name = "EXAMPLE_SUBSTRING")] - filter: Vec, - /// provider/model to use for agent - #[arg(long, default_value = "anthropic/claude-3-7-sonnet-latest")] - model: String, - /// provider/model to use for judges - #[arg(long, default_value = "anthropic/claude-3-7-sonnet-latest")] - judge_model: String, - #[arg(long, value_delimiter = ',', default_value = "rs,ts,py")] - languages: Vec, - /// How many times to run each example. - #[arg(long, default_value = "8")] - repetitions: usize, - /// Maximum number of examples to run concurrently. - #[arg(long, default_value = "4")] - concurrency: usize, - /// Output current environment variables as JSON to stdout - #[arg(long, hide = true)] - printenv: bool, -} - -fn main() { - let args = Args::parse(); - - // This prevents errors showing up in the logs, because - // project::environment::load_shell_environment() calls - // std::env::current_exe().unwrap() --printenv - if args.printenv { - util::shell_env::print_env(); - return; - } - - dotenvy::from_filename(CARGO_MANIFEST_DIR.join(".env")).ok(); - - env_logger::init(); - - let system_id = ids::get_or_create_id(&ids::eval_system_id_path()).ok(); - let installation_id = ids::get_or_create_id(&ids::eval_installation_id_path()).ok(); - let session_id = uuid::Uuid::new_v4().to_string(); - let run_timestamp = chrono::Local::now().format("%Y-%m-%d_%H-%M-%S"); - let run_id = match env::var("GITHUB_RUN_ID") { - Ok(run_id) => format!("github/{}", run_id), - Err(_) => format!("local/{}", run_timestamp), - }; - - let root_dir = Path::new(std::env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .canonicalize() - .unwrap(); - let eval_crate_dir = root_dir.join("crates").join("eval"); - let repos_dir = eval_crate_dir.join("repos"); - let worktrees_dir = eval_crate_dir.join("worktrees"); - let examples_dir = eval_crate_dir.join("src").join("examples"); - let run_dir = eval_crate_dir - .join("runs") - .join(format!("{}", run_timestamp)); - std::fs::create_dir_all(&run_dir).unwrap(); - std::fs::create_dir_all(&repos_dir).unwrap(); - std::fs::create_dir_all(&worktrees_dir).unwrap(); - std::fs::create_dir_all(&examples_dir).unwrap(); - std::fs::create_dir_all(&paths::config_dir()).unwrap(); - - let zed_commit_sha = commit_sha_for_path(&root_dir); - let zed_branch_name = git_branch_for_path(&root_dir); - let languages: HashSet = args.languages.into_iter().collect(); - - let http_client = Arc::new(ReqwestClient::new()); - let app = gpui_platform::headless().with_http_client(http_client); - let all_threads = examples::all(&examples_dir); - - app.run(move |cx| { - let app_state = init(cx); - - let telemetry = app_state.client.telemetry(); - telemetry.start(system_id, installation_id, session_id, cx); - - let enable_telemetry = env::var("ZED_EVAL_TELEMETRY").is_ok_and(|value| value == "1") - && telemetry.has_checksum_seed(); - if enable_telemetry { - println!("Telemetry enabled"); - telemetry::event!( - "Agent Eval Started", - zed_commit_sha = zed_commit_sha, - zed_branch_name = zed_branch_name, - run_id = run_id, - ); - } - - let mut cumulative_tool_metrics = ToolMetrics::default(); - - let tasks = LanguageModelRegistry::global(cx).update(cx, |registry, cx| { - registry.providers().iter().map(|p| p.authenticate(cx)).collect::>() - }); - - cx.spawn(async move |cx| { - future::join_all(tasks).await; - let judge_model = cx.update(|cx| { - let agent_model = load_model(&args.model, cx).unwrap(); - let judge_model = load_model(&args.judge_model, cx).unwrap(); - LanguageModelRegistry::global(cx).update(cx, |registry, cx| { - registry.set_default_model(Some(agent_model.clone()), cx); - }); - judge_model - }); - - let mut examples = Vec::new(); - - const COLORS: [&str; 12] = [ - "\x1b[31m", // Red - "\x1b[32m", // Green - "\x1b[33m", // Yellow - "\x1b[34m", // Blue - "\x1b[35m", // Magenta - "\x1b[36m", // Cyan - "\x1b[91m", // Bright Red - "\x1b[92m", // Bright Green - "\x1b[93m", // Bright Yellow - "\x1b[94m", // Bright Blue - "\x1b[95m", // Bright Magenta - "\x1b[96m", // Bright Cyan - ]; - - let mut skipped = Vec::new(); - - for thread in all_threads { - let meta = thread.meta(); - if !args.filter.is_empty() && !args.filter.iter().any(|sub| meta.name.contains(sub)) - { - skipped.push(meta.name); - continue; - } - - if let Some(language) = meta.language_server - && !languages.contains(&language.file_extension) { - panic!( - "Eval for {:?} could not be run because no language server was found for extension {:?}", - meta.name, - language.file_extension - ); - } - - // TODO: This creates a worktree per repetition. Ideally these examples should - // either be run sequentially on the same worktree, or reuse worktrees when there - // are more examples to run than the concurrency limit. - for repetition_number in 0..args.repetitions { - let example_instance = ExampleInstance::new( - thread.clone(), - &repos_dir, - &run_dir, - &worktrees_dir, - repetition_number, - ); - - examples.push(example_instance); - } - } - - if !skipped.is_empty() { - println!("Skipped threads: {}", skipped.join(", ")); - } - - if examples.is_empty() { - eprintln!("Filter matched no examples"); - cx.update(|cx| cx.quit()); - return anyhow::Ok(()); - } - - let mut repo_urls = HashSet::default(); - let mut clone_tasks = Vec::new(); - - let max_name_width = examples - .iter() - .map(|e| e.worktree_name().len()) - .max() - .unwrap_or(0); - - for (i, example_instance) in examples.iter_mut().enumerate() { - let color = COLORS[i % COLORS.len()].to_string(); - example_instance.set_log_prefix_style(&color, max_name_width); - - println!( - "{}Logging to: {}", - example_instance.log_prefix, - example_instance.run_directory.display() - ); - - let repo_url = example_instance.repo_url(); - if repo_urls.insert(repo_url.clone()) { - let repo_path = example_instance.repo_path.clone(); - - if !repo_path.join(".git").is_dir() { - println!( - "{:, - pub client: Arc, - pub user_store: Entity, - pub fs: Arc, - pub node_runtime: NodeRuntime, - - // Additional fields not present in `workspace::AppState`. - pub prompt_builder: Arc, -} - -pub fn init(cx: &mut App) -> Arc { - let app_commit_sha = option_env!("ZED_COMMIT_SHA").map(|s| AppCommitSha::new(s.to_owned())); - - let app_version = AppVersion::load( - env!("ZED_PKG_VERSION"), - option_env!("ZED_BUILD_ID"), - app_commit_sha, - ); - - release_channel::init(app_version.clone(), cx); - gpui_tokio::init(cx); - - let settings_store = SettingsStore::new(cx, &settings::default_settings()); - cx.set_global(settings_store); - - // Set User-Agent so we can download language servers from GitHub - let user_agent = format!( - "Zed Agent Eval/{} ({}; {})", - app_version, - std::env::consts::OS, - std::env::consts::ARCH - ); - let proxy_str = ProxySettings::get_global(cx).proxy.to_owned(); - let proxy_url = proxy_str - .as_ref() - .and_then(|input| input.parse().ok()) - .or_else(read_proxy_from_env); - let http = { - let _guard = Tokio::handle(cx).enter(); - - ReqwestClient::proxy_and_user_agent(proxy_url, &user_agent) - .expect("could not start HTTP client") - }; - cx.set_http_client(Arc::new(http)); - - let client = Client::production(cx); - cx.set_http_client(client.http_client()); - - let git_binary_path = None; - let fs = Arc::new(RealFs::new( - git_binary_path, - cx.background_executor().clone(), - )); - - let mut languages = LanguageRegistry::new(cx.background_executor().clone()); - languages.set_language_server_download_dir(paths::languages_dir().clone()); - let languages = Arc::new(languages); - - let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); - - extension::init(cx); - - let (mut tx, rx) = watch::channel(None); - cx.observe_global::(move |cx| { - let settings = &ProjectSettings::get_global(cx).node; - let options = NodeBinaryOptions { - allow_path_lookup: !settings.ignore_system_version, - allow_binary_download: true, - use_paths: settings.path.as_ref().map(|node_path| { - let node_path = PathBuf::from(shellexpand::tilde(node_path).as_ref()); - let npm_path = settings - .npm_path - .as_ref() - .map(|path| PathBuf::from(shellexpand::tilde(&path).as_ref())); - ( - node_path.clone(), - npm_path.unwrap_or_else(|| { - let base_path = PathBuf::new(); - node_path.parent().unwrap_or(&base_path).join("npm") - }), - ) - }), - }; - tx.send(Some(options)).log_err(); - }) - .detach(); - let node_runtime = NodeRuntime::new(client.http_client(), None, rx); - - let extension_host_proxy = ExtensionHostProxy::global(cx); - debug_adapter_extension::init(extension_host_proxy.clone(), cx); - language_extension::init(LspAccess::Noop, extension_host_proxy, languages.clone()); - language_model::init(user_store.clone(), client.clone(), cx); - language_models::init(user_store.clone(), client.clone(), cx); - languages::init(languages.clone(), fs.clone(), node_runtime.clone(), cx); - prompt_store::init(cx); - terminal_view::init(cx); - let stdout_is_a_pty = false; - let prompt_builder = PromptBuilder::load(fs.clone(), stdout_is_a_pty, cx); - agent_ui::init( - fs.clone(), - client.clone(), - prompt_builder.clone(), - languages.clone(), - true, - cx, - ); - - SettingsStore::update_global(cx, |store, cx| { - store.set_user_settings(include_str!("../runner_settings.json"), cx) - }) - .unwrap(); - - Arc::new(AgentAppState { - languages, - client, - user_store, - fs, - node_runtime, - prompt_builder, - }) -} - -pub fn find_model( - model_name: &str, - model_registry: &LanguageModelRegistry, - cx: &App, -) -> anyhow::Result> { - let selected = SelectedModel::from_str(model_name).map_err(|e| anyhow::anyhow!(e))?; - model_registry - .available_models(cx) - .find(|model| model.id() == selected.model && model.provider_id() == selected.provider) - .ok_or_else(|| { - anyhow::anyhow!( - "No language model with ID {}/{} was available. Available models: {}", - selected.provider.0, - selected.model.0, - model_registry - .available_models(cx) - .map(|model| format!("{}/{}", model.provider_id().0, model.id().0)) - .collect::>() - .join(", ") - ) - }) -} - -pub fn load_model(model_name: &str, cx: &mut App) -> anyhow::Result { - let model = { - let model_registry = LanguageModelRegistry::read_global(cx); - find_model(model_name, model_registry, cx)? - }; - - let provider = { - let model_registry = LanguageModelRegistry::read_global(cx); - model_registry - .provider(&model.provider_id()) - .ok_or_else(|| anyhow::anyhow!("Provider not found: {}", model.provider_id()))? - }; - - Ok(ConfiguredModel { - provider: provider.clone(), - model: model.clone(), - }) -} - -pub fn commit_sha_for_path(repo_path: &Path) -> String { - futures::executor::block_on(run_git(repo_path, &["rev-parse", "HEAD"])).unwrap() -} - -pub fn git_branch_for_path(repo_path: &Path) -> String { - match std::env::var("GITHUB_REF_NAME") { - Ok(branch) => branch, - Err(_) => { - futures::executor::block_on(run_git(repo_path, &["rev-parse", "--abbrev-ref", "HEAD"])) - .unwrap_or_else(|_| "unknown".to_string()) - } - } -} - -async fn judge_example( - example: ExampleInstance, - model: Arc, - zed_commit_sha: &str, - zed_branch_name: &str, - run_id: &str, - run_output: &RunOutput, - enable_telemetry: bool, - cx: &AsyncApp, -) -> JudgeOutput { - let judge_output = example.judge(model.clone(), run_output, cx).await; - - if enable_telemetry { - telemetry::event!( - "Agent Example Evaluated", - zed_commit_sha = zed_commit_sha, - zed_branch_name = zed_branch_name, - run_id = run_id, - example_name = example.name.clone(), - example_repetition = example.repetition, - diff_evaluation = judge_output.diff.clone(), - thread_evaluation = judge_output.thread, - tool_metrics = run_output.tool_metrics, - token_usage = run_output.token_usage, - model = model.telemetry_id(), - model_provider = model.provider_id().to_string(), - repository_url = example.repo_url(), - repository_revision = example.revision(), - diagnostic_summary_before = run_output.diagnostic_summary_before, - diagnostic_summary_after = run_output.diagnostic_summary_after, - diagnostics_before = run_output.diagnostics_before, - diagnostics_after = run_output.diagnostics_after, - ); - } - - judge_output -} - -const HEADER_WIDTH: usize = 65; - -fn print_h1(header: &str) { - println!("\n\n{:=^HEADER_WIDTH$}", ""); - println!("{:^HEADER_WIDTH$}", header); - println!("{:=^HEADER_WIDTH$}\n", ""); -} - -fn print_h2(header: &str) { - println!("\n{:-^HEADER_WIDTH$}", ""); - println!("{:^HEADER_WIDTH$}", header); - println!("{:-^HEADER_WIDTH$}\n", ""); -} - -fn print_report( - results_by_example_name: &mut HashMap< - String, - Vec<(ExampleInstance, anyhow::Result<(RunOutput, JudgeOutput)>)>, - >, - cumulative_tool_metrics: &mut ToolMetrics, - run_dir: &Path, -) -> anyhow::Result<()> { - print_h1("EVAL RESULTS"); - - let mut diff_scores = Vec::new(); - let mut thread_scores = Vec::new(); - let mut programmatic_scores = Vec::new(); - let mut error_count = 0; - - for (example_name, results) in results_by_example_name.iter_mut() { - print_h2(example_name); - - results.sort_unstable_by_key(|(example, _)| example.repetition); - let mut example_cumulative_tool_metrics = ToolMetrics::default(); - - let mut table_rows = String::new(); - - for (example, result) in results.iter() { - match result { - Err(err) => { - display_error_row(&mut table_rows, example.repetition, err.to_string())?; - error_count += 1; - programmatic_scores.push(0.0); - diff_scores.push(0.0); - thread_scores.push(0.0); - } - Ok((run_output, judge_output)) => { - cumulative_tool_metrics.merge(&run_output.tool_metrics); - example_cumulative_tool_metrics.merge(&run_output.tool_metrics); - - if run_output.programmatic_assertions.total_count() > 0 { - for assertion in &run_output.programmatic_assertions.ran { - assertions::display_table_row( - &mut table_rows, - example.repetition, - assertion, - )?; - } - - programmatic_scores - .push(run_output.programmatic_assertions.passed_percentage()) - } - - if !judge_output.diff.is_empty() { - diff_scores.push(judge_output.diff.passed_percentage()); - - for assertion in &judge_output.diff.ran { - assertions::display_table_row( - &mut table_rows, - example.repetition, - assertion, - )?; - } - } - - if !judge_output.thread.is_empty() { - thread_scores.push(judge_output.thread.passed_percentage()); - - for assertion in &judge_output.thread.ran { - assertions::display_table_row( - &mut table_rows, - example.repetition, - assertion, - )?; - } - } - } - } - } - - let mut all_asserts = Vec::new(); - - if !table_rows.is_empty() { - assertions::print_table_header(); - print!("{}", table_rows); - - assertions::print_table_divider(); - - for (example, result) in results.iter() { - if let Ok((run_output, judge_output)) = result { - let asserts = [ - run_output.programmatic_assertions.clone(), - judge_output.diff.clone(), - judge_output.thread.clone(), - ]; - all_asserts.extend_from_slice(&asserts); - assertions::print_table_round_summary( - &example.repetition.to_string(), - asserts.iter(), - ) - } else if let Err(err) = result { - let assert = AssertionsReport::error(err.to_string()); - all_asserts.push(assert.clone()); - assertions::print_table_round_summary( - &example.repetition.to_string(), - [assert].iter(), - ) - } - } - - assertions::print_table_divider(); - - assertions::print_table_round_summary("avg", all_asserts.iter()); - - assertions::print_table_footer(); - } - - if !example_cumulative_tool_metrics.is_empty() { - println!("{}", &example_cumulative_tool_metrics); - } - } - - if results_by_example_name.len() > 1 { - print_h1("AGGREGATE"); - - if error_count > 0 { - println!("\n{error_count} examples failed to run!"); - } - - let programmatic_score_count = programmatic_scores.len(); - if programmatic_score_count > 0 { - let average_programmatic_score = (programmatic_scores.into_iter().sum::() - / (programmatic_score_count as f32)) - .floor(); - println!("Average programmatic score: {average_programmatic_score}%"); - } - - let diff_score_count = diff_scores.len(); - if diff_score_count > 0 { - let average_diff_score = - (diff_scores.into_iter().sum::() / (diff_score_count as f32)).floor(); - println!("Average diff score: {average_diff_score}%"); - } - - let thread_score_count = thread_scores.len(); - - if thread_score_count > 0 { - let average_thread_score = - (thread_scores.into_iter().sum::() / (thread_score_count as f32)).floor(); - println!("Average thread score: {average_thread_score}%"); - } - - println!(); - - print_h2("CUMULATIVE TOOL METRICS"); - println!("{}", cumulative_tool_metrics); - } - - let explorer_output_path = run_dir.join("overview.html"); - let mut json_paths: Vec = results_by_example_name - .values() - .flat_map(|results| { - results.iter().map(|(example, _)| { - let absolute_path = run_dir.join(example.run_directory.join("last.messages.json")); - let cwd = std::env::current_dir().expect("Can't get current dir"); - pathdiff::diff_paths(&absolute_path, cwd).unwrap_or_else(|| absolute_path.clone()) - }) - }) - .collect::>(); - json_paths.sort(); - if let Err(err) = explorer::generate_explorer_html(&json_paths, &explorer_output_path) { - eprintln!("Failed to generate explorer HTML: {}", err); - } - - Ok(()) -} diff --git a/crates/eval/src/example.rs b/crates/eval/src/example.rs deleted file mode 100644 index d74df7c7f12696a94f6204fae4586c8cec36517d..0000000000000000000000000000000000000000 --- a/crates/eval/src/example.rs +++ /dev/null @@ -1,561 +0,0 @@ -use std::{ - error::Error, - fmt::{self, Debug}, - sync::{Arc, Mutex}, - time::Duration, - u32, -}; - -use crate::{ - ToolMetrics, - assertions::{AssertionsReport, RanAssertion, RanAssertionResult}, -}; -use acp_thread::UserMessageId; -use agent::{Thread, ThreadEvent, UserMessageContent}; -use agent_client_protocol as acp; -use agent_settings::AgentProfileId; -use anyhow::{Result, anyhow}; -use async_trait::async_trait; -use buffer_diff::DiffHunkStatus; -use collections::HashMap; -use futures::{FutureExt as _, StreamExt, select_biased}; -use gpui::{App, AppContext, AsyncApp, Entity}; -use language_model::Role; -use util::rel_path::RelPath; - -pub const THREAD_EVENT_TIMEOUT: Duration = Duration::from_secs(60 * 2); - -#[async_trait(?Send)] -pub trait Example { - fn meta(&self) -> ExampleMetadata; - async fn conversation(&self, cx: &mut ExampleContext) -> Result<()>; - fn diff_assertions(&self) -> Vec { - Vec::new() - } - fn thread_assertions(&self) -> Vec { - Vec::new() - } -} - -#[derive(Clone, Debug)] -pub struct JudgeAssertion { - pub id: String, - pub description: String, -} - -#[derive(Clone, Debug)] -pub struct ExampleMetadata { - pub name: String, - pub url: String, - pub revision: String, - pub language_server: Option, - pub max_assertions: Option, - pub profile_id: AgentProfileId, - pub existing_thread_json: Option, - pub max_turns: Option, -} - -#[derive(Clone, Debug)] -pub struct LanguageServer { - pub file_extension: String, - pub allow_preexisting_diagnostics: bool, -} - -impl ExampleMetadata { - pub fn repo_name(&self) -> String { - self.url - .split('/') - .next_back() - .unwrap_or("") - .trim_end_matches(".git") - .into() - } -} - -pub struct FailedAssertion(pub String); - -impl fmt::Debug for FailedAssertion { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "Assertion failure: {}", self.0) - } -} - -impl fmt::Display for FailedAssertion { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } -} - -impl Error for FailedAssertion {} - -pub struct ExampleContext { - meta: ExampleMetadata, - log_prefix: String, - agent_thread: Entity, - app: AsyncApp, - pub assertions: AssertionsReport, - pub tool_metrics: Arc>, -} - -impl ExampleContext { - pub fn new( - meta: ExampleMetadata, - log_prefix: String, - agent_thread: Entity, - app: AsyncApp, - ) -> Self { - let assertions = AssertionsReport::new(meta.max_assertions); - - Self { - meta, - log_prefix, - agent_thread, - assertions, - app, - tool_metrics: Arc::new(Mutex::new(ToolMetrics::default())), - } - } - - pub fn assert(&mut self, expected: bool, message: impl ToString) -> Result<()> { - let message = message.to_string(); - self.log_assertion( - if expected { - Ok(()) - } else { - Err(anyhow::Error::from(FailedAssertion(message.clone()))) - }, - message, - ) - } - - pub fn assert_some(&mut self, option: Option, message: impl ToString) -> Result { - let message = message.to_string(); - self.log_assertion( - match option { - Some(value) => Ok(value), - None => Err(anyhow::Error::from(FailedAssertion(message.clone()))), - }, - message, - ) - } - - #[allow(dead_code)] - pub fn assert_eq( - &mut self, - left: T, - right: T, - message: impl ToString, - ) -> Result<()> { - let message = message.to_string(); - self.log_assertion( - if left == right { - Ok(()) - } else { - println!( - "{}{}", - self.log_prefix, - pretty_assertions::Comparison::new(&left, &right) - ); - Err(anyhow::Error::from(FailedAssertion(message.clone()))) - }, - message, - ) - } - - fn log_assertion(&mut self, result: Result, message: String) -> Result { - if let Some(max) = self.meta.max_assertions { - anyhow::ensure!( - self.assertions.run_count() <= max, - "More assertions were run than the stated max_assertions of {max}" - ); - } - - self.assertions.ran.push(RanAssertion { - id: message.clone(), - result: Ok(RanAssertionResult { - analysis: None, - passed: result.is_ok(), - }), - }); - - if result.is_ok() { - println!("{}✅ {}", self.log_prefix, message); - } else { - println!("{}❌ {}", self.log_prefix, message); - } - - result - } - - pub async fn prompt(&mut self, prompt: impl Into) -> Result { - self.prompt_with_max_turns(prompt, u32::MAX).await - } - - pub async fn prompt_with_max_turns( - &mut self, - prompt: impl Into, - max_turns: u32, - ) -> Result { - let content = vec![UserMessageContent::Text(prompt.into())]; - self.run_turns(Some(content), max_turns).await - } - - pub async fn proceed_with_max_turns(&mut self, max_turns: u32) -> Result { - self.run_turns(None, max_turns).await - } - - async fn run_turns( - &mut self, - prompt: Option>, - max_turns: u32, - ) -> Result { - let tool_metrics = self.tool_metrics.clone(); - let log_prefix = self.log_prefix.clone(); - - let mut remaining_turns = max_turns; - - let mut event_stream = self.agent_thread.update(&mut self.app, |thread, cx| { - if let Some(prompt) = prompt { - let id = UserMessageId::new(); - thread.send(id, prompt, cx) - } else { - thread.proceed(cx) - } - })?; - - let task = self.app.background_spawn(async move { - let mut messages = Vec::new(); - let mut tool_uses_by_id = HashMap::default(); - while let Some(event) = event_stream.next().await { - match event? { - ThreadEvent::UserMessage(user_message) => { - messages.push(Message { - role: Role::User, - text: user_message.to_markdown(), - tool_use: Vec::new(), - }); - } - ThreadEvent::AgentThinking(text) | ThreadEvent::AgentText(text) => { - if matches!( - messages.last(), - Some(Message { - role: Role::Assistant, - .. - }) - ) { - messages.last_mut().unwrap().text.push_str(&text); - } else { - messages.push(Message { - role: Role::Assistant, - text, - tool_use: Vec::new(), - }); - } - } - ThreadEvent::ToolCall(tool_call) => { - let meta = tool_call.meta.expect("Missing meta field in tool_call"); - let tool_name = meta - .get(acp_thread::TOOL_NAME_META_KEY) - .expect("Missing tool_name field in meta") - .as_str() - .expect("Unknown tool_name content in meta"); - - tool_uses_by_id.insert( - tool_call.tool_call_id, - ToolUse { - name: tool_name.to_string(), - value: tool_call.raw_input.unwrap_or_default(), - }, - ); - if matches!( - tool_call.status, - acp::ToolCallStatus::Completed | acp::ToolCallStatus::Failed - ) { - panic!("Tool call completed without update"); - } - } - ThreadEvent::ToolCallUpdate(tool_call_update) => { - if let acp_thread::ToolCallUpdate::UpdateFields(update) = tool_call_update { - if let Some(raw_input) = update.fields.raw_input { - if let Some(tool_use) = - tool_uses_by_id.get_mut(&update.tool_call_id) - { - tool_use.value = raw_input; - } - } - - if matches!( - update.fields.status, - Some(acp::ToolCallStatus::Completed | acp::ToolCallStatus::Failed) - ) { - let succeeded = - update.fields.status == Some(acp::ToolCallStatus::Completed); - - let tool_use = tool_uses_by_id - .remove(&update.tool_call_id) - .expect("Unrecognized tool call completed"); - - let log_message = if succeeded { - format!("✔︎ {}", tool_use.name) - } else { - format!("✖︎ {}", tool_use.name) - }; - println!("{log_prefix}{log_message}"); - - tool_metrics - .lock() - .unwrap() - .insert(tool_use.name.clone().into(), succeeded); - - if let Some(message) = messages.last_mut() { - message.tool_use.push(tool_use); - } else { - messages.push(Message { - role: Role::Assistant, - text: "".to_string(), - tool_use: vec![tool_use], - }); - } - - remaining_turns -= 1; - if remaining_turns == 0 { - return Ok(messages); - } - } - } - } - ThreadEvent::ToolCallAuthorization(_) => panic!( - "{}Bug: Tool confirmation should not be required in eval", - log_prefix - ), - ThreadEvent::Plan(plan) => { - println!("{log_prefix} Got plan: {plan:?}"); - } - ThreadEvent::SubagentSpawned(session) => { - println!("{log_prefix} Got subagent spawn: {session:?}"); - } - ThreadEvent::Retry(status) => { - println!("{log_prefix} Got retry: {status:?}"); - } - ThreadEvent::Stop(stop_reason) => match stop_reason { - acp::StopReason::EndTurn => {} - acp::StopReason::MaxTokens => { - return Err(anyhow!("Exceeded maximum tokens")); - } - acp::StopReason::MaxTurnRequests => { - return Err(anyhow!("Exceeded maximum turn requests")); - } - stop_reason => return Err(anyhow!("{stop_reason:?}")), - }, - } - } - Ok(messages) - }); - - select_biased! { - result = task.fuse() => { - Ok(Response::new(result?)) - } - _ = self.app.background_executor().timer(THREAD_EVENT_TIMEOUT).fuse() => { - anyhow::bail!("Agentic loop stalled - waited {THREAD_EVENT_TIMEOUT:?} without any events"); - } - } - } - - pub fn edits(&self) -> HashMap, FileEdits> { - self.agent_thread.read_with(&self.app, |thread, cx| { - let action_log = thread.action_log().read(cx); - HashMap::from_iter( - action_log - .changed_buffers(cx) - .into_iter() - .map(|(buffer, diff)| { - let snapshot = buffer.read(cx).snapshot(); - - let file = snapshot.file().unwrap(); - let base_text = diff.read(cx).base_text(cx).text(); - - let hunks = diff - .read(cx) - .snapshot(cx) - .hunks(&snapshot) - .map(|hunk| FileEditHunk { - base_text: base_text[hunk.diff_base_byte_range.clone()].to_string(), - text: snapshot - .text_for_range(hunk.range.clone()) - .collect::(), - status: hunk.status(), - }) - .collect(); - - (file.path().clone(), FileEdits { hunks }) - }), - ) - }) - } - - pub fn agent_thread(&self) -> Entity { - self.agent_thread.clone() - } -} - -impl AppContext for ExampleContext { - fn new( - &mut self, - build_entity: impl FnOnce(&mut gpui::Context) -> T, - ) -> Entity { - self.app.new(build_entity) - } - - fn reserve_entity(&mut self) -> gpui::Reservation { - self.app.reserve_entity() - } - - fn insert_entity( - &mut self, - reservation: gpui::Reservation, - build_entity: impl FnOnce(&mut gpui::Context) -> T, - ) -> Entity { - self.app.insert_entity(reservation, build_entity) - } - - fn update_entity( - &mut self, - handle: &Entity, - update: impl FnOnce(&mut T, &mut gpui::Context) -> R, - ) -> R - where - T: 'static, - { - self.app.update_entity(handle, update) - } - - fn as_mut<'a, T>(&'a mut self, handle: &Entity) -> gpui::GpuiBorrow<'a, T> - where - T: 'static, - { - self.app.as_mut(handle) - } - - fn read_entity(&self, handle: &Entity, read: impl FnOnce(&T, &App) -> R) -> R - where - T: 'static, - { - self.app.read_entity(handle, read) - } - - fn update_window(&mut self, window: gpui::AnyWindowHandle, f: F) -> Result - where - F: FnOnce(gpui::AnyView, &mut gpui::Window, &mut App) -> T, - { - self.app.update_window(window, f) - } - - fn read_window( - &self, - window: &gpui::WindowHandle, - read: impl FnOnce(Entity, &App) -> R, - ) -> Result - where - T: 'static, - { - self.app.read_window(window, read) - } - - fn background_spawn( - &self, - future: impl std::future::Future + Send + 'static, - ) -> gpui::Task - where - R: Send + 'static, - { - self.app.background_spawn(future) - } - - fn read_global(&self, callback: impl FnOnce(&G, &App) -> R) -> R - where - G: gpui::Global, - { - self.app.read_global(callback) - } -} - -#[derive(Debug)] -pub struct Response { - messages: Vec, -} - -impl Response { - pub fn new(messages: Vec) -> Self { - Self { messages } - } - - pub fn expect_tool_call( - &self, - tool_name: &'static str, - cx: &mut ExampleContext, - ) -> Result<&ToolUse> { - let result = self.find_tool_call(tool_name); - cx.assert_some(result, format!("called `{}`", tool_name)) - } - - pub fn find_tool_call(&self, tool_name: &str) -> Option<&ToolUse> { - self.messages.iter().rev().find_map(|msg| { - msg.tool_use - .iter() - .find(|tool_use| tool_use.name == tool_name) - }) - } - - pub fn tool_calls(&self) -> impl Iterator { - self.messages.iter().flat_map(|msg| &msg.tool_use) - } - - pub fn texts(&self) -> impl Iterator { - self.messages.iter().map(|message| message.text.clone()) - } -} - -#[derive(Debug)] -pub struct Message { - role: Role, - text: String, - tool_use: Vec, -} - -#[derive(Debug)] -pub struct ToolUse { - pub name: String, - value: serde_json::Value, -} - -impl ToolUse { - pub fn parse_input(&self) -> Result - where - Input: for<'de> serde::Deserialize<'de>, - { - serde_json::from_value::(self.value.clone()).map_err(|err| anyhow!(err)) - } -} - -#[derive(Debug, Eq, PartialEq)] -pub struct FileEdits { - pub hunks: Vec, -} - -#[derive(Debug, Eq, PartialEq)] -pub struct FileEditHunk { - pub base_text: String, - pub text: String, - pub status: DiffHunkStatus, -} - -impl FileEdits { - pub fn has_added_line(&self, line: &str) -> bool { - self.hunks.iter().any(|hunk| { - hunk.status == DiffHunkStatus::added_none() - && hunk.base_text.is_empty() - && hunk.text.contains(line) - }) - } -} diff --git a/crates/eval/src/examples/add_arg_to_trait_method.rs b/crates/eval/src/examples/add_arg_to_trait_method.rs deleted file mode 100644 index 2d06e384b362c2bcbb8101cf00f6908a87f9f71b..0000000000000000000000000000000000000000 --- a/crates/eval/src/examples/add_arg_to_trait_method.rs +++ /dev/null @@ -1,115 +0,0 @@ -use agent_settings::AgentProfileId; -use anyhow::Result; -use async_trait::async_trait; -use util::rel_path::RelPath; - -use crate::example::{Example, ExampleContext, ExampleMetadata, JudgeAssertion, LanguageServer}; - -pub struct AddArgToTraitMethod; - -#[async_trait(?Send)] -impl Example for AddArgToTraitMethod { - fn meta(&self) -> ExampleMetadata { - ExampleMetadata { - name: "add_arg_to_trait_method".to_string(), - url: "https://github.com/zed-industries/zed.git".to_string(), - revision: "f69aeb6311dde3c0b8979c293d019d66498d54f2".to_string(), - language_server: Some(LanguageServer { - file_extension: "rs".to_string(), - allow_preexisting_diagnostics: false, - }), - max_assertions: None, - profile_id: AgentProfileId::default(), - existing_thread_json: None, - max_turns: None, - } - } - - async fn conversation(&self, cx: &mut ExampleContext) -> Result<()> { - const FILENAME: &str = "assistant_tool.rs"; - let _ = cx.prompt(format!( - r#" - Add a `window: Option` argument to the `Tool::run` trait method in {FILENAME}, - and update all the implementations of the trait and call sites accordingly. - "# - )).await?; - - // Adds ignored argument to all but `batch_tool` - - let add_ignored_window_paths = &[ - "code_action_tool", - "code_symbols_tool", - "contents_tool", - "copy_path_tool", - "create_directory_tool", - "create_file_tool", - "delete_path_tool", - "diagnostics_tool", - "edit_file_tool", - "fetch_tool", - "grep_tool", - "list_directory_tool", - "move_path_tool", - "now_tool", - "open_tool", - "path_search_tool", - "read_file_tool", - "rename_tool", - "symbol_info_tool", - "terminal_tool", - "web_search_tool", - ]; - - let edits = cx.edits(); - - for tool_name in add_ignored_window_paths { - let path_str = format!("crates/assistant_tools/src/{}.rs", tool_name); - let edits = edits.get(RelPath::unix(&path_str).unwrap()); - - let ignored = edits.is_some_and(|edits| { - edits.has_added_line(" _window: Option,\n") - }); - let uningored = edits.is_some_and(|edits| { - edits.has_added_line(" window: Option,\n") - }); - - cx.assert(ignored || uningored, format!("Argument: {}", tool_name)) - .ok(); - - cx.assert(ignored, format!("`_` prefix: {}", tool_name)) - .ok(); - } - - // Adds unignored argument to `batch_tool` - - let batch_tool_edits = - edits.get(RelPath::unix("crates/assistant_tools/src/batch_tool.rs").unwrap()); - - cx.assert( - batch_tool_edits.is_some_and(|edits| { - edits.has_added_line(" window: Option,\n") - }), - "Argument: batch_tool", - ) - .ok(); - - Ok(()) - } - - fn diff_assertions(&self) -> Vec { - vec![ - JudgeAssertion { - id: "batch tool passes window to each".to_string(), - description: - "batch_tool is modified to pass a clone of the window to each tool it calls." - .to_string(), - }, - JudgeAssertion { - id: "tool tests updated".to_string(), - description: - "tool tests are updated to pass the new `window` argument (`None` is ok)." - .to_string(), - }, - ] - } -} diff --git a/crates/eval/src/examples/code_block_citations.rs b/crates/eval/src/examples/code_block_citations.rs deleted file mode 100644 index 4fe7aa81124ca3fa8f84cd5145e83bd710fdf461..0000000000000000000000000000000000000000 --- a/crates/eval/src/examples/code_block_citations.rs +++ /dev/null @@ -1,218 +0,0 @@ -use agent_settings::AgentProfileId; -use anyhow::Result; -use async_trait::async_trait; -use markdown::PathWithRange; - -use crate::example::{Example, ExampleContext, ExampleMetadata, JudgeAssertion, LanguageServer}; - -pub struct CodeBlockCitations; - -const FENCE: &str = "```"; - -#[async_trait(?Send)] -impl Example for CodeBlockCitations { - fn meta(&self) -> ExampleMetadata { - ExampleMetadata { - name: "code_block_citations".to_string(), - url: "https://github.com/zed-industries/zed.git".to_string(), - revision: "f69aeb6311dde3c0b8979c293d019d66498d54f2".to_string(), - language_server: Some(LanguageServer { - file_extension: "rs".to_string(), - allow_preexisting_diagnostics: false, - }), - max_assertions: None, - profile_id: AgentProfileId::default(), - existing_thread_json: None, - max_turns: None, - } - } - - async fn conversation(&self, cx: &mut ExampleContext) -> Result<()> { - const FILENAME: &str = "assistant_tool.rs"; - - // Verify that the messages all have the correct formatting. - let texts: Vec = cx - .prompt(format!( - r#" - Show me the method bodies of all the methods of the `Tool` trait in {FILENAME}. - - Please show each method in a separate code snippet. - "# - )) - .await? - .texts() - .collect(); - let closing_fence = format!("\n{FENCE}"); - - for text in texts.iter() { - let mut text = text.as_str(); - - while let Some(index) = text.find(FENCE) { - // Advance text past the opening backticks. - text = &text[index + FENCE.len()..]; - - // Find the closing backticks. - let content_len = text.find(&closing_fence); - - // Verify the citation format - e.g. ```path/to/foo.txt#L123-456 - if let Some(citation_len) = text.find('\n') { - let citation = &text[..citation_len]; - - if let Ok(()) = - cx.assert(citation.contains("/"), format!("Slash in {citation:?}",)) - { - let path_range = PathWithRange::new(citation); - let path = cx.agent_thread().update(cx, |thread, cx| { - thread - .project() - .read(cx) - .find_project_path(path_range.path.as_ref(), cx) - }); - - if let Ok(path) = cx.assert_some(path, format!("Valid path: {citation:?}")) - { - let buffer_text = { - let buffer = cx - .agent_thread() - .update(cx, |thread, cx| { - thread - .project() - .update(cx, |project, cx| project.open_buffer(path, cx)) - }) - .await - .ok(); - - let Ok(buffer_text) = cx.assert_some( - buffer.map(|buffer| { - buffer.read_with(cx, |buffer, _| buffer.text()) - }), - "Reading buffer text succeeded", - ) else { - continue; - }; - buffer_text - }; - - if let Some(content_len) = content_len { - // + 1 because there's a newline character after the citation. - let start_index = citation.len() + 1; - let end_index = content_len.saturating_sub(start_index); - - if cx - .assert( - start_index <= end_index, - "Code block had a valid citation", - ) - .is_ok() - { - let content = &text[start_index..end_index]; - - // deindent (trim the start of each line) because sometimes the model - // chooses to deindent its code snippets for the sake of readability, - // which in markdown is not only reasonable but usually desirable. - cx.assert( - deindent(&buffer_text) - .trim() - .contains(deindent(&content).trim()), - "Code block content was found in file", - ) - .ok(); - - if let Some(range) = path_range.range { - let start_line_index = range.start.line.saturating_sub(1); - let line_count = - range.end.line.saturating_sub(start_line_index); - let mut snippet = buffer_text - .lines() - .skip(start_line_index as usize) - .take(line_count as usize) - .collect::>() - .join("\n"); - - if let Some(start_col) = range.start.col { - snippet = snippet[start_col as usize..].to_string(); - } - - if let Some(end_col) = range.end.col { - let last_line = snippet.lines().last().unwrap(); - snippet = snippet[..snippet.len() - last_line.len() - + end_col as usize] - .to_string(); - } - - // deindent (trim the start of each line) because sometimes the model - // chooses to deindent its code snippets for the sake of readability, - // which in markdown is not only reasonable but usually desirable. - cx.assert_eq( - deindent(snippet.as_str()).trim(), - deindent(content).trim(), - format!( - "Code block was at {:?}-{:?}", - range.start, range.end - ), - ) - .ok(); - } - } - } - } - } - } else { - cx.assert( - false, - format!("Opening {FENCE} did not have a newline anywhere after it."), - ) - .ok(); - } - - if let Some(content_len) = content_len { - // Advance past the closing backticks - text = &text[content_len + FENCE.len()..]; - } else { - // There were no closing backticks associated with these opening backticks. - cx.assert( - false, - "Code block opening had matching closing backticks.".to_string(), - ) - .ok(); - - // There are no more code blocks to parse, so we're done. - break; - } - } - } - - Ok(()) - } - - fn thread_assertions(&self) -> Vec { - vec![ - JudgeAssertion { - id: "trait method bodies are shown".to_string(), - description: - "All method bodies of the Tool trait are shown." - .to_string(), - }, - JudgeAssertion { - id: "code blocks used".to_string(), - description: - "All code snippets are rendered inside markdown code blocks (as opposed to any other formatting besides code blocks)." - .to_string(), - }, - JudgeAssertion { - id: "code blocks use backticks".to_string(), - description: - format!("All markdown code blocks use backtick fences ({FENCE}) rather than indentation.") - } - ] - } -} - -fn deindent(as_str: impl AsRef) -> String { - as_str - .as_ref() - .lines() - .map(|line| line.trim_start()) - .collect::>() - .join("\n") -} diff --git a/crates/eval/src/examples/comment_translation.rs b/crates/eval/src/examples/comment_translation.rs deleted file mode 100644 index 421999893a5a39b3d6f61c22d405bf90528758e7..0000000000000000000000000000000000000000 --- a/crates/eval/src/examples/comment_translation.rs +++ /dev/null @@ -1,60 +0,0 @@ -use crate::example::{Example, ExampleContext, ExampleMetadata, JudgeAssertion}; -use agent::{EditFileMode, EditFileToolInput}; -use agent_settings::AgentProfileId; -use anyhow::Result; -use async_trait::async_trait; - -pub struct CommentTranslation; - -#[async_trait(?Send)] -impl Example for CommentTranslation { - fn meta(&self) -> ExampleMetadata { - ExampleMetadata { - name: "comment_translation".to_string(), - url: "https://github.com/servo/font-kit.git".to_string(), - revision: "504d084e29bce4f60614bc702e91af7f7d9e60ad".to_string(), - language_server: None, - max_assertions: Some(1), - profile_id: AgentProfileId::default(), - existing_thread_json: None, - max_turns: None, - } - } - - async fn conversation(&self, cx: &mut ExampleContext) -> Result<()> { - let response = cx.prompt( - r#" - Edit the following files and translate all their comments to italian, in this exact order: - - - font-kit/src/family.rs - - font-kit/src/canvas.rs - - font-kit/src/error.rs - "# - ).await?; - - let mut create_or_overwrite_count = 0; - for tool_call in response.tool_calls() { - if tool_call.name == "edit_file" { - let input = tool_call.parse_input::()?; - if !matches!(input.mode, EditFileMode::Edit) { - create_or_overwrite_count += 1; - } - } - } - - cx.assert_eq(create_or_overwrite_count, 0, "no_creation_or_overwrite")?; - - Ok(()) - } - - fn diff_assertions(&self) -> Vec { - vec![JudgeAssertion { - id: "comments_translated".to_string(), - description: concat!( - "- Only `family.rs`, `canvas.rs` and `error.rs` should have changed.\n", - "- Their doc comments should have been all translated to Italian." - ) - .into(), - }] - } -} diff --git a/crates/eval/src/examples/file_change_notification.rs b/crates/eval/src/examples/file_change_notification.rs deleted file mode 100644 index 10683ec6509cece9d8d26039ff36ff458bdf418a..0000000000000000000000000000000000000000 --- a/crates/eval/src/examples/file_change_notification.rs +++ /dev/null @@ -1,74 +0,0 @@ -use agent_settings::AgentProfileId; -use anyhow::Result; -use async_trait::async_trait; - -use crate::example::{Example, ExampleContext, ExampleMetadata, JudgeAssertion}; - -pub struct FileChangeNotificationExample; - -#[async_trait(?Send)] -impl Example for FileChangeNotificationExample { - fn meta(&self) -> ExampleMetadata { - ExampleMetadata { - name: "file_change_notification".to_string(), - url: "https://github.com/octocat/hello-world".to_string(), - revision: "7fd1a60b01f91b314f59955a4e4d4e80d8edf11d".to_string(), - language_server: None, - max_assertions: None, - profile_id: AgentProfileId::default(), - existing_thread_json: None, - max_turns: Some(3), - } - } - - async fn conversation(&self, cx: &mut ExampleContext) -> Result<()> { - // Track README so that the model gets notified of its changes - let project_path = cx.agent_thread().read_with(cx, |thread, cx| { - thread - .project() - .read(cx) - .find_project_path("README", cx) - .expect("README file should exist in this repo") - }); - - let buffer = { - cx.agent_thread() - .update(cx, |thread, cx| { - thread - .project() - .update(cx, |project, cx| project.open_buffer(project_path, cx)) - }) - .await? - }; - - cx.agent_thread().update(cx, |thread, cx| { - thread.action_log().update(cx, |action_log, cx| { - action_log.buffer_read(buffer.clone(), cx); - }); - }); - - // Start conversation (specific message is not important) - cx.prompt_with_max_turns("Find all files in this repo", 1) - .await?; - - // Edit the README buffer - the model should get a notification on next turn - buffer.update(cx, |buffer, cx| { - buffer.edit([(0..buffer.len(), "Surprise!")], None, cx); - }); - - // Run for some more turns. - // The model shouldn't thank us for letting it know about the file change. - cx.proceed_with_max_turns(3).await?; - - Ok(()) - } - - fn thread_assertions(&self) -> Vec { - vec![JudgeAssertion { - id: "change-file-notification".into(), - description: - "Agent should not acknowledge or mention anything about files that have been changed" - .into(), - }] - } -} diff --git a/crates/eval/src/examples/file_search.rs b/crates/eval/src/examples/file_search.rs deleted file mode 100644 index 7de7a07d19184b473fd2cb5ba29b270431b71a4c..0000000000000000000000000000000000000000 --- a/crates/eval/src/examples/file_search.rs +++ /dev/null @@ -1,55 +0,0 @@ -use agent::FindPathToolInput; -use agent_settings::AgentProfileId; -use anyhow::Result; -use async_trait::async_trait; -use regex::Regex; - -use crate::example::{Example, ExampleContext, ExampleMetadata}; - -pub struct FileSearchExample; - -#[async_trait(?Send)] -impl Example for FileSearchExample { - fn meta(&self) -> ExampleMetadata { - ExampleMetadata { - name: "file_search".to_string(), - url: "https://github.com/zed-industries/zed.git".to_string(), - revision: "03ecb88fe30794873f191ddb728f597935b3101c".to_string(), - language_server: None, - max_assertions: Some(3), - profile_id: AgentProfileId::default(), - existing_thread_json: None, - max_turns: None, - } - } - - async fn conversation(&self, cx: &mut ExampleContext) -> Result<()> { - const FILENAME: &str = "find_replace_file_tool.rs"; - - let prompt = format!( - r#" - Look at the `{FILENAME}`. I want to implement a card for it. The card should implement the `Render` trait. - - The card should show a diff. It should be a beautifully presented diff. The card "box" should look like what we show for - markdown codeblocks (look at `MarkdownElement`). I want to see a red background for lines that were deleted and a green - background for lines that were added. We should have a div per diff line. - "# - ); - - let response = cx.prompt_with_max_turns(prompt, 1).await?; - let tool_use = response.expect_tool_call("find_path", cx)?; - let input = tool_use.parse_input::()?; - - let glob = input.glob; - cx.assert(glob.ends_with(FILENAME), "glob ends with file name")?; - - let without_filename = glob.replace(FILENAME, ""); - let matches = Regex::new("(\\*\\*|zed)/(\\*\\*?/)?") - .unwrap() - .is_match(&without_filename); - - cx.assert(matches, "glob starts with `**` or project")?; - - Ok(()) - } -} diff --git a/crates/eval/src/examples/find_and_replace_diff_card.toml b/crates/eval/src/examples/find_and_replace_diff_card.toml deleted file mode 100644 index 0e1b9c3972d8cd54bbbb6f066befb273cc6e0abc..0000000000000000000000000000000000000000 --- a/crates/eval/src/examples/find_and_replace_diff_card.toml +++ /dev/null @@ -1,43 +0,0 @@ -url = "https://github.com/zed-industries/zed.git" -revision = "38fcadf9481d018543c65f36ac3bafeba190179b" -language_extension = "rs" - -prompt = """ -Look at the `find_replace_file_tool.rs`. I want to implement a card for it. -The card should implement the `Render` trait. - -The card should show a diff. It should be a beautifully presented diff. -The card "box" should look like what we show for markdown codeblocks (look at `MarkdownElement`). -I want to see a red background for lines that were deleted and a green background for lines -that were added. We should have a div per diff line. -""" - -[diff_assertions] - -modify_find_and_replace_tool = """ -The changes must replace the previous output returned by `FindReplaceFileTool` with the new `ToolResult` struct. -The struct should contain an `output` field that is the same as the task we were returning before, -and a new `card` field that contains a view for the card. -""" - -card_implementation = """ -The card should be a view that displays a diff. -Each line in the diff should be colored according to whether it was added, removed or unchanged. -""" - -[thread_assertions] - -path_search = """ -The first tool call should be to path search including "find_replace_file_tool.rs" in the string. -(*Not* grep, for example, or reading the file based on a guess at the path.) -This is because we gave the model a filename and it needs to turn that into a real path. -""" - -read_file_from_path_search = """ -After obtaining the correct path of "zed/crates/assistant_tools/src/find_replace_file_tool.rs", it should read the contents of that path. -""" - -symbol_search = """ -When trying to find information about the Render trait, it should *not* begin with a path search, because it doesn't yet have any information -on what path the Render trait might be in. -""" diff --git a/crates/eval/src/examples/grep_params_escapement.rs b/crates/eval/src/examples/grep_params_escapement.rs deleted file mode 100644 index d4ba25cfcba60c66aa4a3b7fd1d93d778df1d9e8..0000000000000000000000000000000000000000 --- a/crates/eval/src/examples/grep_params_escapement.rs +++ /dev/null @@ -1,59 +0,0 @@ -use agent::GrepToolInput; -use agent_settings::AgentProfileId; -use anyhow::Result; -use async_trait::async_trait; - -use crate::example::{Example, ExampleContext, ExampleMetadata}; - -pub struct GrepParamsEscapementExample; - -/* - -This eval checks that the model doesn't use HTML escapement for characters like `<` and -`>` in tool parameters. - - original +system_prompt change +tool description - claude-opus-4 89% 92% 97%+ - claude-sonnet-4 100% - gpt-5-mini 100% - gemini-2.5-pro 98% - -*/ - -#[async_trait(?Send)] -impl Example for GrepParamsEscapementExample { - fn meta(&self) -> ExampleMetadata { - ExampleMetadata { - name: "grep_params_escapement".to_string(), - url: "https://github.com/octocat/hello-world".to_string(), - revision: "7fd1a60b01f91b314f59955a4e4d4e80d8edf11d".to_string(), - language_server: None, - max_assertions: Some(1), - profile_id: AgentProfileId::default(), - existing_thread_json: None, - max_turns: Some(2), - } - } - - async fn conversation(&self, cx: &mut ExampleContext) -> Result<()> { - let response = cx - .prompt_with_max_turns("Search for files containing the characters `>` or `<`", 2) - .await?; - let grep_input = response - .find_tool_call("grep") - .and_then(|tool_use| tool_use.parse_input::().ok()); - - cx.assert_some(grep_input.as_ref(), "`grep` tool should be called")?; - - cx.assert( - !contains_html_entities(&grep_input.unwrap().regex), - "Tool parameters should not be escaped", - ) - } -} - -fn contains_html_entities(pattern: &str) -> bool { - regex::Regex::new(r"&[a-zA-Z]+;|&#[0-9]+;|&#x[0-9a-fA-F]+;") - .unwrap() - .is_match(pattern) -} diff --git a/crates/eval/src/examples/hallucinated_tool_calls.toml b/crates/eval/src/examples/hallucinated_tool_calls.toml deleted file mode 100644 index f12f01affef576bc8ada0b34efe57709303c1e81..0000000000000000000000000000000000000000 --- a/crates/eval/src/examples/hallucinated_tool_calls.toml +++ /dev/null @@ -1,13 +0,0 @@ -url = "https://github.com/jlowin/fastmcp" -revision = "a2c1e14e5d83af1c32b76280ab368df199c4e860" -language_extension = "py" - -prompt = "Write a LICENSE file just saying 'Apache 2.0' and nothing else" - -profile_name = "ask" - -[thread_assertions] - -no_edit_attempts = """The agent should not claim that it edited or created the file. It should not pretend making any changes.""" - -mention_insufficient_tools = """Agent should mention that it doesn't have relevant tools needed to make the change.""" diff --git a/crates/eval/src/examples/mod.rs b/crates/eval/src/examples/mod.rs deleted file mode 100644 index aec1bce07957fb81c17666b3e64b00a1fa47240f..0000000000000000000000000000000000000000 --- a/crates/eval/src/examples/mod.rs +++ /dev/null @@ -1,173 +0,0 @@ -use agent_settings::AgentProfileId; -use anyhow::Result; -use async_trait::async_trait; -use serde::Deserialize; -use std::collections::BTreeMap; -use std::fs; -use std::{ - path::{Path, PathBuf}, - rc::Rc, -}; -use util::serde::default_true; - -use crate::example::{Example, ExampleContext, ExampleMetadata, JudgeAssertion}; - -mod add_arg_to_trait_method; -mod code_block_citations; -mod comment_translation; -mod file_change_notification; -mod file_search; -mod grep_params_escapement; -mod overwrite_file; -mod planets; - -pub fn all(examples_dir: &Path) -> Vec> { - let mut threads: Vec> = vec![ - Rc::new(file_search::FileSearchExample), - Rc::new(add_arg_to_trait_method::AddArgToTraitMethod), - Rc::new(code_block_citations::CodeBlockCitations), - Rc::new(planets::Planets), - Rc::new(comment_translation::CommentTranslation), - Rc::new(overwrite_file::FileOverwriteExample), - Rc::new(file_change_notification::FileChangeNotificationExample), - Rc::new(grep_params_escapement::GrepParamsEscapementExample), - ]; - - for example_path in list_declarative_examples(examples_dir).unwrap() { - threads.push(Rc::new(DeclarativeExample::load(&example_path).unwrap())); - } - - threads -} - -struct DeclarativeExample { - metadata: ExampleMetadata, - prompt: String, - diff_assertions: Vec, - thread_assertions: Vec, -} - -impl DeclarativeExample { - pub fn load(example_path: &Path) -> Result { - let name = Self::name_from_path(example_path); - let base: ExampleToml = toml::from_str(&fs::read_to_string(&example_path)?)?; - let example_dir = example_path.parent().unwrap(); - - let language_server = if base.require_lsp { - Some(crate::example::LanguageServer { - file_extension: base - .language_extension - .expect("Language extension is required when require_lsp = true"), - allow_preexisting_diagnostics: base.allow_preexisting_diagnostics, - }) - } else { - None - }; - - let profile_id = if let Some(profile_name) = base.profile_name { - AgentProfileId(profile_name.into()) - } else { - AgentProfileId::default() - }; - - let existing_thread_json = if let Some(path) = base.existing_thread_path { - let content = fs::read_to_string(example_dir.join(&path)) - .unwrap_or_else(|_| panic!("Failed to read existing thread file: {}", path)); - Some(content) - } else { - None - }; - - let metadata = ExampleMetadata { - name, - url: base.url, - revision: base.revision, - language_server, - max_assertions: None, - profile_id, - existing_thread_json, - max_turns: base.max_turns, - }; - - Ok(DeclarativeExample { - metadata, - prompt: base.prompt, - thread_assertions: base - .thread_assertions - .into_iter() - .map(|(id, description)| JudgeAssertion { id, description }) - .collect(), - diff_assertions: base - .diff_assertions - .into_iter() - .map(|(id, description)| JudgeAssertion { id, description }) - .collect(), - }) - } - - pub fn name_from_path(path: &Path) -> String { - path.file_stem().unwrap().to_string_lossy().into_owned() - } -} - -#[derive(Clone, Debug, Deserialize)] -pub struct ExampleToml { - pub url: String, - pub revision: String, - pub language_extension: Option, - #[expect( - unused, - reason = "This field was found to be unused with serde library bump; it's left as is due to insufficient context on PO's side, but it *may* be fine to remove" - )] - pub insert_id: Option, - #[serde(default = "default_true")] - pub require_lsp: bool, - #[serde(default)] - pub allow_preexisting_diagnostics: bool, - pub prompt: String, - #[serde(default)] - pub profile_name: Option, - #[serde(default)] - pub diff_assertions: BTreeMap, - #[serde(default)] - pub thread_assertions: BTreeMap, - #[serde(default)] - pub existing_thread_path: Option, - #[serde(default)] - pub max_turns: Option, -} - -#[async_trait(?Send)] -impl Example for DeclarativeExample { - fn meta(&self) -> ExampleMetadata { - self.metadata.clone() - } - - async fn conversation(&self, cx: &mut ExampleContext) -> Result<()> { - let max_turns = self.metadata.max_turns.unwrap_or(1000); - let _ = cx.prompt_with_max_turns(&self.prompt, max_turns).await; - Ok(()) - } - - fn diff_assertions(&self) -> Vec { - self.diff_assertions.clone() - } - - fn thread_assertions(&self) -> Vec { - self.thread_assertions.clone() - } -} - -fn list_declarative_examples(examples_dir: &Path) -> Result> { - let path = std::fs::canonicalize(examples_dir).unwrap(); - let entries = std::fs::read_dir(path).unwrap(); - let mut result_paths = Vec::new(); - for entry in entries { - let entry = entry?; - let path = entry.path(); - if path.extension() == Some("toml".as_ref()) { - result_paths.push(path); - } - } - Ok(result_paths) -} diff --git a/crates/eval/src/examples/no_tools_enabled.toml b/crates/eval/src/examples/no_tools_enabled.toml deleted file mode 100644 index 8f8f66244ae74220ba02d04d85e25e0b55271f6c..0000000000000000000000000000000000000000 --- a/crates/eval/src/examples/no_tools_enabled.toml +++ /dev/null @@ -1,19 +0,0 @@ -url = "https://github.com/zed-industries/zed" -revision = "main" -require_lsp = false -prompt = """ -I need to explore the codebase to understand what files are available in the project. What can you tell me about the structure of the codebase? - -Please find all uses of the 'find_path' function in the src directory. - -Also, can you tell me what the capital of France is? And how does garbage collection work in programming languages? -""" - -profile_name = "minimal" - -[thread_assertions] -no_hallucinated_tool_calls = """The agent should not hallucinate tool calls - for example, by writing markdown code blocks that simulate commands like `find`, `grep`, `ls`, etc. - since no tools are available. However, it is totally fine if the agent describes to the user what should be done, e.g. telling the user \"You can run `find` to...\" etc.""" - -doesnt_hallucinate_file_paths = """The agent should not make up file paths or pretend to know the structure of the project when tools are not available.""" - -correctly_answers_general_questions = """The agent should correctly answer general knowledge questions about the capital of France and garbage collection without asking for more context, demonstrating it can still be helpful with areas it knows about.""" diff --git a/crates/eval/src/examples/overwrite_file.rs b/crates/eval/src/examples/overwrite_file.rs deleted file mode 100644 index a4df1e97a3f4d9c66262f8679d93324e53df9d53..0000000000000000000000000000000000000000 --- a/crates/eval/src/examples/overwrite_file.rs +++ /dev/null @@ -1,51 +0,0 @@ -use agent::{EditFileMode, EditFileToolInput}; -use agent_settings::AgentProfileId; -use anyhow::Result; -use async_trait::async_trait; - -use crate::example::{Example, ExampleContext, ExampleMetadata}; - -pub struct FileOverwriteExample; - -/* -This eval tests a fix for a destructive behavior of the `edit_file` tool. -Previously, it would rewrite existing files too aggressively, which often -resulted in content loss. - -Model | Pass rate -----------------|---------- -Sonnet 3.7 | 100% -Gemini 2.5 Pro | 80% -*/ - -#[async_trait(?Send)] -impl Example for FileOverwriteExample { - fn meta(&self) -> ExampleMetadata { - let thread_json = include_str!("threads/overwrite-file.json"); - - ExampleMetadata { - name: "file_overwrite".to_string(), - url: "https://github.com/zed-industries/zed.git".to_string(), - revision: "023a60806a8cc82e73bd8d88e63b4b07fc7a0040".to_string(), - language_server: None, - max_assertions: Some(1), - profile_id: AgentProfileId::default(), - existing_thread_json: Some(thread_json.to_string()), - max_turns: None, - } - } - - async fn conversation(&self, cx: &mut ExampleContext) -> Result<()> { - let response = cx.proceed_with_max_turns(1).await?; - let tool_use = response.expect_tool_call("edit_file", cx)?; - let input = tool_use.parse_input::()?; - let file_overwritten = match input.mode { - EditFileMode::Edit => false, - EditFileMode::Create | EditFileMode::Overwrite => { - input.path.ends_with("src/language_model_selector.rs") - } - }; - - cx.assert(!file_overwritten, "File should be edited, not overwritten") - } -} diff --git a/crates/eval/src/examples/planets.rs b/crates/eval/src/examples/planets.rs deleted file mode 100644 index 1ef257a55db82abe3dab9ef006176df4b12cec5f..0000000000000000000000000000000000000000 --- a/crates/eval/src/examples/planets.rs +++ /dev/null @@ -1,75 +0,0 @@ -use agent::{AgentTool, OpenTool, TerminalTool}; -use agent_settings::AgentProfileId; -use anyhow::Result; -use async_trait::async_trait; - -use crate::example::{Example, ExampleContext, ExampleMetadata, JudgeAssertion}; - -pub struct Planets; - -#[async_trait(?Send)] -impl Example for Planets { - fn meta(&self) -> ExampleMetadata { - ExampleMetadata { - name: "planets".to_string(), - url: "https://github.com/roc-lang/roc".to_string(), // This commit in this repo is just the Apache2 license, - revision: "59e49c75214f60b4dc4a45092292061c8c26ce27".to_string(), // so effectively a blank project. - language_server: None, - max_assertions: None, - profile_id: AgentProfileId::default(), - existing_thread_json: None, - max_turns: None, - } - } - - async fn conversation(&self, cx: &mut ExampleContext) -> Result<()> { - let response = cx - .prompt( - r#" - Make a plain JavaScript web page which renders an animated 3D solar system. - Let me drag to rotate the camera around. - Do not use npm. - "#, - ) - .await?; - let mut open_tool_uses = 0; - let mut terminal_tool_uses = 0; - - for tool_use in response.tool_calls() { - if tool_use.name == OpenTool::NAME { - open_tool_uses += 1; - } else if tool_use.name == TerminalTool::NAME { - terminal_tool_uses += 1; - } - } - - // The open tool should only be used when requested, which it was not. - cx.assert_eq(open_tool_uses, 0, "`open` tool was not used") - .ok(); - // No reason to use the terminal if not using npm. - cx.assert_eq(terminal_tool_uses, 0, "`terminal` tool was not used") - .ok(); - - Ok(()) - } - - fn diff_assertions(&self) -> Vec { - vec![ - JudgeAssertion { - id: "animated solar system".to_string(), - description: "This page should render a solar system, and it should be animated." - .to_string(), - }, - JudgeAssertion { - id: "drag to rotate camera".to_string(), - description: "The user can drag to rotate the camera around.".to_string(), - }, - JudgeAssertion { - id: "plain JavaScript".to_string(), - description: - "The code base uses plain JavaScript and no npm, along with HTML and CSS." - .to_string(), - }, - ] - } -} diff --git a/crates/eval/src/examples/threads/overwrite-file.json b/crates/eval/src/examples/threads/overwrite-file.json deleted file mode 100644 index 392ccde5b8e064bdb9d4a124f38e7a99ca6561f3..0000000000000000000000000000000000000000 --- a/crates/eval/src/examples/threads/overwrite-file.json +++ /dev/null @@ -1,262 +0,0 @@ -{ - "completion_mode": "normal", - "cumulative_token_usage": { - "cache_creation_input_tokens": 18383, - "cache_read_input_tokens": 97250, - "input_tokens": 45, - "output_tokens": 776 - }, - "detailed_summary_state": "NotGenerated", - "exceeded_window_error": null, - "initial_project_snapshot": { - "timestamp": "2025-05-08T14:31:16.701157512Z", - "unsaved_buffer_paths": [], - "worktree_snapshots": [ - { - "git_state": { - "current_branch": null, - "diff": "diff --git a/crates/language_model_selector/src/language_model_selector.rs b/crates/language_model_selector/src/language_model_selector.rs\nindex 6775bee98a..e25c9e1415 100644\n--- a/crates/language_model_selector/src/language_model_selector.rs\n+++ b/crates/language_model_selector/src/language_model_selector.rs\n@@ -410,7 +410,8 @@ impl ModelMatcher {\n }\n \n pub fn is_match(self: &Self, info: &ModelInfo) -> bool {\n- self.matched_ids.contains(&info.model.id().0)\n+ let q = (info.model.provider_id(), info.model.id());\n+ self.matched_models.contains(&q)\n }\n }\n \n", - "head_sha": "9245656485e58a5d6d717d82209bc8c57cb9c539", - "remote_url": "git@github.com:zed-industries/zed.git" - }, - "worktree_path": "/home/silver/develop/zed" - } - ] - }, - "messages": [ - { - "context": "\n\nThe following items were attached by the user. They are up-to-date and don't need to be re-read.\n\n\n```rs zed/crates/language_model_selector/src/language_model_selector.rs\nconst TRY_ZED_PRO_URL [L28]\ntype OnModelChanged [L30]\ntype GetActiveModel [L31]\npub struct LanguageModelSelector [L33-37]\n picker [L34]\n _authenticate_all_providers_task [L35]\n _subscriptions [L36]\nimpl LanguageModelSelector [L39-231]\n pub fn new [L40-81]\n fn handle_language_model_registry_event [L83-104]\n fn authenticate_all_providers [L110-154]\n fn all_models [L156-204]\n pub fn active_model [L206-208]\n fn get_active_model_index [L210-230]\nimpl EventEmitter for LanguageModelSelector [L233]\nimpl Focusable for LanguageModelSelector [L235-239]\n fn focus_handle [L236-238]\nimpl Render for LanguageModelSelector [L241-245]\n fn render [L242-244]\npub struct LanguageModelSelectorPopoverMenu [L248-258]\n language_model_selector [L253]\n trigger [L254]\n tooltip [L255]\n handle [L256]\n anchor [L257]\nimpl LanguageModelSelectorPopoverMenu [L260-284]\n pub fn new [L265-278]\n pub fn with_handle [L280-283]\nimpl RenderOnce for LanguageModelSelectorPopoverMenu [L286-304]\n fn render [L291-303]\nstruct ModelInfo [L307-310]\n model [L308]\n icon [L309]\npub struct LanguageModelPickerDelegate [L312-319]\n language_model_selector [L313]\n on_model_changed [L314]\n get_active_model [L315]\n all_models [L316]\n filtered_entries [L317]\n selected_index [L318]\nstruct GroupedModels [L321-324]\n recommended [L322]\n other [L323]\nimpl GroupedModels [L326-385]\n pub fn new [L327-342]\n fn entries [L344-370]\n fn model_infos [L372-384]\nenum LanguageModelPickerEntry [L387-390]\n Model [L388]\n Separator [L389]\nstruct ModelMatcher [L392-396]\n models [L393]\n bg_executor [L394]\n candidates [L395]\nimpl ModelMatcher [L398-452]\n fn new [L399-406]\n pub fn fuzzy_search [L408-424]\n pub fn exact_search [L426-438]\n fn make_match_candidates [L440-451]\nimpl PickerDelegate for LanguageModelPickerDelegate [L454-693]\n type ListItem [L455]\n fn match_count [L457-459]\n fn selected_index [L461-463]\n fn set_selected_index [L465-468]\n fn can_select [L470-480]\n fn placeholder_text [L482-484]\n fn update_matches [L486-546]\n fn confirm [L548-560]\n fn dismissed [L562-566]\n fn render_match [L568-636]\n fn render_footer [L638-692]\n\nShowing symbols 1-71 (total symbols: 71)\n\n```\n\n\n", - "creases": [ - { - "end": 140, - "icon_path": "icons/file_icons/rust.svg", - "label": "language_model_selector.rs", - "start": 38 - } - ], - "id": 0, - "role": "user", - "segments": [ - { - "text": "I recently introduced fuzzy search in [@language_model_selector.rs](@file:zed/crates/language_model_selector/src/language_model_selector.rs). Can you write unit-test that check it? You can find a test list of models on images. ", - "type": "text" - } - ], - "tool_results": [], - "tool_uses": [] - }, - { - "context": "", - "creases": [], - "id": 1, - "role": "assistant", - "segments": [ - { - "text": "I'll help you write unit tests for the fuzzy search functionality in the language model selector. I see that you want to test the fuzzy search with the models shown in the images - both Claude models (3, 3.5, 3.7) and GPT models (GPT-3.5, GPT-4, GPT-4o, etc.).\n\nLet's start by examining the code structure to understand how the fuzzy search works.", - "type": "text" - } - ], - "tool_results": [ - { - "content": "zed/crates/language_model_selector/Cargo.toml\nzed/crates/language_model_selector/LICENSE-GPL\nzed/crates/language_model_selector/src\n", - "is_error": false, - "output": null, - "tool_use_id": "toolu_01FvYMtHs763GL1wA5mpTpqn" - } - ], - "tool_uses": [ - { - "id": "toolu_01FvYMtHs763GL1wA5mpTpqn", - "input": { - "path": "zed/crates/language_model_selector" - }, - "name": "list_directory" - } - ] - }, - { - "context": "", - "creases": [], - "id": 2, - "role": "assistant", - "segments": [ - { - "text": "Let's look at the structure of the tests directory or see if there are existing tests:", - "type": "text" - } - ], - "tool_results": [ - { - "content": "zed/crates/language_model_selector/src/language_model_selector.rs\n", - "is_error": false, - "output": null, - "tool_use_id": "toolu_01KSLDsLLrf7tgixB1sge3cm" - } - ], - "tool_uses": [ - { - "id": "toolu_01KSLDsLLrf7tgixB1sge3cm", - "input": { - "path": "zed/crates/language_model_selector/src" - }, - "name": "list_directory" - } - ] - }, - { - "context": "", - "creases": [], - "id": 3, - "role": "assistant", - "segments": [ - { - "text": "Let's check the Cargo.toml to understand the dependencies:", - "type": "text" - } - ], - "tool_results": [ - { - "content": "[package]\nname = \"language_model_selector\"\nversion = \"0.1.0\"\nedition.workspace = true\npublish.workspace = true\nlicense = \"GPL-3.0-or-later\"\n\n[lints]\nworkspace = true\n\n[lib]\npath = \"src/language_model_selector.rs\"\n\n[dependencies]\ncollections.workspace = true\nfeature_flags.workspace = true\nfuzzy.workspace = true\ngpui.workspace = true\nlanguage_model.workspace = true\nlog.workspace = true\npicker.workspace = true\nproto.workspace = true\nui.workspace = true\n\nzed_actions.workspace = true\n", - "is_error": false, - "output": null, - "tool_use_id": "toolu_019Je2MLfJhpJr93g5igoRAH" - } - ], - "tool_uses": [ - { - "id": "toolu_019Je2MLfJhpJr93g5igoRAH", - "input": { - "path": "zed/crates/language_model_selector/Cargo.toml" - }, - "name": "read_file" - } - ] - }, - { - "context": "", - "creases": [], - "id": 4, - "role": "assistant", - "segments": [ - { - "text": "Now let's look at the fuzzy search implementation in detail:", - "type": "text" - } - ], - "tool_results": [ - { - "content": "struct ModelMatcher {\n models: Vec,\n bg_executor: BackgroundExecutor,\n candidates: Vec,\n}\n\nimpl ModelMatcher {\n fn new(models: Vec, bg_executor: BackgroundExecutor) -> ModelMatcher {\n let candidates = Self::make_match_candidates(&models);\n Self {\n models,\n bg_executor,\n candidates,\n }\n }\n\n pub fn fuzzy_search(&self, query: &str) -> Vec {\n let matches = self.bg_executor.block(match_strings(\n &self.candidates,\n &query,\n false,\n 100,\n &Default::default(),\n self.bg_executor.clone(),\n ));\n\n let matched_models: Vec<_> = matches\n .into_iter()\n .map(|m| self.models[m.candidate_id].clone())\n .collect();\n\n matched_models\n }\n\n pub fn exact_search(&self, query: &str) -> Vec {\n self.models\n .iter()\n .filter(|m| {\n m.model\n .name()\n .0\n .to_lowercase()\n .contains(&query.to_lowercase())\n })\n .cloned()\n .collect::>()\n }\n\n fn make_match_candidates(model_infos: &Vec) -> Vec {\n model_infos\n .iter()\n .enumerate()\n .map(|(index, model)| {\n StringMatchCandidate::new(\n index,\n &format!(\"{}/{}\", &model.model.provider_id().0, &model.model.name().0),\n )\n })\n .collect::>()\n }\n}", - "is_error": false, - "output": null, - "tool_use_id": "toolu_01GpKFEn6hYrXGiS5yBF3urk" - } - ], - "tool_uses": [ - { - "id": "toolu_01GpKFEn6hYrXGiS5yBF3urk", - "input": { - "end_line": 452, - "path": "zed/crates/language_model_selector/src/language_model_selector.rs", - "start_line": 392 - }, - "name": "read_file" - } - ] - }, - { - "context": "", - "creases": [], - "id": 5, - "role": "assistant", - "segments": [ - { - "text": "Let's now check if there's an existing test module:", - "type": "text" - } - ], - "tool_results": [ - { - "content": "No matches found", - "is_error": false, - "output": null, - "tool_use_id": "toolu_01RZAvFvpMq16Dwh6d6cnVzy" - } - ], - "tool_uses": [ - { - "id": "toolu_01RZAvFvpMq16Dwh6d6cnVzy", - "input": { - "include_pattern": "zed/crates/language_model_selector/**/*.rs", - "regex": "mod tests" - }, - "name": "grep" - } - ] - }, - { - "context": "", - "creases": [], - "id": 6, - "role": "assistant", - "segments": [ - { - "text": "Now I'll create a test module for the ModelMatcher's fuzzy search functionality. Let's create this test module:", - "type": "text" - } - ], - "tool_results": [], - "tool_uses": [] - } - ], - "model": { - "model": "claude-3-7-sonnet-latest", - "provider": "zed.dev" - }, - "profile": "Write", - "request_token_usage": [ - { - "cache_creation_input_tokens": 9581, - "input_tokens": 4, - "output_tokens": 5 - }, - { - "cache_creation_input_tokens": 204, - "cache_read_input_tokens": 9581, - "input_tokens": 6, - "output_tokens": 5 - }, - { - "cache_creation_input_tokens": 104, - "cache_read_input_tokens": 9785, - "input_tokens": 6, - "output_tokens": 5 - }, - { - "cache_creation_input_tokens": 239, - "cache_read_input_tokens": 9889, - "input_tokens": 6, - "output_tokens": 5 - }, - { - "cache_creation_input_tokens": 569, - "cache_read_input_tokens": 10128, - "input_tokens": 5, - "output_tokens": 5 - }, - { - "cache_creation_input_tokens": 87, - "cache_read_input_tokens": 10697, - "input_tokens": 5, - "output_tokens": 2 - }, - { - "cache_creation_input_tokens": 7355, - "cache_read_input_tokens": 10784, - "input_tokens": 5, - "output_tokens": 3 - } - ], - "summary": "Fuzzy Search Testing Language Model Selector", - "updated_at": "2025-05-08T18:20:34.205405751Z", - "version": "0.2.0" -} diff --git a/crates/eval/src/examples/tree_sitter_drop_emscripten_dep.toml b/crates/eval/src/examples/tree_sitter_drop_emscripten_dep.toml deleted file mode 100644 index a2846e9d15854a86d5309da04c8ae0f1d01ff6c1..0000000000000000000000000000000000000000 --- a/crates/eval/src/examples/tree_sitter_drop_emscripten_dep.toml +++ /dev/null @@ -1,53 +0,0 @@ -url = "https://github.com/tree-sitter/tree-sitter.git" -revision = "635c49909ce4aa7f58a9375374f91b1b434f6f9c" -language_extension = "rs" - -prompt = """ -Change `compile_parser_to_wasm` to use `wasi-sdk` instead of emscripten. -Use `ureq` to download the SDK for the current platform and architecture. -Extract the archive into a sibling of `lib` inside the `tree-sitter` directory in the cache_dir. -Compile the parser to wasm using the `bin/clang` executable (or `bin/clang.exe` on windows) -that's inside of the archive. -Don't re-download the SDK if that executable already exists. - -Use these clang flags: -fPIC -shared -Os -Wl,--export=tree_sitter_{language_name} - -Here are the available wasi-sdk assets: -- wasi-sdk-25.0-x86_64-macos.tar.gz -- wasi-sdk-25.0-arm64-macos.tar.gz -- wasi-sdk-25.0-x86_64-linux.tar.gz -- wasi-sdk-25.0-arm64-linux.tar.gz -- wasi-sdk-25.0-x86_64-linux.tar.gz -- wasi-sdk-25.0-arm64-linux.tar.gz -- wasi-sdk-25.0-x86_64-windows.tar.gz -""" - -[diff_assertions] - -modify_function = """ -The patch modifies the `compile_parser_to_wasm` function, removing logic for running `emscripten`, -and adding logic to download `wasi-sdk`. -""" - -use_listed_assets = """ -The patch implements logic for selecting from the assets listed in the prompt by detecting the -current platform and architecture. -""" - -add_deps = """ -The patch adds a dependency for `ureq` to the Cargo.toml, and adds an import to the top of `loader/lib.rs` -If the patch uses any other dependencies (such as `tar` or `flate2`), it also correctly adds them -to the Cargo.toml and imports them. -""" - -[thread_assertions] - -find_specified_function = """ -The agent finds the specified function `compile_parser_to_wasm` in a logical way. -It does not begin by guessing any paths to files in the codebase, but rather searches for the function by name. -""" - -no_syntax_errors = """ -As it edits the file, the agent never introduces syntax errors. It's ok if there are other compile errors, -but it should not introduce glaring issues like mismatched curly braces. -""" diff --git a/crates/eval/src/explorer.html b/crates/eval/src/explorer.html deleted file mode 100644 index 04c41090d37ef975ce1f4805cde3eaaf433d100a..0000000000000000000000000000000000000000 --- a/crates/eval/src/explorer.html +++ /dev/null @@ -1,949 +0,0 @@ - - - - - - Eval Explorer - - - -

Thread Explorer

-
- - - -
- -
-
-
- -
- Thread 1 of 1: - Default Thread -
- -
- - - - - - - - - - - - -
TurnTextToolResult
- - - - diff --git a/crates/eval/src/explorer.rs b/crates/eval/src/explorer.rs deleted file mode 100644 index 3326070cea4e860210f8ba7e0038fec2f3404c30..0000000000000000000000000000000000000000 --- a/crates/eval/src/explorer.rs +++ /dev/null @@ -1,182 +0,0 @@ -use anyhow::{Context as _, Result}; -use clap::Parser; -use serde_json::{Value, json}; -use std::fs; -use std::path::{Path, PathBuf}; - -#[derive(Parser, Debug)] -#[clap(about = "Generate HTML explorer from JSON thread files")] -struct Args { - /// Paths to JSON files or directories. If a directory is provided, - /// it will be searched for 'last.messages.json' files up to 2 levels deep. - #[clap(long, required = true, num_args = 1..)] - input: Vec, - - /// Path where the output HTML file will be written - #[clap(long)] - output: PathBuf, -} - -/// Recursively finds files with `target_filename` in `dir_path` up to `max_depth`. -#[allow(dead_code)] -fn find_target_files_recursive( - dir_path: &Path, - target_filename: &str, - current_depth: u8, - max_depth: u8, - found_files: &mut Vec, -) -> Result<()> { - if current_depth > max_depth { - return Ok(()); - } - - for entry_result in fs::read_dir(dir_path) - .with_context(|| format!("Failed to read directory: {}", dir_path.display()))? - { - let entry = entry_result.with_context(|| { - format!("Failed to read directory entry in: {}", dir_path.display()) - })?; - let path = entry.path(); - - if path.is_dir() { - find_target_files_recursive( - &path, - target_filename, - current_depth + 1, - max_depth, - found_files, - )?; - } else if path.is_file() - && let Some(filename_osstr) = path.file_name() - && let Some(filename_str) = filename_osstr.to_str() - && filename_str == target_filename - { - found_files.push(path); - } - } - Ok(()) -} - -pub fn generate_explorer_html(input_paths: &[PathBuf], output_path: &PathBuf) -> Result { - if let Some(parent) = output_path.parent() - && !parent.exists() - { - fs::create_dir_all(parent).context(format!( - "Failed to create output directory: {}", - parent.display() - ))?; - } - - let template_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/explorer.html"); - let template_content = fs::read_to_string(&template_path).context(format!( - "Template file not found or couldn't be read: {}", - template_path.display() - ))?; - - if input_paths.is_empty() { - println!( - "No input JSON files found to process. Explorer will be generated with template defaults or empty data." - ); - } - - let threads = input_paths - .iter() - .map(|input_path| { - let file_content = fs::read_to_string(input_path) - .context(format!("Failed to read file: {}", input_path.display()))?; - let mut thread_data: Value = file_content - .parse::() - .context(format!("Failed to parse JSON from file: {}", input_path.display()))?; - - if let Some(obj) = thread_data.as_object_mut() { - obj.insert("filename".to_string(), json!(input_path.display().to_string())); - } else { - eprintln!("Warning: JSON data in {} is not a root object. Wrapping it to include filename.", input_path.display()); - thread_data = json!({ - "original_data": thread_data, - "filename": input_path.display().to_string() - }); - } - Ok(thread_data) - }) - .collect::>>()?; - - let all_threads_data = json!({ "threads": threads }); - let html_content = inject_thread_data(template_content, all_threads_data)?; - fs::write(&output_path, &html_content) - .context(format!("Failed to write output: {}", output_path.display()))?; - - println!( - "Saved data from {} resolved file(s) ({} threads) to {}", - input_paths.len(), - threads.len(), - output_path.display() - ); - Ok(html_content) -} - -fn inject_thread_data(template: String, threads_data: Value) -> Result { - let injection_marker = "let threadsData = window.threadsData || { threads: [dummyThread] };"; - if !template.contains(injection_marker) { - anyhow::bail!( - "Could not find the thread injection point in the template. Expected: '{}'", - injection_marker - ); - } - - let threads_json_string = serde_json::to_string_pretty(&threads_data) - .context("Failed to serialize threads data to JSON")? - .replace("", r"<\/script>"); - - let script_injection = format!("let threadsData = {};", threads_json_string); - let final_html = template.replacen(injection_marker, &script_injection, 1); - - Ok(final_html) -} - -#[cfg(not(any(test, doctest)))] -#[allow(dead_code)] -fn main() -> Result<()> { - let args = Args::parse(); - - const DEFAULT_FILENAME: &str = "last.messages.json"; - const MAX_SEARCH_DEPTH: u8 = 2; - - let mut resolved_input_files: Vec = Vec::new(); - - for input_path_arg in &args.input { - if !input_path_arg.exists() { - eprintln!( - "Warning: Input path {} does not exist. Skipping.", - input_path_arg.display() - ); - continue; - } - - if input_path_arg.is_dir() { - find_target_files_recursive( - input_path_arg, - DEFAULT_FILENAME, - 0, // starting depth - MAX_SEARCH_DEPTH, - &mut resolved_input_files, - ) - .with_context(|| { - format!( - "Error searching for '{}' files in directory: {}", - DEFAULT_FILENAME, - input_path_arg.display() - ) - })?; - } else if input_path_arg.is_file() { - resolved_input_files.push(input_path_arg.clone()); - } - } - - resolved_input_files.sort_unstable(); - resolved_input_files.dedup(); - - println!("No input paths provided/found."); - - generate_explorer_html(&resolved_input_files, &args.output).map(|_| ()) -} diff --git a/crates/eval/src/ids.rs b/crates/eval/src/ids.rs deleted file mode 100644 index 7057344206ba1530db5034fc2ed5d73e52b41382..0000000000000000000000000000000000000000 --- a/crates/eval/src/ids.rs +++ /dev/null @@ -1,29 +0,0 @@ -use anyhow::{Context as _, Result}; -use std::fs; -use std::path::{Path, PathBuf}; -use uuid::Uuid; - -pub fn get_or_create_id(path: &Path) -> Result { - if let Ok(id) = fs::read_to_string(path) { - let trimmed = id.trim(); - if !trimmed.is_empty() { - return Ok(trimmed.to_string()); - } - } - let new_id = Uuid::new_v4().to_string(); - fs::create_dir_all(path.parent().context("invalid id path")?)?; - fs::write(path, &new_id)?; - Ok(new_id) -} - -pub fn eval_system_id_path() -> PathBuf { - dirs::data_local_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join("zed-eval-system-id") -} - -pub fn eval_installation_id_path() -> PathBuf { - dirs::data_local_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join("zed-eval-installation-id") -} diff --git a/crates/eval/src/instance.rs b/crates/eval/src/instance.rs deleted file mode 100644 index 54e6ab0b925191c16885b8b8ed89369039c467f6..0000000000000000000000000000000000000000 --- a/crates/eval/src/instance.rs +++ /dev/null @@ -1,1446 +0,0 @@ -use agent::ContextServerRegistry; -use agent_client_protocol as acp; -use anyhow::{Context as _, Result, anyhow, bail}; -use client::proto::LspWorkProgress; -use futures::channel::mpsc; -use futures::future::Shared; -use futures::{FutureExt as _, StreamExt as _, future}; -use gpui::{App, AppContext as _, AsyncApp, Entity, Task}; -use handlebars::Handlebars; -use language::{Buffer, DiagnosticSeverity, OffsetRangeExt as _}; -use language_model::{ - LanguageModel, LanguageModelCompletionEvent, LanguageModelRegistry, LanguageModelRequest, - LanguageModelRequestMessage, LanguageModelToolResultContent, MessageContent, Role, TokenUsage, -}; -use project::{DiagnosticSummary, Project, ProjectPath, lsp_store::OpenLspBufferHandle}; -use prompt_store::{ProjectContext, WorktreeContext}; -use rand::{distr, prelude::*}; -use serde::{Deserialize, Serialize}; -use std::{ - fmt::Write as _, - fs::{self, File}, - io::Write as _, - path::{Path, PathBuf}, - rc::Rc, - sync::{Arc, Mutex}, - time::Duration, -}; -use unindent::Unindent as _; -use util::{ResultExt as _, command::new_command, markdown::MarkdownCodeBlock}; - -use crate::{ - AgentAppState, ToolMetrics, - assertions::{AssertionsReport, RanAssertion, RanAssertionResult}, - example::{Example, ExampleContext, FailedAssertion, JudgeAssertion}, -}; - -pub const ZED_REPO_URL: &str = "https://github.com/zed-industries/zed.git"; - -#[derive(Clone)] -pub struct ExampleInstance { - pub thread: Rc, - pub name: String, - pub run_directory: PathBuf, - pub log_prefix: String, - /// The repetition number for this example (0-based) - /// When running multiple repetitions of the same example, each instance is assigned a unique repetition number. - /// This affects the worktree path and log prefix to avoid clobbering results between runs. - pub repetition: usize, - pub repo_path: PathBuf, - /// Path to the directory containing the requests and responses for the agentic loop - worktrees_dir: PathBuf, -} - -#[derive(Debug, Serialize, Clone)] -pub struct RunOutput { - pub repository_diff: String, - pub diagnostic_summary_before: DiagnosticSummary, - pub diagnostic_summary_after: DiagnosticSummary, - pub diagnostics_before: Option, - pub diagnostics_after: Option, - pub token_usage: TokenUsage, - pub tool_metrics: ToolMetrics, - pub thread_markdown: String, - pub programmatic_assertions: AssertionsReport, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct JudgeDiffInput { - pub repository_diff: String, - pub assertion: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct JudgeThreadInput { - pub messages: String, - pub assertion: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct JudgeOutput { - pub thread: AssertionsReport, - pub diff: AssertionsReport, -} - -impl ExampleInstance { - pub fn new( - thread: Rc, - repos_dir: &Path, - run_dir: &Path, - worktrees_dir: &Path, - repetition: usize, - ) -> Self { - let name = thread.meta().name; - let run_directory = run_dir.join(&name).join(repetition.to_string()); - - let repo_path = repo_path_for_url(repos_dir, &thread.meta().url); - - Self { - name, - thread, - log_prefix: String::new(), - run_directory, - repetition, - repo_path, - worktrees_dir: worktrees_dir.to_path_buf(), - } - } - - pub fn repo_url(&self) -> String { - self.thread.meta().url - } - - pub fn revision(&self) -> String { - self.thread.meta().revision - } - - pub fn worktree_name(&self) -> String { - format!("{}-{}", self.name, self.repetition) - } - - pub fn set_log_prefix_style(&mut self, color: &str, name_width: usize) { - self.log_prefix = format!( - "{}{: Result<()> { - let meta = self.thread.meta(); - - let revision_exists = run_git( - &self.repo_path, - &["rev-parse", &format!("{}^{{commit}}", &meta.revision)], - ) - .await - .is_ok(); - - if !revision_exists { - println!("{}Fetching revision {}", self.log_prefix, &meta.revision); - run_git( - &self.repo_path, - &["fetch", "--depth", "1", "origin", &meta.revision], - ) - .await?; - } - Ok(()) - } - - /// Set up the example by checking out the specified Git revision - pub async fn setup(&mut self) -> Result<()> { - let worktree_path = self.worktree_path(); - let meta = self.thread.meta(); - if worktree_path.is_dir() { - println!("{}Resetting existing worktree", self.log_prefix); - - // TODO: consider including "-x" to remove ignored files. The downside of this is that - // it will also remove build artifacts, and so prevent incremental reuse there. - run_git(&worktree_path, &["clean", "--force", "-d"]).await?; - run_git(&worktree_path, &["reset", "--hard", "HEAD"]).await?; - run_git(&worktree_path, &["checkout", &meta.revision]).await?; - } else { - println!("{}Creating worktree", self.log_prefix); - - let worktree_path_string = worktree_path.to_string_lossy().into_owned(); - - run_git( - &self.repo_path, - &[ - "worktree", - "add", - "-f", - &worktree_path_string, - &meta.revision, - ], - ) - .await?; - } - - if meta.url == ZED_REPO_URL { - std::fs::write(worktree_path.join(".rules"), std::fs::read(".rules")?)?; - } - - std::fs::create_dir_all(&self.run_directory)?; - - Ok(()) - } - - pub fn worktree_path(&self) -> PathBuf { - self.worktrees_dir - .join(self.worktree_name()) - .join(self.thread.meta().repo_name()) - } - - pub fn run(&self, app_state: Arc, cx: &mut App) -> Task> { - let project = 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, - project::LocalProjectFlags { - init_worktree_trust: false, - ..Default::default() - }, - cx, - ); - - let worktree = project.update(cx, |project, cx| { - project.create_worktree(self.worktree_path(), true, cx) - }); - - let meta = self.thread.meta(); - let this = self.clone(); - - cx.spawn(async move |cx| { - let worktree = worktree.await?; - - // Wait for worktree scan to finish before choosing a file to open. - worktree - .update(cx, |worktree, _cx| { - worktree.as_local().unwrap().scan_complete() - }) - .await; - - struct LanguageServerState { - _lsp_open_handle: OpenLspBufferHandle, - language_file_buffer: Entity, - } - - let mut diagnostics_before = None; - let mut diagnostic_summary_before = DiagnosticSummary::default(); - - let lsp = if let Some(language_server) = &meta.language_server { - // Open a file that matches the language to cause LSP to start. - let language_file = worktree - .read_with(cx, |worktree, _cx| { - worktree - .files(false, 0) - .find_map(|e| { - if e.path.clone().extension() - == Some(&language_server.file_extension) - { - Some(ProjectPath { - worktree_id: worktree.id(), - path: e.path.clone(), - }) - } else { - None - } - }) - .context("Failed to find a file for example language") - })?; - - let open_language_file_buffer_task = project.update(cx, |project, cx| { - project.open_buffer(language_file.clone(), cx) - }); - - let language_file_buffer = open_language_file_buffer_task.await?; - - let lsp_open_handle = project.update(cx, |project, cx| { - project.register_buffer_with_language_servers(&language_file_buffer, cx) - }); - - wait_for_lang_server(&project, &language_file_buffer, this.log_prefix.clone(), cx).await?; - - diagnostic_summary_before = project.read_with(cx, |project, cx| { - project.diagnostic_summary(false, cx) - }); - - diagnostics_before = query_lsp_diagnostics(project.clone(), cx).await?; - if diagnostics_before.is_some() && language_server.allow_preexisting_diagnostics { - anyhow::bail!("Example has pre-existing diagnostics. If you want to run this example regardless, set `allow_preexisting_diagnostics` to `true` in `base.toml`"); - } - - Some(LanguageServerState { - _lsp_open_handle: lsp_open_handle, - language_file_buffer, - }) - } else { - None - }; - - anyhow::ensure!(std::env::var("ZED_EVAL_SETUP_ONLY").is_err(), "Setup only mode"); - - let last_diff_file_path = this.run_directory.join("last.diff"); - - // Write an empty "last.diff" so that it can be opened in Zed for convenient view of the - // history using undo/redo. - std::fs::write(&last_diff_file_path, "")?; - - let thread = cx.update(|cx| { - //todo: Do we want to load rules files here? - let worktrees = project.read(cx).visible_worktrees(cx).map(|worktree| { - let root_name = worktree.read(cx).root_name_str().into(); - let abs_path = worktree.read(cx).abs_path(); - - WorktreeContext { - root_name, - abs_path, - rules_file: None, - } - }).collect::>(); - let project_context = cx.new(|_cx| ProjectContext::new(worktrees, vec![])); - let context_server_registry = cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); - - let thread = if let Some(json) = &meta.existing_thread_json { - let session_id = acp::SessionId::new( - rand::rng() - .sample_iter(&distr::Alphanumeric) - .take(7) - .map(char::from) - .collect::(), - ); - - let db_thread = agent::DbThread::from_json(json.as_bytes()).expect("Can't read serialized thread"); - cx.new(|cx| agent::Thread::from_db(session_id, db_thread, project.clone(), project_context, context_server_registry, agent::Templates::new(), cx)) - } else { - cx.new(|cx| agent::Thread::new(project.clone(), project_context, context_server_registry, agent::Templates::new(), None, cx)) - }; - - thread.update(cx, |thread, cx| { - thread.add_default_tools(Rc::new(EvalThreadEnvironment { - project: project.clone(), - }), cx); - thread.set_profile(meta.profile_id.clone(), cx); - thread.set_model( - LanguageModelInterceptor::new( - LanguageModelRegistry::read_global(cx).default_model().expect("Missing model").model.clone(), - this.run_directory.clone(), - last_diff_file_path.clone(), - this.run_directory.join("last.messages.json"), - this.worktree_path(), - this.repo_url(), - ), - cx, - ); - }); - - thread - }); - - let mut example_cx = ExampleContext::new( - meta.clone(), - this.log_prefix.clone(), - thread.clone(), - cx.clone(), - ); - let result = this.thread.conversation(&mut example_cx).await; - - if let Err(err) = result - && !err.is::() { - return Err(err); - } - - println!("{}Stopped", this.log_prefix); - - println!("{}Getting repository diff", this.log_prefix); - let repository_diff = Self::repository_diff(this.worktree_path(), &this.repo_url()).await?; - - std::fs::write(last_diff_file_path, &repository_diff)?; - - - let mut diagnostics_after = None; - let mut diagnostic_summary_after = Default::default(); - - if let Some(language_server_state) = lsp { - wait_for_lang_server(&project, &language_server_state.language_file_buffer, this.log_prefix.clone(), cx).await?; - - println!("{}Getting diagnostics", this.log_prefix); - diagnostics_after = cx - .update(|cx| { - let project = project.clone(); - cx.spawn(async move |cx| query_lsp_diagnostics(project, cx).await) - }) - .await?; - println!("{}Got diagnostics", this.log_prefix); - - diagnostic_summary_after = project.read_with(cx, |project, cx| { - project.diagnostic_summary(false, cx) - }); - - } - - if let Some(diagnostics_before) = &diagnostics_before { - fs::write(this.run_directory.join("diagnostics_before.txt"), diagnostics_before)?; - } - - if let Some(diagnostics_after) = &diagnostics_after { - fs::write(this.run_directory.join("diagnostics_after.txt"), diagnostics_after)?; - } - - Ok(thread.update(cx, |thread, _cx| { - RunOutput { - repository_diff, - diagnostic_summary_before, - diagnostic_summary_after, - diagnostics_before, - diagnostics_after, - token_usage: thread.latest_request_token_usage().unwrap(), - tool_metrics: example_cx.tool_metrics.lock().unwrap().clone(), - thread_markdown: thread.to_markdown(), - programmatic_assertions: example_cx.assertions, - } - })) - }) - } - - async fn repository_diff(repository_path: PathBuf, repository_url: &str) -> Result { - run_git(&repository_path, &["add", "."]).await?; - let mut diff_args = vec!["diff", "--staged"]; - if repository_url == ZED_REPO_URL { - diff_args.push(":(exclude).rules"); - } - run_git(&repository_path, &diff_args).await - } - - pub async fn judge( - &self, - model: Arc, - run_output: &RunOutput, - cx: &AsyncApp, - ) -> JudgeOutput { - let mut output_file = - File::create(self.run_directory.join("judge.md")).expect("failed to create judge.md"); - - let diff_task = self.judge_diff(model.clone(), run_output, cx); - let thread_task = self.judge_thread(model.clone(), run_output, cx); - - let (diff_result, thread_result) = futures::join!(diff_task, thread_task); - - let (diff_response, diff_output) = diff_result; - let (thread_response, thread_output) = thread_result; - - writeln!( - &mut output_file, - "# Judgment\n\n## Thread\n\n{thread_response}\n\n## Diff\n\n{diff_response}", - ) - .log_err(); - - JudgeOutput { - thread: thread_output, - diff: diff_output, - } - } - - async fn judge_diff( - &self, - model: Arc, - run_output: &RunOutput, - cx: &AsyncApp, - ) -> (String, AssertionsReport) { - let diff_assertions = self.thread.diff_assertions(); - - if diff_assertions.is_empty() { - return ( - "No diff assertions".to_string(), - AssertionsReport::default(), - ); - } - - println!("{}Running diff judge", self.log_prefix); - - let judge_diff_prompt = include_str!("judge_diff_prompt.hbs"); - let judge_diff_prompt_name = "judge_diff_prompt"; - let mut hbs = Handlebars::new(); - hbs.register_template_string(judge_diff_prompt_name, judge_diff_prompt) - .unwrap(); - - let to_prompt = |assertion: String| { - hbs.render( - judge_diff_prompt_name, - &JudgeDiffInput { - repository_diff: run_output.repository_diff.clone(), - assertion, - }, - ) - .unwrap() - }; - - let (responses, report) = self - .judge_assertions(model, diff_assertions, to_prompt, cx) - .await; - - println!( - "{}Judge - Diff score: {}%", - self.log_prefix, - report.passed_percentage() - ); - - (responses, report) - } - - async fn judge_thread( - &self, - model: Arc, - run_output: &RunOutput, - cx: &AsyncApp, - ) -> (String, AssertionsReport) { - let thread_assertions = self.thread.thread_assertions(); - - if thread_assertions.is_empty() { - return ( - "No thread assertions".to_string(), - AssertionsReport::default(), - ); - } - - let judge_thread_prompt = include_str!("judge_thread_prompt.hbs"); - let judge_thread_prompt_name = "judge_thread_prompt"; - let mut hbs = Handlebars::new(); - hbs.register_template_string(judge_thread_prompt_name, judge_thread_prompt) - .unwrap(); - - let complete_messages = &run_output.thread_markdown; - let to_prompt = |assertion: String| { - hbs.render( - judge_thread_prompt_name, - &JudgeThreadInput { - messages: complete_messages.clone(), - assertion, - }, - ) - .unwrap() - }; - - let (responses, report) = self - .judge_assertions(model, thread_assertions, to_prompt, cx) - .await; - - println!( - "{}Judge - Thread score: {}%", - self.log_prefix, - report.passed_percentage() - ); - - (responses, report) - } - - async fn judge_assertions( - &self, - model: Arc, - assertions: Vec, - to_prompt: impl Fn(String) -> String, - cx: &AsyncApp, - ) -> (String, AssertionsReport) { - let assertions = assertions.into_iter().map(|assertion| { - let request = LanguageModelRequest { - thread_id: None, - prompt_id: None, - intent: None, - messages: vec![LanguageModelRequestMessage { - role: Role::User, - content: vec![MessageContent::Text(to_prompt(assertion.description))], - cache: false, - reasoning_details: None, - }], - temperature: None, - tools: Vec::new(), - tool_choice: None, - stop: Vec::new(), - thinking_allowed: true, - thinking_effort: None, - speed: None, - }; - - let model = model.clone(); - let log_prefix = self.log_prefix.clone(); - async move { - let response = send_language_model_request(model, request, cx).await; - - let (response, result) = match response { - Ok(response) => ( - response.clone(), - parse_assertion_result(&response).map_err(|err| err.to_string()), - ), - Err(err) => (err.to_string(), Err(err.to_string())), - }; - - if result.is_ok() { - println!("{}✅ {}", log_prefix, assertion.id); - } else { - println!("{}❌ {}", log_prefix, assertion.id); - } - - ( - response, - RanAssertion { - id: assertion.id, - result, - }, - ) - } - }); - - let mut responses = String::new(); - let mut report = AssertionsReport::default(); - - for (response, assertion) in future::join_all(assertions).await { - writeln!(&mut responses, "# {}", assertion.id).unwrap(); - writeln!(&mut responses, "{}\n\n", response).unwrap(); - report.ran.push(assertion); - } - - (responses, report) - } -} - -struct EvalThreadEnvironment { - project: Entity, -} - -struct EvalTerminalHandle { - terminal: Entity, -} - -impl agent::TerminalHandle for EvalTerminalHandle { - fn id(&self, cx: &AsyncApp) -> Result { - Ok(self.terminal.read_with(cx, |term, _cx| term.id().clone())) - } - - fn wait_for_exit(&self, cx: &AsyncApp) -> Result>> { - Ok(self - .terminal - .read_with(cx, |term, _cx| term.wait_for_exit())) - } - - fn current_output(&self, cx: &AsyncApp) -> Result { - Ok(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(()) - } - - fn was_stopped_by_user(&self, cx: &AsyncApp) -> Result { - Ok(self - .terminal - .read_with(cx, |term, _cx| term.was_stopped_by_user())) - } -} - -impl agent::ThreadEnvironment for EvalThreadEnvironment { - fn create_terminal( - &self, - command: String, - cwd: Option, - output_byte_limit: Option, - cx: &mut AsyncApp, - ) -> Task>> { - let project = self.project.clone(); - cx.spawn(async move |cx| { - let language_registry = - project.read_with(cx, |project, _cx| project.languages().clone()); - let id = acp::TerminalId::new(uuid::Uuid::new_v4().to_string()); - let terminal = - acp_thread::create_terminal_entity(command, &[], vec![], cwd.clone(), &project, cx) - .await?; - let terminal = cx.new(|cx| { - acp_thread::Terminal::new( - id, - "", - cwd, - output_byte_limit.map(|limit| limit as usize), - terminal, - language_registry, - cx, - ) - }); - Ok(Rc::new(EvalTerminalHandle { terminal }) as Rc) - }) - } - - fn create_subagent( - &self, - _label: String, - _cx: &mut App, - ) -> Result> { - unimplemented!() - } -} - -struct LanguageModelInterceptor { - model: Arc, - request_count: Arc>, - previous_diff: Arc>, - example_output_dir: PathBuf, - last_diff_file_path: PathBuf, - messages_json_file_path: PathBuf, - repository_path: PathBuf, - repository_url: String, -} - -impl LanguageModelInterceptor { - fn new( - model: Arc, - example_output_dir: PathBuf, - last_diff_file_path: PathBuf, - messages_json_file_path: PathBuf, - repository_path: PathBuf, - repository_url: String, - ) -> Arc { - Arc::new(Self { - model, - request_count: Arc::new(Mutex::new(0)), - previous_diff: Arc::new(Mutex::new("".to_string())), - example_output_dir, - last_diff_file_path, - messages_json_file_path, - repository_path, - repository_url, - }) - } -} - -impl language_model::LanguageModel for LanguageModelInterceptor { - fn id(&self) -> language_model::LanguageModelId { - self.model.id() - } - - fn name(&self) -> language_model::LanguageModelName { - self.model.name() - } - - fn provider_id(&self) -> language_model::LanguageModelProviderId { - self.model.provider_id() - } - - fn provider_name(&self) -> language_model::LanguageModelProviderName { - self.model.provider_name() - } - - fn telemetry_id(&self) -> String { - self.model.telemetry_id() - } - - fn supports_images(&self) -> bool { - self.model.supports_images() - } - - fn supports_tools(&self) -> bool { - self.model.supports_tools() - } - - fn supports_tool_choice(&self, choice: language_model::LanguageModelToolChoice) -> bool { - self.model.supports_tool_choice(choice) - } - - fn max_token_count(&self) -> u64 { - self.model.max_token_count() - } - - fn count_tokens( - &self, - request: LanguageModelRequest, - cx: &App, - ) -> future::BoxFuture<'static, Result> { - self.model.count_tokens(request, cx) - } - - fn stream_completion( - &self, - request: LanguageModelRequest, - cx: &AsyncApp, - ) -> future::BoxFuture< - 'static, - Result< - futures::stream::BoxStream< - 'static, - Result, - >, - language_model::LanguageModelCompletionError, - >, - > { - let stream = self.model.stream_completion(request.clone(), cx); - let request_count = self.request_count.clone(); - let previous_diff = self.previous_diff.clone(); - let example_output_dir = self.example_output_dir.clone(); - let last_diff_file_path = self.last_diff_file_path.clone(); - let messages_json_file_path = self.messages_json_file_path.clone(); - let repository_path = self.repository_path.clone(); - let repository_url = self.repository_url.clone(); - - Box::pin(async move { - let stream = stream.await?; - - let response_events = Arc::new(Mutex::new(Vec::new())); - let request_clone = request.clone(); - - let wrapped_stream = stream.then(move |event| { - let response_events = response_events.clone(); - let request = request_clone.clone(); - let request_count = request_count.clone(); - let previous_diff = previous_diff.clone(); - let example_output_dir = example_output_dir.clone(); - let last_diff_file_path = last_diff_file_path.clone(); - let messages_json_file_path = messages_json_file_path.clone(); - let repository_path = repository_path.clone(); - let repository_url = repository_url.clone(); - - async move { - let event_result = match &event { - Ok(ev) => Ok(ev.clone()), - Err(err) => Err(err.to_string()), - }; - response_events.lock().unwrap().push(event_result); - - let should_execute = matches!( - &event, - Ok(LanguageModelCompletionEvent::Stop { .. }) | Err(_) - ); - - if should_execute { - let current_request_count = { - let mut count = request_count.lock().unwrap(); - *count += 1; - *count - }; - - let messages_file_path = - example_output_dir.join(format!("{current_request_count}.messages.md")); - let diff_file_path = - example_output_dir.join(format!("{current_request_count}.diff")); - let last_messages_file_path = example_output_dir.join("last.messages.md"); - - let collected_events = response_events.lock().unwrap().clone(); - let request_markdown = RequestMarkdown::new(&request); - let response_events_markdown = - response_events_to_markdown(&collected_events); - let dialog = ThreadDialog::new(&request, &collected_events); - let dialog_json = - serde_json::to_string_pretty(&dialog.to_combined_request()) - .unwrap_or_default(); - - let messages = format!( - "{}\n\n{}", - request_markdown.messages, response_events_markdown - ); - fs::write(&messages_file_path, messages.clone()) - .expect("failed to write messages file"); - fs::write(&last_messages_file_path, messages) - .expect("failed to write last messages file"); - fs::write(&messages_json_file_path, dialog_json) - .expect("failed to write last.messages.json"); - - // Get repository diff - let diff_result = - ExampleInstance::repository_diff(repository_path, &repository_url) - .await; - - match diff_result { - Ok(diff) => { - let prev_diff = previous_diff.lock().unwrap().clone(); - if diff != prev_diff { - fs::write(&diff_file_path, &diff) - .expect("failed to write diff file"); - fs::write(&last_diff_file_path, &diff) - .expect("failed to write last diff file"); - *previous_diff.lock().unwrap() = diff; - } - } - Err(err) => { - let error_message = format!("{err:?}"); - fs::write(&diff_file_path, &error_message) - .expect("failed to write diff error to file"); - fs::write(&last_diff_file_path, &error_message) - .expect("failed to write last diff file"); - } - } - - if current_request_count == 1 { - let tools_file_path = example_output_dir.join("tools.md"); - fs::write(tools_file_path, request_markdown.tools) - .expect("failed to write tools file"); - } - } - - event - } - }); - - Ok(Box::pin(wrapped_stream) - as futures::stream::BoxStream< - 'static, - Result< - LanguageModelCompletionEvent, - language_model::LanguageModelCompletionError, - >, - >) - }) - } -} - -pub fn wait_for_lang_server( - project: &Entity, - buffer: &Entity, - log_prefix: String, - cx: &mut AsyncApp, -) -> Task> { - if std::env::var("ZED_EVAL_SKIP_LS").is_ok() { - return Task::ready(Ok(())); - } - - println!("{}⏵ 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()); - - let has_lang_server = buffer.update(cx, |buffer, cx| { - lsp_store.update(cx, |lsp_store, cx| { - lsp_store - .running_language_servers_for_local_buffer(buffer, cx) - .next() - .is_some() - }) - }); - - if has_lang_server { - project - .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)) - .detach(); - } - - 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( - LspWorkProgress { - message: Some(message), - .. - }, - ), - .. - } = event - { - println!("{}⟲ {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(); - } - project::Event::DiskBasedDiagnosticsFinished { .. } => { - tx.try_send(()).ok(); - } - _ => {} - } - }), - ]; - - cx.spawn(async move |cx| { - let timeout = cx.background_executor().timer(Duration::new(60 * 5, 0)); - let result = futures::select! { - _ = rx.next() => { - println!("{}⚑ Language server idle", log_prefix); - anyhow::Ok(()) - }, - _ = timeout.fuse() => { - anyhow::bail!("LSP wait timed out after 5 minutes"); - } - }; - drop(subscriptions); - result - }) -} - -pub async fn query_lsp_diagnostics( - project: Entity, - cx: &mut AsyncApp, -) -> Result> { - let paths_with_diagnostics = project.update(cx, |project, cx| { - project - .diagnostic_summaries(true, cx) - .filter(|(_, _, summary)| summary.error_count > 0 || summary.warning_count > 0) - .map(|(project_path, _, _)| project_path) - .collect::>() - }); - - if paths_with_diagnostics.is_empty() { - return Ok(None); - } - - let mut output = String::new(); - for project_path in paths_with_diagnostics { - let buffer = project - .update(cx, |project, cx| project.open_buffer(project_path, cx)) - .await?; - let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); - - for (_, group) in snapshot.diagnostic_groups(None) { - let entry = &group.entries[group.primary_ix]; - let range = entry.range.to_point(&snapshot); - let severity = match entry.diagnostic.severity { - DiagnosticSeverity::ERROR => "error", - DiagnosticSeverity::WARNING => "warning", - _ => continue, - }; - - writeln!( - output, - "{} at line {}: {}", - severity, - range.start.row + 1, - entry.diagnostic.message - )?; - } - } - anyhow::Ok(Some(output)) -} - -fn parse_assertion_result(response: &str) -> Result { - let analysis = get_tag("analysis", response)?; - let passed = match get_tag("passed", response)?.to_lowercase().as_str() { - "true" => true, - "false" => false, - value @ _ => bail!("invalid judge `passed` tag: {value}"), - }; - Ok(RanAssertionResult { - analysis: Some(analysis), - passed, - }) -} - -fn get_tag(name: &'static str, response: &str) -> Result { - let start_tag = format!("<{}>", name); - let end_tag = format!("", name); - - let start_ix = response - .find(&start_tag) - .context(format!("{} start tag not found", name))?; - let content_start_ix = start_ix + start_tag.len(); - - let end_ix = content_start_ix - + response[content_start_ix..] - .find(&end_tag) - .context(format!("{} end tag not found", name))?; - - let content = response[content_start_ix..end_ix].trim().unindent(); - - anyhow::Ok(content) -} - -pub fn repo_path_for_url(repos_dir: &Path, repo_url: &str) -> PathBuf { - let repo_name = repo_url - .trim_start_matches("https://") - .replace(|c: char| !c.is_alphanumeric(), "-"); - Path::new(repos_dir).join(repo_name) -} - -pub async fn run_git(repo_path: &Path, args: &[&str]) -> Result { - let output = new_command("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()) -} - -fn push_role(role: &Role, buf: &mut String, assistant_message_number: &mut u32) { - match role { - Role::System => buf.push_str("# ⚙️ SYSTEM\n\n"), - Role::User => buf.push_str("# 👤 USER\n\n"), - Role::Assistant => { - buf.push_str(&format!("# 🤖 ASSISTANT {assistant_message_number}\n\n")); - *assistant_message_number = *assistant_message_number + 1; - } - } -} - -pub async fn send_language_model_request( - model: Arc, - request: LanguageModelRequest, - cx: &AsyncApp, -) -> anyhow::Result { - match model.stream_completion_text(request, cx).await { - Ok(mut stream) => { - let mut full_response = String::new(); - while let Some(chunk_result) = stream.stream.next().await { - match chunk_result { - Ok(chunk_str) => { - full_response.push_str(&chunk_str); - } - Err(err) => { - anyhow::bail!("Error receiving response from language model: {err}"); - } - } - } - Ok(full_response) - } - Err(err) => Err(anyhow!( - "Failed to get response from language model. Error was: {err}" - )), - } -} - -pub struct RequestMarkdown { - pub tools: String, - pub messages: String, -} - -impl RequestMarkdown { - pub fn new(request: &LanguageModelRequest) -> Self { - let mut tools = String::new(); - let mut messages = String::new(); - let mut assistant_message_number: u32 = 1; - - // Print the tools - if !request.tools.is_empty() { - for tool in &request.tools { - write!(&mut tools, "# {}\n\n", tool.name).unwrap(); - write!(&mut tools, "{}\n\n", tool.description).unwrap(); - writeln!( - &mut tools, - "{}", - MarkdownCodeBlock { - tag: "json", - text: &format!("{:#}", tool.input_schema) - } - ) - .unwrap(); - } - } - - // Print the messages - for message in &request.messages { - push_role(&message.role, &mut messages, &mut assistant_message_number); - - for content in &message.content { - match content { - MessageContent::Text(text) => { - messages.push_str(text); - messages.push_str("\n\n"); - } - MessageContent::Image(_) => { - messages.push_str("[IMAGE DATA]\n\n"); - } - MessageContent::Thinking { text, signature } => { - messages.push_str("**Thinking**:\n\n"); - if let Some(sig) = signature { - messages.push_str(&format!("Signature: {}\n\n", sig)); - } - messages.push_str(text); - messages.push_str("\n"); - } - MessageContent::RedactedThinking(items) => { - messages.push_str(&format!( - "**Redacted Thinking**: {} item(s)\n\n", - items.len() - )); - } - MessageContent::ToolUse(tool_use) => { - messages.push_str(&format!( - "**Tool Use**: {} (ID: {})\n", - tool_use.name, tool_use.id - )); - messages.push_str(&format!( - "{}\n", - MarkdownCodeBlock { - tag: "json", - text: &format!("{:#}", tool_use.input) - } - )); - } - MessageContent::ToolResult(tool_result) => { - messages.push_str(&format!( - "**Tool Result**: {} (ID: {})\n\n", - tool_result.tool_name, tool_result.tool_use_id - )); - if tool_result.is_error { - messages.push_str("**ERROR:**\n"); - } - - match &tool_result.content { - LanguageModelToolResultContent::Text(text) => { - writeln!(messages, "{text}\n").ok(); - } - LanguageModelToolResultContent::Image(image) => { - writeln!(messages, "![Image](data:base64,{})\n", image.source).ok(); - } - } - - if let Some(output) = tool_result.output.as_ref() { - writeln!( - messages, - "**Debug Output**:\n\n```json\n{}\n```\n", - serde_json::to_string_pretty(output).unwrap() - ) - .unwrap(); - } - } - } - } - } - - Self { tools, messages } - } -} - -pub fn response_events_to_markdown( - response_events: &[std::result::Result], -) -> String { - let mut response = String::new(); - // Print the response events if any - response.push_str("# Response\n\n"); - let mut text_buffer = String::new(); - let mut thinking_buffer = String::new(); - - let flush_buffers = - |output: &mut String, text_buffer: &mut String, thinking_buffer: &mut String| { - if !text_buffer.is_empty() { - output.push_str(&format!("**Text**:\n{}\n\n", text_buffer)); - text_buffer.clear(); - } - if !thinking_buffer.is_empty() { - output.push_str(&format!("**Thinking**:\n{}\n\n", thinking_buffer)); - thinking_buffer.clear(); - } - }; - - for event in response_events { - match event { - Ok(LanguageModelCompletionEvent::Text(text)) => { - text_buffer.push_str(text); - } - Ok(LanguageModelCompletionEvent::Thinking { text, .. }) => { - thinking_buffer.push_str(text); - } - Ok(LanguageModelCompletionEvent::RedactedThinking { .. }) => {} - Ok(LanguageModelCompletionEvent::Stop(reason)) => { - flush_buffers(&mut response, &mut text_buffer, &mut thinking_buffer); - response.push_str(&format!("**Stop**: {:?}\n\n", reason)); - } - Ok(LanguageModelCompletionEvent::ToolUse(tool_use)) => { - flush_buffers(&mut response, &mut text_buffer, &mut thinking_buffer); - response.push_str(&format!( - "**Tool Use**: {} (ID: {})\n", - tool_use.name, tool_use.id - )); - response.push_str(&format!( - "{}\n", - MarkdownCodeBlock { - tag: "json", - text: &format!("{:#}", tool_use.input) - } - )); - } - Ok( - LanguageModelCompletionEvent::UsageUpdate(_) - | LanguageModelCompletionEvent::StartMessage { .. } - | LanguageModelCompletionEvent::Queued { .. } - | LanguageModelCompletionEvent::Started - | LanguageModelCompletionEvent::ReasoningDetails(_), - ) => {} - Ok(LanguageModelCompletionEvent::ToolUseJsonParseError { - json_parse_error, .. - }) => { - flush_buffers(&mut response, &mut text_buffer, &mut thinking_buffer); - response.push_str(&format!( - "**Error**: parse error in tool use JSON: {}\n\n", - json_parse_error - )); - } - Err(error) => { - flush_buffers(&mut response, &mut text_buffer, &mut thinking_buffer); - response.push_str(&format!("**Error**: {}\n\n", error)); - } - } - } - - flush_buffers(&mut response, &mut text_buffer, &mut thinking_buffer); - - response -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -pub struct ThreadDialog { - pub request: LanguageModelRequest, - pub response_events: Vec>, -} - -impl ThreadDialog { - pub fn new( - request: &LanguageModelRequest, - response_events: &[std::result::Result], - ) -> Self { - Self { - request: request.clone(), - response_events: response_events.to_vec(), - } - } - - /// Represents all request and response messages in a unified format. - /// - /// Specifically, it appends the assistant's response (derived from response events) - /// as a new message to existing messages in the request. - pub fn to_combined_request(&self) -> LanguageModelRequest { - let mut request = self.request.clone(); - if let Some(assistant_message) = self.response_events_to_message() { - request.messages.push(assistant_message); - } - request - } - fn response_events_to_message(&self) -> Option { - let response_events = &self.response_events; - let mut content: Vec = Vec::new(); - let mut current_text = String::new(); - - let flush_text = |text: &mut String, content: &mut Vec| { - if !text.is_empty() { - content.push(MessageContent::Text(std::mem::take(text))); - } - }; - - for event in response_events { - match event { - Ok(LanguageModelCompletionEvent::Text(text)) => { - current_text.push_str(text); - } - - Ok(LanguageModelCompletionEvent::ToolUse(tool_use)) => { - flush_text(&mut current_text, &mut content); - if tool_use.is_input_complete { - content.push(MessageContent::ToolUse(tool_use.clone())); - } - } - Ok(LanguageModelCompletionEvent::Thinking { text, signature }) => { - flush_text(&mut current_text, &mut content); - content.push(MessageContent::Thinking { - text: text.clone(), - signature: signature.clone(), - }); - } - - // Skip these - Ok(LanguageModelCompletionEvent::UsageUpdate(_)) - | Ok(LanguageModelCompletionEvent::RedactedThinking { .. }) - | Ok(LanguageModelCompletionEvent::StartMessage { .. }) - | Ok(LanguageModelCompletionEvent::ReasoningDetails(_)) - | Ok(LanguageModelCompletionEvent::Stop(_)) - | Ok(LanguageModelCompletionEvent::Queued { .. }) - | Ok(LanguageModelCompletionEvent::Started) => {} - - Ok(LanguageModelCompletionEvent::ToolUseJsonParseError { - json_parse_error, - .. - }) => { - flush_text(&mut current_text, &mut content); - content.push(MessageContent::Text(format!( - "ERROR: parse error in tool use JSON: {}", - json_parse_error - ))); - } - - Err(error) => { - flush_text(&mut current_text, &mut content); - content.push(MessageContent::Text(format!("ERROR: {}", error))); - } - } - } - - flush_text(&mut current_text, &mut content); - - if !content.is_empty() { - Some(LanguageModelRequestMessage { - role: Role::Assistant, - content, - cache: false, - reasoning_details: None, - }) - } else { - None - } - } -} - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn test_parse_judge_output() { - let response = r#" - The model did a good job but there were still compilations errors. - true - "# - .unindent(); - - let output = parse_assertion_result(&response).unwrap(); - assert_eq!( - output.analysis, - Some("The model did a good job but there were still compilations errors.".into()) - ); - assert!(output.passed); - - let response = r#" - Text around ignored - - - Failed to compile: - - Error 1 - - Error 2 - - - false - "# - .unindent(); - - let output = parse_assertion_result(&response).unwrap(); - assert_eq!( - output.analysis, - Some("Failed to compile:\n- Error 1\n- Error 2".into()) - ); - assert!(!output.passed); - } -} diff --git a/crates/eval/src/judge_diff_prompt.hbs b/crates/eval/src/judge_diff_prompt.hbs deleted file mode 100644 index 24ef9ac97e389ab5a3059eead27727343786cb1b..0000000000000000000000000000000000000000 --- a/crates/eval/src/judge_diff_prompt.hbs +++ /dev/null @@ -1,25 +0,0 @@ -You are an expert software developer. Your task is to evaluate a diff produced by an AI agent -in response to a prompt. Here is the prompt and the diff: - - -{{{prompt}}} - - - -{{{repository_diff}}} - - -Evaluate whether or not the diff passes the following assertion: - - -{{assertion}} - - -Analyze the diff hunk by hunk, and structure your answer in the following XML format: - -``` -{YOUR ANALYSIS HERE} -{PASSED_ASSERTION} -``` - -Where `PASSED_ASSERTION` is either `true` or `false`. diff --git a/crates/eval/src/judge_thread_prompt.hbs b/crates/eval/src/judge_thread_prompt.hbs deleted file mode 100644 index e80bafcce1f46ddddb236e572b27f51960a5a223..0000000000000000000000000000000000000000 --- a/crates/eval/src/judge_thread_prompt.hbs +++ /dev/null @@ -1,21 +0,0 @@ -You are an expert software developer. -Your task is to evaluate an AI agent's messages and tool calls in this conversation: - - -{{{messages}}} - - -Evaluate whether or not the sequence of messages passes the following assertion: - - -{{{assertion}}} - - -Analyze the messages one by one, and structure your answer in the following XML format: - -``` -{YOUR ANALYSIS HERE} -{PASSED_ASSERTION} -``` - -Where `PASSED_ASSERTION` is either `true` or `false`. diff --git a/crates/eval/src/tool_metrics.rs b/crates/eval/src/tool_metrics.rs deleted file mode 100644 index 63d8a4f2bc4d1be477a81e92aa2a68683f9d6434..0000000000000000000000000000000000000000 --- a/crates/eval/src/tool_metrics.rs +++ /dev/null @@ -1,106 +0,0 @@ -use collections::HashMap; -use serde::{Deserialize, Serialize}; -use std::{fmt::Display, sync::Arc}; - -#[derive(Debug, Default, Clone, Serialize, Deserialize)] -pub struct ToolMetrics { - pub use_counts: HashMap, u32>, - pub failure_counts: HashMap, u32>, -} - -impl ToolMetrics { - pub fn insert(&mut self, tool_name: Arc, succeeded: bool) { - *self.use_counts.entry(tool_name.clone()).or_insert(0) += 1; - if !succeeded { - *self.failure_counts.entry(tool_name).or_insert(0) += 1; - } - } - - pub fn merge(&mut self, other: &ToolMetrics) { - for (tool_name, use_count) in &other.use_counts { - *self.use_counts.entry(tool_name.clone()).or_insert(0) += use_count; - } - for (tool_name, failure_count) in &other.failure_counts { - *self.failure_counts.entry(tool_name.clone()).or_insert(0) += failure_count; - } - } - - pub fn is_empty(&self) -> bool { - self.use_counts.is_empty() && self.failure_counts.is_empty() - } -} - -impl Display for ToolMetrics { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let mut failure_rates: Vec<(Arc, f64)> = Vec::new(); - - for (tool_name, use_count) in &self.use_counts { - let failure_count = self.failure_counts.get(tool_name).cloned().unwrap_or(0); - if *use_count > 0 { - let failure_rate = failure_count as f64 / *use_count as f64; - failure_rates.push((tool_name.clone(), failure_rate)); - } - } - - // Sort by failure rate descending - failure_rates.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); - - // Table dimensions - let tool_width = 30; - let count_width = 10; - let rate_width = 10; - - // Write table top border - writeln!( - f, - "┌{}┬{}┬{}┬{}┐", - "─".repeat(tool_width), - "─".repeat(count_width), - "─".repeat(count_width), - "─".repeat(rate_width) - )?; - - // Write header row - writeln!( - f, - "│{:^30}│{:^10}│{:^10}│{:^10}│", - "Tool", "Uses", "Failures", "Rate" - )?; - - // Write header-data separator - writeln!( - f, - "├{}┼{}┼{}┼{}┤", - "─".repeat(tool_width), - "─".repeat(count_width), - "─".repeat(count_width), - "─".repeat(rate_width) - )?; - - // Write data rows - for (tool_name, failure_rate) in failure_rates { - let use_count = self.use_counts.get(&tool_name).cloned().unwrap_or(0); - let failure_count = self.failure_counts.get(&tool_name).cloned().unwrap_or(0); - writeln!( - f, - "│{:<30}│{:^10}│{:^10}│{:^10}│", - tool_name, - use_count, - failure_count, - format!("{}%", (failure_rate * 100.0).round()) - )?; - } - - // Write table bottom border - writeln!( - f, - "└{}┴{}┴{}┴{}┘", - "─".repeat(tool_width), - "─".repeat(count_width), - "─".repeat(count_width), - "─".repeat(rate_width) - )?; - - Ok(()) - } -} diff --git a/crates/eval_cli/Cargo.toml b/crates/eval_cli/Cargo.toml index d8f52992e2ae9512e694bb11c491fd8b60c0c947..cac5dc6aa28fa9dfa9b7d41caf0db125daf596dc 100644 --- a/crates/eval_cli/Cargo.toml +++ b/crates/eval_cli/Cargo.toml @@ -21,6 +21,7 @@ anyhow.workspace = true clap.workspace = true client.workspace = true ctrlc = { version = "3.5", features = ["termination"] } +db.workspace = true debug_adapter_extension.workspace = true env_logger.workspace = true extension.workspace = true diff --git a/crates/eval_cli/Dockerfile b/crates/eval_cli/Dockerfile index 7b91a7adf991428670fac43ad745a6e9998c9c38..06593a124fe61c50f36c1c3e88f2a0b7443604d3 100644 --- a/crates/eval_cli/Dockerfile +++ b/crates/eval_cli/Dockerfile @@ -7,55 +7,43 @@ # Or use the helper script: # crates/eval_cli/script/build-linux -FROM rust:1.93.1-bookworm AS builder +FROM rust:1.94.1 AS builder WORKDIR /app -# Install build dependencies (subset of script/linux needed for headless GPUI). + # Pre-install the toolchain specified in rust-toolchain.toml so it is cached. +RUN rustup toolchain install 1.94.1 --profile minimal \ + --component rustfmt --component clippy --component rust-analyzer --component rust-src \ + --target wasm32-wasip2 --target wasm32-unknown-unknown --target x86_64-unknown-linux-musl --target x86_64-unknown-linux-gnu + +# Install build tools. cmake + build-essential are needed for vendored C +# libraries (libgit2-sys, zstd-sys, libsqlite3-sys). No audio/GUI -dev +# packages required — eval-cli runs headless with those features disabled. +# +# cargo-zigbuild cross-compiles against musl libc, producing a fully +# static binary that runs on any Linux distro (glibc or musl / Alpine). RUN apt-get update && apt-get install -y --no-install-recommends \ cmake \ - clang \ - g++ \ - libasound2-dev \ - libfontconfig-dev \ - libgit2-dev \ - libglib2.0-dev \ - libssl-dev \ - libwayland-dev \ - libx11-xcb-dev \ - libxkbcommon-x11-dev \ - libzstd-dev \ - libsqlite3-dev \ build-essential \ curl \ + xz-utils \ && rm -rf /var/lib/apt/lists/* -# Install wild linker for faster linking (built from source to match bookworm's glibc). -RUN cargo install --locked wild-linker --version 0.8.0 --root /usr/local +RUN mkdir -p /opt/zig \ + && curl -fsSL https://ziglang.org/download/0.15.2/zig-x86_64-linux-0.15.2.tar.xz \ + | tar -xJ -C /opt/zig --strip-components=1 \ + && ln -s /opt/zig/zig /usr/local/bin/zig -# Download WASI SDK (needed by some dependencies). -ARG TARGETARCH -RUN mkdir -p /app/target && \ - WASI_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "arm64" || echo "x86_64") && \ - curl -L "https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-25/wasi-sdk-25.0-${WASI_ARCH}-linux.tar.gz" \ - | tar -xz -C /app/target && \ - mv /app/target/wasi-sdk-25.0-${WASI_ARCH}-linux /app/target/wasi-sdk - -# Pre-install the toolchain specified in rust-toolchain.toml so it is cached. -RUN rustup toolchain install 1.93 --profile minimal \ - --component rustfmt --component clippy --component rust-analyzer --component rust-src \ - --target wasm32-wasip2 --target wasm32-unknown-unknown --target x86_64-unknown-linux-musl +RUN cargo install --locked cargo-zigbuild COPY . . -ENV CC=clang CXX=clang++ -ENV RUSTFLAGS="-C linker=clang -C link-arg=--ld-path=wild" - RUN --mount=type=cache,target=/usr/local/cargo/registry \ --mount=type=cache,target=/usr/local/cargo/git \ --mount=type=cache,target=/app/target \ - cargo build --release --package eval_cli && \ - cp /app/target/release/eval-cli /eval-cli && \ + cargo zigbuild --release --package eval_cli \ + --target x86_64-unknown-linux-musl && \ + cp /app/target/x86_64-unknown-linux-musl/release/eval-cli /eval-cli && \ strip /eval-cli FROM scratch diff --git a/crates/eval_cli/script/build-linux b/crates/eval_cli/script/build-linux index 9c710668de2aa5e956efff727e6ef8eb2c5ed627..dbb1d32668e9e3347a98de423521985059cbbbff 100755 --- a/crates/eval_cli/script/build-linux +++ b/crates/eval_cli/script/build-linux @@ -1,8 +1,10 @@ #!/usr/bin/env bash # # Build eval-cli for x86_64 Linux from any host (macOS, Linux, etc.) -# using Docker. The resulting binary is placed at the path printed on -# completion (default: target/eval-cli). +# using Docker + cargo-zigbuild. Targets musl libc, producing a fully +# static binary that runs on any Linux distro (glibc or musl / Alpine). +# The resulting binary is placed at the path printed on completion +# (default: target/eval-cli). # # Usage: # crates/eval_cli/script/build-linux [--output PATH] @@ -36,7 +38,7 @@ cd "$REPO_ROOT" IMAGE_TAG="eval-cli-builder" -echo "Building eval-cli for x86_64-unknown-linux-gnu..." +echo "Building eval-cli for x86_64-unknown-linux-musl (static binary)..." echo " Repo root: $REPO_ROOT" echo " Output: $OUTPUT" echo "" diff --git a/crates/eval_cli/src/headless.rs b/crates/eval_cli/src/headless.rs index 54f14ee1938d4b58bdc32acbd07eced8d8a86406..f1c8830b11a195df120f3b54aa466d27ce708568 100644 --- a/crates/eval_cli/src/headless.rs +++ b/crates/eval_cli/src/headless.rs @@ -2,6 +2,7 @@ use std::path::PathBuf; use std::sync::Arc; use client::{Client, ProxySettings, UserStore}; +use db::AppDatabase; use extension::ExtensionHostProxy; use fs::RealFs; use gpui::http_client::read_proxy_from_env; @@ -61,6 +62,9 @@ pub fn init(cx: &mut App) -> Arc { let client = Client::production(cx); cx.set_http_client(client.http_client()); + let app_db = AppDatabase::new(); + cx.set_global(app_db); + let git_binary_path = None; let fs = Arc::new(RealFs::new( git_binary_path, @@ -118,6 +122,7 @@ pub fn init(cx: &mut App) -> Arc { prompt_builder, languages.clone(), true, + true, cx, ); diff --git a/crates/eval_cli/src/main.rs b/crates/eval_cli/src/main.rs index b49cc4d53f50eeb5ea10216867257332c5354cb4..f9ab1835f94327c72462ba7014bf7517d12ac55d 100644 --- a/crates/eval_cli/src/main.rs +++ b/crates/eval_cli/src/main.rs @@ -82,8 +82,21 @@ struct Args { timeout: Option, /// Directory for output artifacts (result.json, thread.md, thread.json). - #[arg(long, default_value = "/logs/agent")] + #[arg(long, default_value = ".")] output_dir: PathBuf, + + /// Disable staff mode (staff mode is enabled by default). + #[arg(long)] + no_staff: bool, + + /// Reasoning effort level for models that support thinking (low, medium, high). + /// Defaults to "high" for thinking-capable models. + #[arg(long)] + reasoning_effort: Option, + + /// Enable or disable extended thinking. Defaults to model auto-detection if omitted. + #[arg(long)] + thinking: Option, } enum AgentOutcome { @@ -154,7 +167,7 @@ fn main() { app.run(move |cx| { let app_state = headless::init(cx); - cx.set_staff(true); + cx.set_staff(!args.no_staff); let auth_tasks = LanguageModelRegistry::global(cx).update(cx, |registry, cx| { registry @@ -166,6 +179,8 @@ fn main() { let model_name = args.model.clone(); let timeout = args.timeout; + let thinking_override = args.thinking; + let reasoning_effort = args.reasoning_effort.clone(); cx.spawn(async move |cx| { futures::future::join_all(auth_tasks).await; @@ -178,6 +193,8 @@ fn main() { &instruction, &model_name, timeout, + thinking_override, + reasoning_effort.as_deref(), Some(&output_dir), cx, ) @@ -257,6 +274,8 @@ async fn run_agent( instruction: &str, model_name: &str, timeout: Option, + thinking_override: Option, + reasoning_effort: Option<&str>, output_dir: Option<&std::path::Path>, cx: &mut AsyncApp, ) -> (Result, Option) { @@ -292,10 +311,14 @@ async fn run_agent( anyhow::Ok(()) })?; - let (enable_thinking, effort) = if supports_thinking { - (true, "\"high\"") + let enable_thinking = thinking_override.unwrap_or(supports_thinking); + let effort = if enable_thinking { + match reasoning_effort { + Some(level) => format!("\"{level}\""), + None => "\"high\"".to_string(), + } } else { - (false, "null") + "null".to_string() }; let provider_id = selected.provider.0.to_string(); let model_id = selected.model.0.to_string(); diff --git a/crates/eval_cli/zed_eval/agent.py b/crates/eval_cli/zed_eval/agent.py index 6214ff18d784dd9620f404a00ba1b48ce96b5707..54403e9a2531fdf772330ea986e45a37cf62418a 100644 --- a/crates/eval_cli/zed_eval/agent.py +++ b/crates/eval_cli/zed_eval/agent.py @@ -22,7 +22,7 @@ import os import shlex from pathlib import Path -from harbor.agents.installed.base import BaseInstalledAgent, ExecInput +from harbor.agents.installed.base import BaseInstalledAgent, with_prompt_template from harbor.environments.base import BaseEnvironment from harbor.models.agent.context import AgentContext @@ -51,12 +51,70 @@ class ZedAgent(BaseInstalledAgent): def name() -> str: return "zed" - @property - def _install_agent_template_path(self) -> Path: - return Path(__file__).parent / "install.sh.j2" + async def _detect_workdir(self, environment: BaseEnvironment) -> str: + """Detect the repo working directory inside the container. - async def setup(self, environment: BaseEnvironment) -> None: - await environment.exec(command="mkdir -p /installed-agent") + Checks, in order: + 1. Explicit ``EVAL_CLI_WORKDIR`` extra-env override + 2. ``/app`` (SWE-bench Pro) + 3. ``/testbed`` (SWE-bench Verified) + 4. ``/repo`` + 5. First git repo found under ``/`` (max depth 3) + """ + override = self._extra_env.get("EVAL_CLI_WORKDIR") + if override: + return override + + result = await self.exec_as_agent( + environment, + command=( + "for d in /app /testbed /repo; do " + ' if [ -d "$d/.git" ]; then echo "$d"; exit 0; fi; ' + "done; " + "find / -maxdepth 3 -name .git -type d 2>/dev/null " + '| head -1 | sed "s|/.git$||"' + ), + ) + workdir = result.stdout.strip() + if not workdir: + raise RuntimeError( + "Could not find a git repository in the container. " + "Set EVAL_CLI_WORKDIR explicitly via --ae EVAL_CLI_WORKDIR=/path/to/repo" + ) + return workdir + + async def install(self, environment: BaseEnvironment) -> None: + # Detect the package manager and install base dependencies. + # Supports Debian/Ubuntu (apt-get), Alpine (apk), and + # Fedora/RHEL/CentOS (dnf/yum). + await self.exec_as_root( + environment, + command=( + "if command -v apt-get >/dev/null 2>&1; then " + " apt-get update && " + " apt-get install -y --no-install-recommends ca-certificates curl git; " + "elif command -v apk >/dev/null 2>&1; then " + " apk add --no-cache ca-certificates curl git bash coreutils gcompat libstdc++; " + "elif command -v dnf >/dev/null 2>&1; then " + " dnf install -y ca-certificates curl git; " + "elif command -v yum >/dev/null 2>&1; then " + " yum install -y ca-certificates curl git; " + "else " + " echo 'WARNING: No supported package manager found (apt-get, apk, dnf, yum)' >&2; " + "fi" + ), + env={"DEBIAN_FRONTEND": "noninteractive"}, + ) + + # ── Non-essential tooling ───────────────────────────────────── + # Everything below here (Node.js, LSPs, uv/ruff) is nice-to-have. + # If any step fails (e.g. musl incompatibility, network issues), + # log a warning and continue — the agent can still work without + # pre-installed language servers. + + await self._install_node(environment) + await self._install_lsps(environment) + await self._install_uv_and_ruff(environment) if self._binary_path: binary = Path(self._binary_path) @@ -69,18 +127,206 @@ class ZedAgent(BaseInstalledAgent): source_path=binary, target_path="/usr/local/bin/eval-cli", ) - await environment.exec(command="chmod +x /usr/local/bin/eval-cli") - - await super().setup(environment) + await self.exec_as_root( + environment, + command="chmod +x /usr/local/bin/eval-cli && eval-cli --help", + ) + return - @property - def _template_variables(self) -> dict[str, str]: - variables = super()._template_variables - if self._binary_path: - variables["binary_uploaded"] = "true" if self._download_url: - variables["download_url"] = self._download_url - return variables + await self.exec_as_root( + environment, + command=( + f"curl -fsSL {shlex.quote(self._download_url)} " + "-o /usr/local/bin/eval-cli && " + "chmod +x /usr/local/bin/eval-cli && " + "eval-cli --help" + ), + ) + return + + raise ValueError( + "No eval-cli binary provided. " + "Either pass binary_path=/path/to/target/release/eval-cli " + "or set download_url=/EVAL_CLI_DOWNLOAD_URL." + ) + + async def _install_node(self, environment: BaseEnvironment) -> None: + """Install Node.js from official binary tarballs. + + Uses the musl build on Alpine and the glibc build elsewhere. + Skips if node is already on PATH. + """ + try: + await self.exec_as_root( + environment, + command=( + "if command -v node >/dev/null 2>&1; then " + ' echo "Node.js already available: $(node --version)"; ' + "else " + " NODE_VER=v22.14.0; " + " ARCH=$(uname -m); " + ' case "$ARCH" in ' + " x86_64) NODE_ARCH=x64 ;; " + " aarch64) NODE_ARCH=arm64 ;; " + ' *) echo "WARNING: unsupported arch $ARCH for Node.js" >&2; exit 0 ;; ' + " esac; " + " if ldd /bin/sh 2>&1 | grep -qi musl; then " + ' NODE_URL="https://unofficial-builds.nodejs.org/download/release/${NODE_VER}/node-${NODE_VER}-linux-${NODE_ARCH}-musl.tar.gz"; ' + " else " + ' NODE_URL="https://nodejs.org/dist/${NODE_VER}/node-${NODE_VER}-linux-${NODE_ARCH}.tar.gz"; ' + " fi; " + ' echo "Downloading Node.js from $NODE_URL"; ' + ' curl -fsSL "$NODE_URL" | tar -xz -C /usr/local --strip-components=1; ' + ' echo "Installed Node.js $(node --version)"; ' + "fi" + ), + ) + except Exception as exc: + self.logger.warning("Node.js installation failed (non-fatal): %s", exc) + + async def _install_lsps(self, environment: BaseEnvironment) -> None: + """Pre-install language servers so Zed doesn't download them at runtime. + + Each LSP is installed independently so one failure doesn't block the rest. + """ + # npm-based LSPs — skip all if npm is not available. + try: + await self.exec_as_agent( + environment, + command="command -v npm >/dev/null 2>&1", + ) + except Exception: + self.logger.warning("npm not available — skipping npm-based LSP installs") + return + + lsp_installs = [ + ( + "basedpyright", + 'DIR="$ZED_DATA_DIR/languages/basedpyright"; ' + 'mkdir -p "$DIR" && npm install --prefix "$DIR" --save-exact basedpyright', + ), + ( + "typescript-language-server", + 'DIR="$ZED_DATA_DIR/languages/typescript-language-server"; ' + 'mkdir -p "$DIR" && npm install --prefix "$DIR" --save-exact typescript typescript-language-server', + ), + ( + "vtsls", + 'DIR="$ZED_DATA_DIR/languages/vtsls"; ' + 'mkdir -p "$DIR" && npm install --prefix "$DIR" --save-exact @vtsls/language-server typescript', + ), + ( + "tailwindcss-language-server", + 'DIR="$ZED_DATA_DIR/languages/tailwindcss-language-server"; ' + 'mkdir -p "$DIR" && npm install --prefix "$DIR" --save-exact @tailwindcss/language-server', + ), + ] + + for name, cmd in lsp_installs: + try: + await self.exec_as_agent( + environment, + command=( + 'ZED_DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/zed"; ' + + cmd + ), + ) + except Exception as exc: + self.logger.warning( + "LSP install '%s' failed (non-fatal): %s", name, exc + ) + + # eslint — downloaded from GitHub and compiled separately. + try: + await self.exec_as_agent( + environment, + command=( + "set -euo pipefail; " + 'ZED_DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/zed"; ' + 'ESLINT_DIR="$ZED_DATA_DIR/languages/eslint/vscode-eslint-2.4.4"; ' + 'mkdir -p "$ESLINT_DIR"; ' + 'curl -fsSL "https://github.com/zed-industries/vscode-eslint/archive/refs/tags/release/2.4.4.tar.gz" ' + '| tar -xz -C "$ESLINT_DIR"; ' + 'mv "$ESLINT_DIR"/vscode-eslint-release-2.4.4 "$ESLINT_DIR/vscode-eslint"; ' + 'cd "$ESLINT_DIR/vscode-eslint" && npm install && npm run compile' + ), + ) + except Exception as exc: + self.logger.warning("eslint LSP install failed (non-fatal): %s", exc) + + # gopls — only when Go is present. Guarded by a 120s timeout so slow + # compilation can never eat the full setup budget. + gopls_script = ( + "if command -v go >/dev/null 2>&1; then " + "if go install golang.org/x/tools/gopls@latest 2>/dev/null; then " + "echo 'Installed gopls@latest'; " + "else " + ' MY_GO=$(go env GOVERSION | sed "s/^go//"); ' + " for v in $(curl -fsSL " + "https://proxy.golang.org/golang.org/x/tools/gopls/@v/list 2>/dev/null" + " | grep -E '^v[0-9]+\\.[0-9]+\\.[0-9]+$' | sort -rV | head -5); do " + " NEED=$(curl -fsSL " + '"https://proxy.golang.org/golang.org/x/tools/gopls/@v/${v}.mod"' + " 2>/dev/null | awk '/^go /{print $2; exit}'); " + ' if [ -n "$NEED" ] ' + ' && [ "$(printf \'%s\\n%s\\n\' "$NEED" "$MY_GO" ' + ' | sort -V | head -1)" = "$NEED" ]; then ' + ' echo "Installing gopls $v (compatible with Go $MY_GO)"; ' + ' go install "golang.org/x/tools/gopls@$v" && break; ' + " fi; " + " done; " + "fi; " + "fi" + ) + try: + await self.exec_as_agent( + environment, + command=( + "timeout 120 bash -c " + + shlex.quote(gopls_script) + + " || echo 'WARNING: gopls installation timed out or failed -- skipping'" + ), + ) + except Exception as exc: + self.logger.warning("gopls install failed (non-fatal): %s", exc) + + async def _install_uv_and_ruff(self, environment: BaseEnvironment) -> None: + """Install uv and ruff for Python tooling.""" + try: + await self.exec_as_agent( + environment, + command=( + "curl -LsSf https://astral.sh/uv/install.sh | sh && " + '. "$HOME/.local/bin/env"' + ), + ) + + agent_home_result = await self.exec_as_agent( + environment, + command='printf %s "$HOME"', + ) + agent_home = agent_home_result.stdout.strip() + if not agent_home: + self.logger.warning( + "Could not determine agent home directory — skipping uv symlinks" + ) + return + + await self.exec_as_root( + environment, + command=( + f"ln -sf {shlex.quote(agent_home + '/.local/bin/uv')} /usr/local/bin/uv && " + f"ln -sf {shlex.quote(agent_home + '/.local/bin/uvx')} /usr/local/bin/uvx" + ), + ) + + await self.exec_as_agent( + environment, + command='export PATH="$HOME/.local/bin:$PATH" && uv tool install ruff', + ) + except Exception as exc: + self.logger.warning("uv/ruff installation failed (non-fatal): %s", exc) def populate_context_post_run(self, context: AgentContext) -> None: result_data = None @@ -131,31 +377,61 @@ class ZedAgent(BaseInstalledAgent): return env - def create_run_agent_commands(self, instruction: str) -> list[ExecInput]: + @with_prompt_template + async def run( + self, instruction: str, environment: BaseEnvironment, context: AgentContext + ) -> None: escaped_instruction = shlex.quote(instruction) env = self._get_api_env() - parts = ["eval-cli", "--workdir /testbed", "--output-dir /logs/agent"] + workdir = await self._detect_workdir(environment) + + parts = [ + "eval-cli", + f"--workdir {shlex.quote(workdir)}", + "--output-dir /logs/agent", + ] if self.model_name: - parts.append(f"--model {self.model_name}") + parts.append(f"--model {shlex.quote(self.model_name)}") timeout = self._extra_env.get("EVAL_CLI_TIMEOUT") if timeout: - parts.append(f"--timeout {timeout}") + parts.append(f"--timeout {shlex.quote(timeout)}") - parts.append(f"--instruction {escaped_instruction}") + staff = self._extra_env.get("EVAL_CLI_STAFF") + if staff and staff.lower() == "false": + parts.append("--no-staff") + + reasoning_effort = self._extra_env.get("EVAL_CLI_REASONING_EFFORT") + if reasoning_effort: + parts.append(f"--reasoning-effort {shlex.quote(reasoning_effort)}") + + enable_thinking = self._extra_env.get("EVAL_CLI_ENABLE_THINKING") + if enable_thinking: + if enable_thinking.lower() == "true": + parts.append("--enable-thinking") + elif enable_thinking.lower() == "false": + parts.append("--disable-thinking") - eval_cli_command = " ".join(parts) + " 2>&1 | stdbuf -oL tee /logs/agent/eval-cli.txt" + parts.append(f"--instruction {escaped_instruction}") - patch_command = ( - "cd /testbed && " - "git add -A && " - "git diff --cached HEAD > /logs/agent/patch.diff && " - "echo \"Patch size: $(wc -c < /logs/agent/patch.diff) bytes\"" + await self.exec_as_agent( + environment, + command=( + " ".join(parts) + " 2>&1 | if command -v stdbuf >/dev/null 2>&1;" + " then stdbuf -oL tee /logs/agent/eval-cli.txt;" + " else tee /logs/agent/eval-cli.txt; fi" + ), + env=env, ) - return [ - ExecInput(command=eval_cli_command, env=env), - ExecInput(command=patch_command), - ] + await self.exec_as_agent( + environment, + command=( + "git add -A && " + "git diff --cached HEAD > /logs/agent/patch.diff && " + 'echo "Patch size: $(wc -c < /logs/agent/patch.diff) bytes"' + ), + cwd=workdir, + ) diff --git a/crates/eval_cli/zed_eval/install.sh.j2 b/crates/eval_cli/zed_eval/install.sh.j2 deleted file mode 100644 index f7ebbe028216a1a7a0fd606e50a2f707db34c5ce..0000000000000000000000000000000000000000 --- a/crates/eval_cli/zed_eval/install.sh.j2 +++ /dev/null @@ -1,49 +0,0 @@ -#!/bin/bash -set -euo pipefail - -# Install runtime dependencies needed by the eval-cli binary (dynamically linked -# against glibc + these shared libraries from its GPUI/terminal/language stacks). -apt-get update -apt-get install -y --no-install-recommends \ - ca-certificates \ - curl \ - git \ - libasound2 \ - libfontconfig1 \ - libglib2.0-0 \ - libsqlite3-0 \ - libssl3 \ - libwayland-client0 \ - libx11-xcb1 \ - libxkbcommon-x11-0 \ - libzstd1 - -# Install Node.js 22 LTS (needed by language servers like basedpyright). -curl -fsSL https://deb.nodesource.com/setup_22.x | bash - -apt-get install -y --no-install-recommends nodejs - -# Install uv (needed for running Python tests in SWE-bench tasks). -curl -LsSf https://astral.sh/uv/install.sh | sh -. "$HOME/.local/bin/env" -ln -sf "$HOME/.local/bin/uv" /usr/local/bin/uv -ln -sf "$HOME/.local/bin/uvx" /usr/local/bin/uvx - -{% if binary_uploaded is defined %} -# Binary was uploaded directly via setup() — just verify it works. -eval-cli --help -{% elif download_url is defined %} -curl -fsSL "{{ download_url }}" -o /usr/local/bin/eval-cli -chmod +x /usr/local/bin/eval-cli -eval-cli --help -{% else %} -echo "ERROR: No eval-cli binary provided." -echo "" -echo "Either pass binary_path= to upload a local build:" -echo " --ae binary_path=/path/to/target/release/eval-cli" -echo "" -echo "Or set download_url= / EVAL_CLI_DOWNLOAD_URL:" -echo " --ae download_url=https://example.com/eval-cli" -exit 1 -{% endif %} - -echo "INSTALL_SUCCESS" diff --git a/crates/extension_api/src/settings.rs b/crates/extension_api/src/settings.rs index a133a8027a4361c1f92e3fdecc73664497b4e6d6..bb9f2e20b23efd077e37b6c90b245f120c1d6ab9 100644 --- a/crates/extension_api/src/settings.rs +++ b/crates/extension_api/src/settings.rs @@ -1,6 +1,6 @@ //! Provides access to Zed settings. -#[path = "../wit/since_v0.2.0/settings.rs"] +#[path = "../wit/since_v0.8.0/settings.rs"] mod types; use crate::{Project, Result, SettingsLocation, Worktree, wit}; diff --git a/crates/extension_api/wit/since_v0.8.0/settings.rs b/crates/extension_api/wit/since_v0.8.0/settings.rs index 19e28c1ba955a998fe7b97f3eacb57c4b1104154..7c77dc79baf7ab89bec74b6c66ea5b736d2ba858 100644 --- a/crates/extension_api/wit/since_v0.8.0/settings.rs +++ b/crates/extension_api/wit/since_v0.8.0/settings.rs @@ -6,6 +6,8 @@ use std::{collections::HashMap, num::NonZeroU32}; pub struct LanguageSettings { /// How many columns a tab should occupy. pub tab_size: NonZeroU32, + /// The preferred line length (column at which to wrap). + pub preferred_line_length: u32, } /// The settings for a particular language server. diff --git a/crates/extension_cli/Cargo.toml b/crates/extension_cli/Cargo.toml index 24ea9cfafadc61b2753f7b739fd4b7cbbd24dbfe..c019a323196e96d0b7a0131cc518e599154cd350 100644 --- a/crates/extension_cli/Cargo.toml +++ b/crates/extension_cli/Cargo.toml @@ -29,7 +29,7 @@ serde_json_lenient.workspace = true settings_content.workspace = true snippet_provider.workspace = true task.workspace = true -theme.workspace = true +theme_settings.workspace = true tokio = { workspace = true, features = ["full"] } toml.workspace = true tree-sitter.workspace = true diff --git a/crates/extension_cli/src/main.rs b/crates/extension_cli/src/main.rs index d0a533bfeb331c196d802df9894e726201794ce7..57845754fc8263c516bc3aec7d1ae0a2ffe68a2f 100644 --- a/crates/extension_cli/src/main.rs +++ b/crates/extension_cli/src/main.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeSet; use std::collections::HashMap; use std::env; use std::fs; @@ -7,6 +8,7 @@ use std::sync::Arc; use ::fs::{CopyOptions, Fs, RealFs, copy_recursive}; use anyhow::{Context as _, Result, anyhow, bail}; use clap::Parser; +use cloud_api_types::ExtensionProvides; use extension::extension_builder::{CompileExtensionOptions, ExtensionBuilder}; use extension::{ExtensionManifest, ExtensionSnippets}; use language::LanguageConfig; @@ -80,10 +82,7 @@ async fn main() -> Result<()> { .context("failed to compile extension")?; let extension_provides = manifest.provides(); - - if extension_provides.is_empty() { - bail!("extension does not provide any features"); - } + validate_extension_features(&extension_provides)?; let grammars = test_grammars(&manifest, &extension_path, &mut wasm_store)?; test_languages(&manifest, &extension_path, &grammars)?; @@ -203,7 +202,7 @@ async fn copy_extension_resources( }, ) .await - .with_context(|| "failed to copy icons")?; + .context("failed to copy icons")?; } for (_, agent_entry) in &manifest.agent_servers { @@ -297,6 +296,22 @@ async fn copy_extension_resources( Ok(()) } +fn validate_extension_features(provides: &BTreeSet) -> Result<()> { + if provides.is_empty() { + bail!("extension does not provide any features"); + } + + if provides.contains(&ExtensionProvides::Themes) && provides.len() != 1 { + bail!("extension must not provide other features along with themes"); + } + + if provides.contains(&ExtensionProvides::IconThemes) && provides.len() != 1 { + bail!("extension must not provide other features along with icon themes"); + } + + Ok(()) +} + fn test_grammars( manifest: &ExtensionManifest, extension_path: &Path, @@ -398,7 +413,8 @@ async fn test_themes( ) -> Result<()> { for relative_theme_path in &manifest.themes { let theme_path = extension_path.join(relative_theme_path); - let theme_family = theme::read_user_theme(&theme_path, fs.clone()).await?; + let theme_family = + theme_settings::deserialize_user_theme(&fs.load_bytes(&theme_path).await?)?; log::info!("loaded theme family {}", theme_family.name); for theme in &theme_family.themes { diff --git a/crates/extension_host/Cargo.toml b/crates/extension_host/Cargo.toml index c6f4db47c97d69173242953926c6965c039a6397..8dd949844f03ed7d625a2374aaf99b7c38b6522f 100644 --- a/crates/extension_host/Cargo.toml +++ b/crates/extension_host/Cargo.toml @@ -68,6 +68,7 @@ project = { workspace = true, features = ["test-support"] } reqwest_client.workspace = true theme = { workspace = true, features = ["test-support"] } +theme_settings.workspace = true theme_extension.workspace = true zlog.workspace = true diff --git a/crates/extension_host/src/extension_store_test.rs b/crates/extension_host/src/extension_store_test.rs index fa93709d077d435e9d6b579ece8890885675329d..a2722da336b4d52a04a7d6da3c22347a3535bf2b 100644 --- a/crates/extension_host/src/extension_store_test.rs +++ b/crates/extension_host/src/extension_store_test.rs @@ -216,6 +216,7 @@ async fn test_extension_store(cx: &mut TestAppContext) { matcher: LanguageMatcher { path_suffixes: vec!["erb".into()], first_line_pattern: None, + ..LanguageMatcher::default() }, }, ), @@ -229,6 +230,7 @@ async fn test_extension_store(cx: &mut TestAppContext) { matcher: LanguageMatcher { path_suffixes: vec!["rb".into()], first_line_pattern: None, + ..LanguageMatcher::default() }, }, ), @@ -1005,7 +1007,7 @@ fn init_test(cx: &mut TestAppContext) { cx.set_global(store); release_channel::init(semver::Version::new(0, 0, 0), cx); extension::init(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); gpui_tokio::init(cx); }); } diff --git a/crates/extension_host/src/headless_host.rs b/crates/extension_host/src/headless_host.rs index 0aff06fdddcf5c075bd669528b5c52137f745863..7c30228257dbaa037fbc772be822a1000adfdfef 100644 --- a/crates/extension_host/src/headless_host.rs +++ b/crates/extension_host/src/headless_host.rs @@ -281,7 +281,7 @@ impl HeadlessExtensionStore { fs.rename(&tmp_path, &path, RenameOptions::default()) .await - .context("Failed to rename {tmp_path:?} to {path:?}")?; + .with_context(|| format!("Failed to rename {tmp_path:?} to {path:?}"))?; Self::load_extension(this, extension, cx).await }) 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 d7cf29ad0a3fcc7448d5bf44a8a2612d55e07a88..324a572f40c98037816870c99151a4789793da1b 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 @@ -941,6 +941,7 @@ impl ExtensionImports for WasmState { ); Ok(serde_json::to_string(&settings::LanguageSettings { tab_size: settings.tab_size, + preferred_line_length: settings.preferred_line_length, })?) } "lsp" => { diff --git a/crates/extensions_ui/Cargo.toml b/crates/extensions_ui/Cargo.toml index a80defd128549e9f2ed6b634c188a7f2f319ef6a..6b6b6838313ecc8738df769609cf236e3f6e0bfb 100644 --- a/crates/extensions_ui/Cargo.toml +++ b/crates/extensions_ui/Cargo.toml @@ -35,7 +35,7 @@ settings.workspace = true smallvec.workspace = true strum.workspace = true telemetry.workspace = true -theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true vim_mode_setting.workspace = true diff --git a/crates/extensions_ui/src/extension_suggest.rs b/crates/extensions_ui/src/extension_suggest.rs index 47d1092eacabb8f49593cb266ece7c8401cf3f3e..c78db92c2fd3b24ceb78c7a33b4ab177be483b9d 100644 --- a/crates/extensions_ui/src/extension_suggest.rs +++ b/crates/extensions_ui/src/extension_suggest.rs @@ -22,7 +22,7 @@ const SUGGESTIONS_BY_EXTENSION_ID: &[(&str, &[&str])] = &[ ("dart", &["dart"]), ("dockerfile", &["Dockerfile"]), ("elisp", &["el"]), - ("elixir", &["ex", "exs", "heex"]), + ("elixir", &["eex", "ex", "exs", "heex", "leex", "neex"]), ("elm", &["elm"]), ("erlang", &["erl", "hrl"]), ("fish", &["fish"]), diff --git a/crates/extensions_ui/src/extensions_ui.rs b/crates/extensions_ui/src/extensions_ui.rs index 2d0b151a107000e913ba4772d7d3d2bf50474fc1..fceae09e5a4fbe1116c73d6ff5ca8bf018480fd9 100644 --- a/crates/extensions_ui/src/extensions_ui.rs +++ b/crates/extensions_ui/src/extensions_ui.rs @@ -23,7 +23,7 @@ use project::DirectoryLister; use release_channel::ReleaseChannel; use settings::{Settings, SettingsContent}; use strum::IntoEnumIterator as _; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ Banner, Chip, ContextMenu, Divider, PopoverMenu, ScrollableHandle, Switch, ToggleButtonGroup, ToggleButtonGroupSize, ToggleButtonGroupStyle, ToggleButtonSimple, Tooltip, WithScrollbar, diff --git a/crates/feature_flags/src/flags.rs b/crates/feature_flags/src/flags.rs index 985257577f53314da218934e99156a069808e999..4d477aa4b393ee8b04829833324cd9092c2a04cd 100644 --- a/crates/feature_flags/src/flags.rs +++ b/crates/feature_flags/src/flags.rs @@ -69,7 +69,7 @@ impl FeatureFlag for UpdatePlanToolFeatureFlag { const NAME: &'static str = "update-plan-tool"; fn enabled_for_staff() -> bool { - true + false } } diff --git a/crates/file_finder/Cargo.toml b/crates/file_finder/Cargo.toml index 80e466ac4c571ede217aa734a7862becd08e72ba..5eb36f0f5150263629b407dbe07dc73b6eff31cf 100644 --- a/crates/file_finder/Cargo.toml +++ b/crates/file_finder/Cargo.toml @@ -47,3 +47,4 @@ theme = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } zlog.workspace = true remote_connection = { workspace = true, features = ["test-support"] } +theme_settings = { workspace = true, features = ["test-support"] } \ No newline at end of file diff --git a/crates/file_finder/src/file_finder_tests.rs b/crates/file_finder/src/file_finder_tests.rs index 3f9d579b03c9aa2abeb408bdf6b77cf5e69de003..cd9cdeee1ff266717d380aeaecf7cbeb66ec8309 100644 --- a/crates/file_finder/src/file_finder_tests.rs +++ b/crates/file_finder/src/file_finder_tests.rs @@ -3789,7 +3789,7 @@ async fn open_queried_buffer( fn init_test(cx: &mut TestAppContext) -> Arc { cx.update(|cx| { let state = AppState::test(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); super::init(cx); editor::init(cx); state diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index 9c218c8e53f9a2135ee09fadc78f627e3960da54..38cb1e6b3c467dba4430767c2f4d6705c1d8b2aa 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -8,7 +8,7 @@ use git::{ repository::{ AskPassDelegate, Branch, CommitDataReader, CommitDetails, CommitOptions, FetchOptions, GRAPH_CHUNK_SIZE, GitRepository, GitRepositoryCheckpoint, InitialGraphCommitData, LogOrder, - LogSource, PushOptions, Remote, RepoPath, ResetMode, Worktree, + LogSource, PushOptions, Remote, RepoPath, ResetMode, SearchCommitArgs, Worktree, }, status::{ DiffTreeType, FileStatus, GitStatus, StatusCode, TrackedStatus, TreeDiff, TreeDiffStatus, @@ -1017,6 +1017,15 @@ impl GitRepository for FakeGitRepository { .boxed() } + fn search_commits( + &self, + _log_source: LogSource, + _search_args: SearchCommitArgs, + _request_tx: Sender, + ) -> BoxFuture<'_, Result<()>> { + async { bail!("search_commits not supported for FakeGitRepository") }.boxed() + } + fn commit_data_reader(&self) -> Result { anyhow::bail!("commit_data_reader not supported for FakeGitRepository") } diff --git a/crates/git/src/git.rs b/crates/git/src/git.rs index 13745c1fdfc0523d850b95e45a81cae286a77a00..766378bf2e514d8a50348b608d52e9e764072f21 100644 --- a/crates/git/src/git.rs +++ b/crates/git/src/git.rs @@ -161,6 +161,14 @@ impl Oid { } } +impl TryFrom<&str> for Oid { + type Error = anyhow::Error; + + fn try_from(value: &str) -> std::prelude::v1::Result { + Oid::from_str(value) + } +} + impl FromStr for Oid { type Err = anyhow::Error; diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 32904aa9a9001187193c91a055a5e0393221514d..036ceeb620e1aa0345b6f9a296c16069c0fa09bf 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -50,6 +50,10 @@ pub const REMOTE_CANCELLED_BY_USER: &str = "Operation cancelled by user"; /// %x00 - Null byte separator, used to split up commit data static GRAPH_COMMIT_FORMAT: &str = "--format=%H%x00%P%x00%D"; +/// Used to get commits that match with a search +/// %H - Full commit hash +static SEARCH_COMMIT_FORMAT: &str = "--format=%H"; + /// Number of commits to load per chunk for the git graph. pub const GRAPH_CHUNK_SIZE: usize = 1000; @@ -623,6 +627,11 @@ impl LogSource { } } +pub struct SearchCommitArgs { + pub query: SharedString, + pub case_sensitive: bool, +} + pub trait GitRepository: Send + Sync { fn reload_index(&self); @@ -875,6 +884,13 @@ pub trait GitRepository: Send + Sync { request_tx: Sender>>, ) -> BoxFuture<'_, Result<()>>; + fn search_commits( + &self, + log_source: LogSource, + search_args: SearchCommitArgs, + request_tx: Sender, + ) -> BoxFuture<'_, Result<()>>; + fn commit_data_reader(&self) -> Result; fn set_trusted(&self, trusted: bool); @@ -1046,7 +1062,6 @@ impl GitRepository for RealGitRepository { let git = git_binary?; let output = git .build_command(&[ - "--no-optional-locks", "show", "--no-patch", "--format=%H%x00%B%x00%at%x00%ae%x00%an%x00", @@ -1084,7 +1099,6 @@ impl GitRepository for RealGitRepository { let git = git_binary?; let show_output = git .build_command(&[ - "--no-optional-locks", "show", "--format=", "-z", @@ -1105,7 +1119,7 @@ impl GitRepository for RealGitRepository { let parent_sha = format!("{}^", commit); let mut cat_file_process = git - .build_command(&["--no-optional-locks", "cat-file", "--batch=%(objectsize)"]) + .build_command(&["cat-file", "--batch=%(objectsize)"]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -1417,11 +1431,7 @@ impl GitRepository for RealGitRepository { .spawn(async move { let git = git_binary?; let mut process = git - .build_command(&[ - "--no-optional-locks", - "cat-file", - "--batch-check=%(objectname)", - ]) + .build_command(&["cat-file", "--batch-check=%(objectname)"]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -1495,7 +1505,6 @@ impl GitRepository for RealGitRepository { }; let mut args = vec![ - OsString::from("--no-optional-locks"), OsString::from("diff-tree"), OsString::from("-r"), OsString::from("-z"), @@ -1612,7 +1621,7 @@ impl GitRepository for RealGitRepository { .spawn(async move { let git = git_binary?; let output = git - .build_command(&["--no-optional-locks", "worktree", "list", "--porcelain"]) + .build_command(&["worktree", "list", "--porcelain"]) .output() .await?; if output.status.success() { @@ -1634,7 +1643,6 @@ impl GitRepository for RealGitRepository { ) -> BoxFuture<'_, Result<()>> { let git_binary = self.git_binary(); let mut args = vec![ - OsString::from("--no-optional-locks"), OsString::from("worktree"), OsString::from("add"), OsString::from("-b"), @@ -1668,11 +1676,7 @@ impl GitRepository for RealGitRepository { self.executor .spawn(async move { - let mut args: Vec = vec![ - "--no-optional-locks".into(), - "worktree".into(), - "remove".into(), - ]; + let mut args: Vec = vec!["worktree".into(), "remove".into()]; if force { args.push("--force".into()); } @@ -1690,7 +1694,6 @@ impl GitRepository for RealGitRepository { self.executor .spawn(async move { let args: Vec = vec![ - "--no-optional-locks".into(), "worktree".into(), "move".into(), "--".into(), @@ -1833,7 +1836,7 @@ impl GitRepository for RealGitRepository { commit_delimiter ); - let mut args = vec!["--no-optional-locks", "log", "--follow", &format_string]; + let mut args = vec!["log", "--follow", &format_string]; let skip_str; let limit_str; @@ -2709,6 +2712,61 @@ impl GitRepository for RealGitRepository { .boxed() } + fn search_commits( + &self, + log_source: LogSource, + search_args: SearchCommitArgs, + request_tx: Sender, + ) -> BoxFuture<'_, Result<()>> { + let git_binary = self.git_binary(); + + async move { + let git = git_binary?; + + let mut args = vec!["log", SEARCH_COMMIT_FORMAT, log_source.get_arg()?]; + + args.push("--fixed-strings"); + + if !search_args.case_sensitive { + args.push("--regexp-ignore-case"); + } + + args.push("--grep"); + args.push(search_args.query.as_str()); + + let mut command = git.build_command(&args); + command.stdout(Stdio::piped()); + command.stderr(Stdio::null()); + + let mut child = command.spawn()?; + let stdout = child.stdout.take().context("failed to get stdout")?; + let mut reader = BufReader::new(stdout); + + let mut line_buffer = String::new(); + + loop { + line_buffer.clear(); + let bytes_read = reader.read_line(&mut line_buffer).await?; + + if bytes_read == 0 { + break; + } + + let sha = line_buffer.trim_end_matches('\n'); + + if let Ok(oid) = Oid::from_str(sha) + && request_tx.send(oid).await.is_err() + { + break; + } + } + + child.status().await?; + Ok(()) + } + .boxed() + } + fn commit_data_reader(&self) -> Result { let git_binary = self.git_binary()?; @@ -2741,7 +2799,7 @@ async fn run_commit_data_reader( request_rx: smol::channel::Receiver, ) -> Result<()> { let mut process = git - .build_command(&["--no-optional-locks", "cat-file", "--batch"]) + .build_command(&["cat-file", "--batch"]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -2855,7 +2913,6 @@ fn parse_initial_graph_output<'a>( fn git_status_args(path_prefixes: &[RepoPath]) -> Vec { let mut args = vec![ - OsString::from("--no-optional-locks"), OsString::from("status"), OsString::from("--porcelain=v1"), OsString::from("--untracked-files=all"), @@ -3039,6 +3096,7 @@ impl GitBinary { let mut command = new_command(&self.git_binary_path); command.current_dir(&self.working_directory); command.args(["-c", "core.fsmonitor=false"]); + command.arg("--no-optional-locks"); command.arg("--no-pager"); if !self.is_trusted { diff --git a/crates/git_graph/Cargo.toml b/crates/git_graph/Cargo.toml index 4756c55ac9232631a46056e252021a704d4a25b6..6aeaefe7e9b32ab01b19e6f9747f9128f3718edf 100644 --- a/crates/git_graph/Cargo.toml +++ b/crates/git_graph/Cargo.toml @@ -22,6 +22,7 @@ test-support = [ anyhow.workspace = true collections.workspace = true db.workspace = true +editor.workspace = true feature_flags.workspace = true git.workspace = true git_ui.workspace = true @@ -29,9 +30,12 @@ gpui.workspace = true language.workspace = true menu.workspace = true project.workspace = true +search.workspace = true settings.workspace = true smallvec.workspace = true +smol.workspace = true theme.workspace = true +theme_settings.workspace = true time.workspace = true ui.workspace = true workspace.workspace = true diff --git a/crates/git_graph/src/git_graph.rs b/crates/git_graph/src/git_graph.rs index aa53cd83e45b07cf94a6fc1b862b71053b92c81d..305dc8e42d3789cf8495fa30fbb5ca5770b10600 100644 --- a/crates/git_graph/src/git_graph.rs +++ b/crates/git_graph/src/git_graph.rs @@ -1,16 +1,20 @@ -use collections::{BTreeMap, HashMap}; +use collections::{BTreeMap, HashMap, IndexSet}; +use editor::Editor; use feature_flags::{FeatureFlagAppExt as _, GitGraphFeatureFlag}; use git::{ BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, Oid, ParsedGitRemote, parse_git_remote_url, - repository::{CommitDiff, CommitFile, InitialGraphCommitData, LogOrder, LogSource, RepoPath}, + repository::{ + CommitDiff, CommitFile, InitialGraphCommitData, LogOrder, LogSource, RepoPath, + SearchCommitArgs, + }, status::{FileStatus, StatusCode, TrackedStatus}, }; use git_ui::{commit_tooltip::CommitAvatar, commit_view::CommitView, git_status_icon}; use gpui::{ AnyElement, App, Bounds, ClickEvent, ClipboardItem, Corner, DefiniteLength, DragMoveEvent, ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable, Hsla, PathBuilder, Pixels, - Point, ScrollStrategy, ScrollWheelEvent, SharedString, Subscription, Task, + Point, ScrollStrategy, ScrollWheelEvent, SharedString, Subscription, Task, TextStyleRefinement, UniformListScrollHandle, WeakEntity, Window, actions, anchored, deferred, point, prelude::*, px, uniform_list, }; @@ -23,6 +27,10 @@ use project::{ RepositoryEvent, RepositoryId, }, }; +use search::{ + SearchOption, SearchOptions, SearchSource, SelectNextMatch, SelectPreviousMatch, + ToggleCaseSensitive, +}; use settings::Settings; use smallvec::{SmallVec, smallvec}; use std::{ @@ -33,12 +41,13 @@ use std::{ sync::OnceLock, time::{Duration, Instant}, }; -use theme::{AccentColors, ThemeSettings}; +use theme::AccentColors; +use theme_settings::ThemeSettings; use time::{OffsetDateTime, UtcOffset, format_description::BorrowedFormatItem}; use ui::{ - ButtonLike, Chip, CommonAnimationExt as _, ContextMenu, DiffStat, Divider, ScrollableHandle, - Table, TableColumnWidths, TableInteractionState, TableResizeBehavior, Tooltip, WithScrollbar, - prelude::*, + ButtonLike, Chip, CommonAnimationExt as _, ContextMenu, DiffStat, Divider, HighlightedLabel, + ScrollableHandle, Table, TableColumnWidths, TableInteractionState, TableResizeBehavior, + Tooltip, WithScrollbar, prelude::*, }; use workspace::{ Workspace, @@ -197,6 +206,29 @@ impl ChangedFileEntry { } } +enum QueryState { + Pending(SharedString), + Confirmed((SharedString, Task<()>)), + Empty, +} + +impl QueryState { + fn next_state(&mut self) { + match self { + Self::Confirmed((query, _)) => *self = Self::Pending(std::mem::take(query)), + _ => {} + }; + } +} + +struct SearchState { + case_sensitive: bool, + editor: Entity, + state: QueryState, + pub matches: IndexSet, + pub selected_index: Option, +} + pub struct SplitState { left_ratio: f32, visible_left_ratio: f32, @@ -742,7 +774,7 @@ pub fn init(cx: &mut App) { let existing = workspace.items_of_type::(cx).next(); if let Some(existing) = existing { existing.update(cx, |graph, cx| { - graph.select_commit_by_sha(&sha, cx); + graph.select_commit_by_sha(sha.as_str(), cx); }); workspace.activate_item(&existing, true, true, window, cx); return; @@ -753,7 +785,7 @@ pub fn init(cx: &mut App) { let git_graph = cx.new(|cx| { let mut graph = GitGraph::new(project, workspace_handle, window, cx); - graph.select_commit_by_sha(&sha, cx); + graph.select_commit_by_sha(sha.as_str(), cx); graph }); workspace.add_item_to_active_pane( @@ -835,6 +867,7 @@ fn compute_diff_stats(diff: &CommitDiff) -> (usize, usize) { pub struct GitGraph { focus_handle: FocusHandle, + search_state: SearchState, graph_data: GraphData, project: Entity, workspace: WeakEntity, @@ -859,6 +892,14 @@ pub struct GitGraph { } impl GitGraph { + fn invalidate_state(&mut self, cx: &mut Context) { + self.graph_data.clear(); + self.search_state.matches.clear(); + self.search_state.selected_index = None; + self.search_state.state.next_state(); + cx.notify(); + } + fn row_height(cx: &App) -> Pixels { let settings = ThemeSettings::get_global(cx); let font_size = settings.buffer_font_size(cx); @@ -901,8 +942,7 @@ impl GitGraph { // todo(git_graph): Make this selectable from UI so we don't have to always use active repository if this.selected_repo_id != *changed_repo_id { this.selected_repo_id = *changed_repo_id; - this.graph_data.clear(); - cx.notify(); + this.invalidate_state(cx); } } _ => {} @@ -914,6 +954,12 @@ impl GitGraph { .active_repository(cx) .map(|repo| repo.read(cx).id); + let search_editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_placeholder_text("Search commits…", window, cx); + editor + }); + let table_interaction_state = cx.new(|cx| TableInteractionState::new(cx)); let table_column_widths = cx.new(|cx| TableColumnWidths::new(4, cx)); let mut row_height = Self::row_height(cx); @@ -933,6 +979,13 @@ impl GitGraph { let mut this = GitGraph { focus_handle, + search_state: SearchState { + case_sensitive: false, + editor: search_editor, + matches: IndexSet::default(), + selected_index: None, + state: QueryState::Empty, + }, project, workspace, graph_data: graph, @@ -980,7 +1033,7 @@ impl GitGraph { .and_then(|data| data.commit_oid_to_index.get(&oid).copied()) }) { - self.select_entry(pending_sha_index, cx); + self.select_entry(pending_sha_index, ScrollStrategy::Nearest, cx); } } GitGraphEvent::LoadingError => { @@ -1016,7 +1069,7 @@ impl GitGraph { pending_sha_index }) { - self.select_entry(pending_selection_index, cx); + self.select_entry(pending_selection_index, ScrollStrategy::Nearest, cx); self.pending_select_sha.take(); } @@ -1030,8 +1083,7 @@ impl GitGraph { // meaning we are not inside the initial repo loading state // NOTE: this fixes an loading performance regression if repository.read(cx).scan_id > 1 { - self.graph_data.clear(); - cx.notify(); + self.invalidate_state(cx); } } RepositoryEvent::GraphEvent(_, _) => {} @@ -1128,6 +1180,7 @@ impl GitGraph { .unwrap_or_else(|| accent_colors.0.first().copied().unwrap_or_default()); let is_selected = self.selected_entry_idx == Some(idx); + let is_matched = self.search_state.matches.contains(&commit.data.sha); let column_label = |label: SharedString| { Label::new(label) .when(!is_selected, |c| c.color(Color::Muted)) @@ -1135,11 +1188,49 @@ impl GitGraph { .into_any_element() }; + let subject_label = if is_matched { + let query = match &self.search_state.state { + QueryState::Confirmed((query, _)) => Some(query.clone()), + _ => None, + }; + let highlight_ranges = query + .and_then(|q| { + let ranges = if self.search_state.case_sensitive { + subject + .match_indices(q.as_str()) + .map(|(start, matched)| start..start + matched.len()) + .collect::>() + } else { + let q = q.to_lowercase(); + let subject_lower = subject.to_lowercase(); + + subject_lower + .match_indices(&q) + .filter_map(|(start, matched)| { + let end = start + matched.len(); + subject.is_char_boundary(start).then_some(()).and_then( + |_| subject.is_char_boundary(end).then_some(start..end), + ) + }) + .collect::>() + }; + + (!ranges.is_empty()).then_some(ranges) + }) + .unwrap_or_default(); + HighlightedLabel::from_ranges(subject.clone(), highlight_ranges) + .when(!is_selected, |c| c.color(Color::Muted)) + .truncate() + .into_any_element() + } else { + column_label(subject.clone()) + }; + vec![ div() .id(ElementId::NamedInteger("commit-subject".into(), idx as u64)) .overflow_hidden() - .tooltip(Tooltip::text(subject.clone())) + .tooltip(Tooltip::text(subject)) .child( h_flex() .gap_2() @@ -1153,7 +1244,7 @@ impl GitGraph { .map(|name| self.render_chip(name, accent_color)), ) })) - .child(column_label(subject)), + .child(subject_label), ) .into_any_element(), column_label(formatted_time.into()), @@ -1172,12 +1263,16 @@ impl GitGraph { } fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context) { - self.select_entry(0, cx); + self.select_entry(0, ScrollStrategy::Nearest, cx); } fn select_prev(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context) { if let Some(selected_entry_idx) = &self.selected_entry_idx { - self.select_entry(selected_entry_idx.saturating_sub(1), cx); + self.select_entry( + selected_entry_idx.saturating_sub(1), + ScrollStrategy::Nearest, + cx, + ); } else { self.select_first(&SelectFirst, window, cx); } @@ -1189,6 +1284,7 @@ impl GitGraph { selected_entry_idx .saturating_add(1) .min(self.graph_data.commits.len().saturating_sub(1)), + ScrollStrategy::Nearest, cx, ); } else { @@ -1197,14 +1293,88 @@ impl GitGraph { } fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context) { - self.select_entry(self.graph_data.commits.len().saturating_sub(1), cx); + self.select_entry( + self.graph_data.commits.len().saturating_sub(1), + ScrollStrategy::Nearest, + cx, + ); } fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { self.open_selected_commit_view(window, cx); } - fn select_entry(&mut self, idx: usize, cx: &mut Context) { + fn search(&mut self, query: SharedString, cx: &mut Context) { + let Some(repo) = self.get_selected_repository(cx) else { + return; + }; + + self.search_state.matches.clear(); + self.search_state.selected_index = None; + self.search_state.editor.update(cx, |editor, _cx| { + editor.set_text_style_refinement(Default::default()); + }); + + let (request_tx, request_rx) = smol::channel::unbounded::(); + + repo.update(cx, |repo, cx| { + repo.search_commits( + self.log_source.clone(), + SearchCommitArgs { + query: query.clone(), + case_sensitive: self.search_state.case_sensitive, + }, + request_tx, + cx, + ); + }); + + let search_task = cx.spawn(async move |this, cx| { + while let Ok(first_oid) = request_rx.recv().await { + let mut pending_oids = vec![first_oid]; + while let Ok(oid) = request_rx.try_recv() { + pending_oids.push(oid); + } + + this.update(cx, |this, cx| { + if this.search_state.selected_index.is_none() { + this.search_state.selected_index = Some(0); + this.select_commit_by_sha(first_oid, cx); + } + + this.search_state.matches.extend(pending_oids); + cx.notify(); + }) + .ok(); + } + + this.update(cx, |this, cx| { + if this.search_state.matches.is_empty() { + this.search_state.editor.update(cx, |editor, cx| { + editor.set_text_style_refinement(TextStyleRefinement { + color: Some(Color::Error.color(cx)), + ..Default::default() + }); + }); + } + }) + .ok(); + }); + + self.search_state.state = QueryState::Confirmed((query, search_task)); + } + + fn confirm_search(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context) { + let query = self.search_state.editor.read(cx).text(cx).into(); + self.search(query, cx); + } + + fn select_entry( + &mut self, + idx: usize, + scroll_strategy: ScrollStrategy, + cx: &mut Context, + ) { if self.selected_entry_idx == Some(idx) { return; } @@ -1215,9 +1385,7 @@ impl GitGraph { self.changed_files_scroll_handle .scroll_to_item(0, ScrollStrategy::Top); self.table_interaction_state.update(cx, |state, cx| { - state - .scroll_handle - .scroll_to_item(idx, ScrollStrategy::Nearest); + state.scroll_handle.scroll_to_item(idx, scroll_strategy); cx.notify(); }); @@ -1248,25 +1416,71 @@ impl GitGraph { cx.notify(); } - pub fn select_commit_by_sha(&mut self, sha: &str, cx: &mut Context) { - let Ok(oid) = sha.parse::() else { + fn select_previous_match(&mut self, cx: &mut Context) { + if self.search_state.matches.is_empty() { return; - }; + } + + let mut prev_selection = self.search_state.selected_index.unwrap_or_default(); - let Some(selected_repository) = self.get_selected_repository(cx) else { + if prev_selection == 0 { + prev_selection = self.search_state.matches.len() - 1; + } else { + prev_selection -= 1; + } + + let Some(&oid) = self.search_state.matches.get_index(prev_selection) else { return; }; - let Some(index) = selected_repository - .read(cx) - .get_graph_data(self.log_source.clone(), self.log_order) - .and_then(|data| data.commit_oid_to_index.get(&oid)) - .copied() - else { + self.search_state.selected_index = Some(prev_selection); + self.select_commit_by_sha(oid, cx); + } + + fn select_next_match(&mut self, cx: &mut Context) { + if self.search_state.matches.is_empty() { + return; + } + + let mut next_selection = self + .search_state + .selected_index + .map(|index| index + 1) + .unwrap_or_default(); + + if next_selection >= self.search_state.matches.len() { + next_selection = 0; + } + + let Some(&oid) = self.search_state.matches.get_index(next_selection) else { return; }; - self.select_entry(index, cx); + self.search_state.selected_index = Some(next_selection); + self.select_commit_by_sha(oid, cx); + } + + pub fn select_commit_by_sha(&mut self, sha: impl TryInto, cx: &mut Context) { + fn inner(this: &mut GitGraph, oid: Oid, cx: &mut Context) { + let Some(selected_repository) = this.get_selected_repository(cx) else { + return; + }; + + let Some(index) = selected_repository + .read(cx) + .get_graph_data(this.log_source.clone(), this.log_order) + .and_then(|data| data.commit_oid_to_index.get(&oid)) + .copied() + else { + return; + }; + + this.select_entry(index, ScrollStrategy::Center, cx); + } + + if let Ok(oid) = sha.try_into() { + inner(self, oid, cx); + } } fn open_selected_commit_view(&mut self, window: &mut Window, cx: &mut Context) { @@ -1318,6 +1532,129 @@ impl GitGraph { }) } + fn render_search_bar(&self, cx: &mut Context) -> impl IntoElement { + let color = cx.theme().colors(); + let query_focus_handle = self.search_state.editor.focus_handle(cx); + let search_options = { + let mut options = SearchOptions::NONE; + options.set( + SearchOptions::CASE_SENSITIVE, + self.search_state.case_sensitive, + ); + options + }; + + h_flex() + .w_full() + .p_1p5() + .gap_1p5() + .border_b_1() + .border_color(color.border_variant) + .child( + h_flex() + .h_8() + .flex_1() + .min_w_0() + .px_1p5() + .gap_1() + .border_1() + .border_color(color.border) + .rounded_md() + .bg(color.toolbar_background) + .on_action(cx.listener(Self::confirm_search)) + .child(self.search_state.editor.clone()) + .child(SearchOption::CaseSensitive.as_button( + search_options, + SearchSource::Buffer, + query_focus_handle, + )), + ) + .child( + h_flex() + .min_w_64() + .gap_1() + .child({ + let focus_handle = self.focus_handle.clone(); + IconButton::new("git-graph-search-prev", IconName::ChevronLeft) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) + .tooltip(move |_, cx| { + Tooltip::for_action_in( + "Select Previous Match", + &SelectPreviousMatch, + &focus_handle, + cx, + ) + }) + .map(|this| { + if self.search_state.matches.is_empty() { + this.disabled(true) + } else { + this.disabled(false).on_click(cx.listener(|this, _, _, cx| { + this.select_previous_match(cx); + })) + } + }) + }) + .child({ + let focus_handle = self.focus_handle.clone(); + IconButton::new("git-graph-search-next", IconName::ChevronRight) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) + .tooltip(move |_, cx| { + Tooltip::for_action_in( + "Select Next Match", + &SelectNextMatch, + &focus_handle, + cx, + ) + }) + .map(|this| { + if self.search_state.matches.is_empty() { + this.disabled(true) + } else { + this.disabled(false).on_click(cx.listener(|this, _, _, cx| { + this.select_next_match(cx); + })) + } + }) + }) + .child( + h_flex() + .gap_1p5() + .child( + Label::new(format!( + "{}/{}", + self.search_state + .selected_index + .map(|index| index + 1) + .unwrap_or(0), + self.search_state.matches.len() + )) + .size(LabelSize::Small) + .when(self.search_state.matches.is_empty(), |this| { + this.color(Color::Disabled) + }), + ) + .when( + matches!( + &self.search_state.state, + QueryState::Confirmed((_, task)) if !task.is_ready() + ), + |this| { + this.child( + Icon::new(IconName::ArrowCircle) + .color(Color::Accent) + .size(IconSize::Small) + .with_rotate_animation(2) + .into_any_element(), + ) + }, + ), + ), + ) + } + fn render_loading_spinner(&self, cx: &App) -> AnyElement { let rems = TextSize::Large.rems(cx); Icon::new(IconName::LoadCircle) @@ -1360,7 +1697,8 @@ impl GitGraph { .copied() .unwrap_or_else(|| accent_colors.0.first().copied().unwrap_or_default()); - let (author_name, author_email, commit_timestamp, subject) = match &data { + // todo(git graph): We should use the full commit message here + let (author_name, author_email, commit_timestamp, commit_message) = match &data { CommitDataState::Loaded(data) => ( data.author_name.clone(), data.author_email.clone(), @@ -1616,7 +1954,7 @@ impl GitGraph { ), ) .child(Divider::horizontal()) - .child(div().min_w_0().p_2().child(Label::new(subject))) + .child(div().p_2().child(Label::new(commit_message))) .child(Divider::horizontal()) .child( v_flex() @@ -1854,13 +2192,45 @@ impl GitGraph { -COMMIT_CIRCLE_RADIUS - COMMIT_CIRCLE_STROKE_WIDTH }; - let control = match curve_kind { + match curve_kind { CurveKind::Checkout => { if is_last { to_column -= column_shift; } builder.move_to(point(current_column, current_row)); - point(current_column, to_row) + + if (to_column - current_column).abs() > LANE_WIDTH { + // Multi-lane checkout: straight down, small + // curve turn, then straight horizontal. + if (to_row - current_row).abs() > row_height { + let vertical_end = + point(current_column, to_row - row_height); + builder.line_to(vertical_end); + builder.move_to(vertical_end); + } + + let lane_shift = if going_right { + LANE_WIDTH + } else { + -LANE_WIDTH + }; + let curve_end = + point(current_column + lane_shift, to_row); + let curve_control = point(current_column, to_row); + builder.curve_to(curve_end, curve_control); + builder.move_to(curve_end); + + builder.line_to(point(to_column, to_row)); + } else { + if (to_row - current_row).abs() > row_height { + let start_curve = + point(current_column, to_row - row_height); + builder.line_to(start_curve); + builder.move_to(start_curve); + } + let control = point(current_column, to_row); + builder.curve_to(point(to_column, to_row), control); + } } CurveKind::Merge => { if is_last { @@ -1870,37 +2240,25 @@ impl GitGraph { current_column + column_shift, current_row - COMMIT_CIRCLE_RADIUS, )); - point(to_column, current_row) - } - }; - - match curve_kind { - CurveKind::Checkout - if (to_row - current_row).abs() > row_height => - { - let start_curve = - point(current_column, current_row + row_height); - builder.line_to(start_curve); - builder.move_to(start_curve); - } - CurveKind::Merge - if (to_column - current_column).abs() > LANE_WIDTH => - { - let column_shift = - if going_right { LANE_WIDTH } else { -LANE_WIDTH }; - let start_curve = point( - current_column + column_shift, - current_row - COMMIT_CIRCLE_RADIUS, - ); + if (to_column - current_column).abs() > LANE_WIDTH { + let column_shift = if going_right { + LANE_WIDTH + } else { + -LANE_WIDTH + }; + let start_curve = point( + current_column + column_shift, + current_row - COMMIT_CIRCLE_RADIUS, + ); + builder.line_to(start_curve); + builder.move_to(start_curve); + } - builder.line_to(start_curve); - builder.move_to(start_curve); + let control = point(to_column, current_row); + builder.curve_to(point(to_column, to_row), control); } - _ => {} - }; - - builder.curve_to(point(to_column, to_row), control); + } current_row = to_row; current_column = to_column; builder.move_to(point(current_column, current_row)); @@ -1976,7 +2334,7 @@ impl GitGraph { cx: &mut Context, ) { if let Some(row) = self.row_at_position(event.position().y, cx) { - self.select_entry(row, cx); + self.select_entry(row, ScrollStrategy::Nearest, cx); if event.click_count() >= 2 { self.open_commit_view(row, window, cx); } @@ -2067,6 +2425,12 @@ impl GitGraph { impl Render for GitGraph { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + // This happens when we changed branches, we should refresh our search as well + if let QueryState::Pending(query) = &mut self.search_state.state { + let query = std::mem::take(query); + self.search_state.state = QueryState::Empty; + self.search(query, cx); + } let description_width_fraction = 0.72; let date_width_fraction = 0.12; let author_width_fraction = 0.10; @@ -2229,7 +2593,7 @@ impl Render for GitGraph { .on_click(move |event, window, cx| { let click_count = event.click_count(); weak.update(cx, |this, cx| { - this.select_entry(index, cx); + this.select_entry(index, ScrollStrategy::Center, cx); if click_count >= 2 { this.open_commit_view(index, window, cx); } @@ -2275,7 +2639,23 @@ impl Render for GitGraph { .on_action(cx.listener(Self::select_next)) .on_action(cx.listener(Self::select_last)) .on_action(cx.listener(Self::confirm)) - .child(content) + .on_action(cx.listener(|this, _: &SelectNextMatch, _window, cx| { + this.select_next_match(cx); + })) + .on_action(cx.listener(|this, _: &SelectPreviousMatch, _window, cx| { + this.select_previous_match(cx); + })) + .on_action(cx.listener(|this, _: &ToggleCaseSensitive, _window, cx| { + this.search_state.case_sensitive = !this.search_state.case_sensitive; + this.search_state.state.next_state(); + cx.notify(); + })) + .child( + v_flex() + .size_full() + .child(self.render_search_bar(cx)) + .child(div().flex_1().child(content)), + ) .children(self.context_menu.as_ref().map(|(menu, position, _)| { deferred( anchored() @@ -2489,7 +2869,7 @@ mod tests { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); }); } diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml index 464a71489e2a0ed9118ca672299c9b7310855668..d95e25fbc7821d42fac4386b522c4effb9462715 100644 --- a/crates/git_ui/Cargo.toml +++ b/crates/git_ui/Cargo.toml @@ -21,7 +21,6 @@ anyhow.workspace = true askpass.workspace = true buffer_diff.workspace = true call.workspace = true -cloud_llm_client.workspace = true collections.workspace = true component.workspace = true db.workspace = true @@ -57,6 +56,7 @@ smol.workspace = true strum.workspace = true telemetry.workspace = true theme.workspace = true +theme_settings.workspace = true time.workspace = true time_format.workspace = true ui.workspace = true diff --git a/crates/git_ui/src/blame_ui.rs b/crates/git_ui/src/blame_ui.rs index c2d7333484224bbfbc248e25fb2ac51a19f428e2..47d781c4870ade9688b93b75db5a68dd26865ca8 100644 --- a/crates/git_ui/src/blame_ui.rs +++ b/crates/git_ui/src/blame_ui.rs @@ -11,7 +11,7 @@ use gpui::{ use markdown::{Markdown, MarkdownElement}; use project::{git_store::Repository, project_settings::ProjectSettings}; use settings::Settings as _; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use time::OffsetDateTime; use ui::{ContextMenu, CopyButton, Divider, prelude::*, tooltip_container}; use workspace::Workspace; diff --git a/crates/git_ui/src/branch_picker.rs b/crates/git_ui/src/branch_picker.rs index cfb7b6bd2a2fb6c57f17244e0e57a4a637866418..438df6839949d46d3ba8e0509995beb1300b7c80 100644 --- a/crates/git_ui/src/branch_picker.rs +++ b/crates/git_ui/src/branch_picker.rs @@ -486,6 +486,10 @@ impl BranchListDelegate { let is_remote; let result = match &entry { Entry::Branch { branch, .. } => { + if branch.is_head { + return Ok(()); + } + is_remote = branch.is_remote(); repo.update(cx, |repo, _| { repo.delete_branch(is_remote, branch.name().to_string()) @@ -559,8 +563,7 @@ impl PickerDelegate for BranchListDelegate { match self.state { PickerState::List | PickerState::NewRemote | PickerState::NewBranch => { match self.branch_filter { - BranchFilter::All => "Select branch or remote…", - BranchFilter::Remote => "Select remote…", + BranchFilter::All | BranchFilter::Remote => "Select branch…", } } PickerState::CreateRemote(_) => "Enter a name for this remote…", @@ -884,13 +887,13 @@ impl PickerDelegate for BranchListDelegate { let entry_icon = match entry { Entry::NewUrl { .. } | Entry::NewBranch { .. } | Entry::NewRemoteName { .. } => { - Icon::new(IconName::Plus).color(Color::Muted) + IconName::Plus } Entry::Branch { branch, .. } => { if branch.is_remote() { - Icon::new(IconName::Screen).color(Color::Muted) + IconName::Screen } else { - Icon::new(IconName::GitBranchAlt).color(Color::Muted) + IconName::GitBranchAlt } } }; @@ -922,8 +925,11 @@ impl PickerDelegate for BranchListDelegate { Entry::NewUrl { .. } | Entry::NewBranch { .. } | Entry::NewRemoteName { .. } ); - let deleted_branch_icon = |entry_ix: usize, is_head_branch: bool| { + let is_head_branch = entry.as_branch().is_some_and(|branch| branch.is_head); + + let deleted_branch_icon = |entry_ix: usize| { IconButton::new(("delete", entry_ix), IconName::Trash) + .icon_size(IconSize::Small) .tooltip(move |_, cx| { Tooltip::for_action_in( "Delete Branch", @@ -932,7 +938,6 @@ impl PickerDelegate for BranchListDelegate { cx, ) }) - .disabled(is_head_branch) .on_click(cx.listener(move |this, _, window, cx| { this.delegate.delete_at(entry_ix, window, cx); })) @@ -943,6 +948,7 @@ impl PickerDelegate for BranchListDelegate { let focus_handle = self.focus_handle.clone(); IconButton::new("create_from_default", IconName::GitBranchPlus) + .icon_size(IconSize::Small) .tooltip(move |_, cx| { Tooltip::for_action_in( tooltip_label.clone(), @@ -965,105 +971,132 @@ impl PickerDelegate for BranchListDelegate { .child( h_flex() .w_full() - .gap_3() + .gap_2p5() .flex_grow() - .child(entry_icon) + .child( + Icon::new(entry_icon) + .color(Color::Muted) + .size(IconSize::Small), + ) .child( v_flex() .id("info_container") .w_full() .child(entry_title) - .child( - h_flex() - .w_full() - .justify_between() - .gap_1p5() - .when(self.style == BranchListStyle::Modal, |el| { - el.child(div().max_w_96().child({ - let message = match entry { - 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| { - repo.read(cx) - .branch - .as_ref() - .map(|b| b.name()) - }) - { - format!("Based off {}", current_branch) - } else { - "Based off the current branch" - .to_string() - } - } - Entry::Branch { .. } => { - let show_author_name = - ProjectSettings::get_global(cx) - .git - .branch_picker - .show_author_name; - - subject.map_or( - "No commits found".into(), - |subject| { - if show_author_name - && let Some(author) = - author_name - { - format!( - "{} • {}", - author, subject - ) - } else { - subject.to_string() - } - }, - ) - } - }; - - Label::new(message) - .size(LabelSize::Small) - .color(Color::Muted) - .truncate() - })) - }) - .when_some(commit_time, |label, commit_time| { - label.child( - Label::new(commit_time) - .size(LabelSize::Small) - .color(Color::Muted), - ) - }), - ) + .child({ + let message = match entry { + 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| { + repo.read(cx).branch.as_ref().map(|b| b.name()) + }) + { + format!("Based off {}", current_branch) + } else { + "Based off the current branch".to_string() + } + } + Entry::Branch { .. } => String::new(), + }; + + if matches!(entry, Entry::Branch { .. }) { + let show_author_name = ProjectSettings::get_global(cx) + .git + .branch_picker + .show_author_name; + let has_author = show_author_name && author_name.is_some(); + let has_commit = commit_time.is_some(); + let author_for_meta = + if show_author_name { author_name } else { None }; + + let dot = || { + Label::new("•") + .alpha(0.5) + .color(Color::Muted) + .size(LabelSize::Small) + }; + + h_flex() + .w_full() + .min_w_0() + .gap_1p5() + .when_some(author_for_meta, |this, author| { + this.child( + Label::new(author) + .color(Color::Muted) + .size(LabelSize::Small), + ) + }) + .when_some(commit_time, |this, time| { + this.when(has_author, |this| this.child(dot())) + .child( + Label::new(time) + .color(Color::Muted) + .size(LabelSize::Small), + ) + }) + .when_some(subject, |this, subj| { + this.when(has_commit, |this| this.child(dot())) + .child( + Label::new(subj.to_string()) + .color(Color::Muted) + .size(LabelSize::Small) + .truncate() + .flex_1(), + ) + }) + .when(!has_commit, |this| { + this.child( + Label::new("No commits found") + .color(Color::Muted) + .size(LabelSize::Small), + ) + }) + .into_any_element() + } else { + Label::new(message) + .size(LabelSize::Small) + .color(Color::Muted) + .truncate() + .into_any_element() + } + }) .when_some( entry.as_branch().map(|b| b.name().to_string()), - |this, branch_name| this.tooltip(Tooltip::text(branch_name)), + |this, branch_name| { + this.map(|this| { + if is_head_branch { + this.tooltip(move |_, cx| { + Tooltip::with_meta( + branch_name.clone(), + None, + "Current Branch", + cx, + ) + }) + } else { + this.tooltip(Tooltip::text(branch_name)) + } + }) + }, ), ), ) - .when( - 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(deleted_branch_icon(ix, is_head_branch)) - } else { - this.end_hover_slot(deleted_branch_icon(ix, is_head_branch)) - } - }) - }, - ) + .when(!is_new_items && !is_head_branch, |this| { + this.map(|this| { + if self.selected_index() == ix { + this.end_slot(deleted_branch_icon(ix)) + } else { + this.end_hover_slot(deleted_branch_icon(ix)) + } + }) + }) .when_some( - if self.editor_position() == PickerEditorPosition::End && is_new_items { + if is_new_items { create_from_default_button } else { None @@ -1122,20 +1155,29 @@ impl PickerDelegate for BranchListDelegate { let delete_and_select_btns = h_flex() .gap_1() - .child( - Button::new("delete-branch", "Delete") - .key_binding( - KeyBinding::for_action_in( - &branch_picker::DeleteBranch, - &focus_handle, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))), + .when( + !selected_entry + .and_then(|entry| entry.as_branch()) + .is_some_and(|branch| branch.is_head), + |this| { + this.child( + Button::new("delete-branch", "Delete") + .key_binding( + KeyBinding::for_action_in( + &branch_picker::DeleteBranch, + &focus_handle, + cx, + ) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(|_, window, cx| { + window.dispatch_action( + branch_picker::DeleteBranch.boxed_clone(), + cx, + ); + }), ) - .on_click(|_, window, cx| { - window - .dispatch_action(branch_picker::DeleteBranch.boxed_clone(), cx); - }), + }, ) .child( Button::new("select_branch", "Select") @@ -1283,7 +1325,7 @@ mod tests { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); }); } diff --git a/crates/git_ui/src/commit_tooltip.rs b/crates/git_ui/src/commit_tooltip.rs index 4740e148099980a7510a1f551d0d3f51c08892a1..b22fcee7e2de5273983b6959f8c52511b877eeaf 100644 --- a/crates/git_ui/src/commit_tooltip.rs +++ b/crates/git_ui/src/commit_tooltip.rs @@ -12,7 +12,7 @@ use markdown::{Markdown, MarkdownElement}; use project::git_store::Repository; use settings::Settings; use std::hash::Hash; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use time::{OffsetDateTime, UtcOffset}; use ui::{Avatar, CopyButton, Divider, prelude::*, tooltip_container}; use workspace::Workspace; diff --git a/crates/git_ui/src/commit_view.rs b/crates/git_ui/src/commit_view.rs index b7f7b526ca16ed6686965f82180d0dcbb63f994a..757ec1e0ebb92431e110e20f0833e2fcd0a88177 100644 --- a/crates/git_ui/src/commit_view.rs +++ b/crates/git_ui/src/commit_view.rs @@ -414,38 +414,7 @@ impl CommitView { } fn calculate_changed_lines(&self, cx: &App) -> (u32, u32) { - let snapshot = self.multibuffer.read(cx).snapshot(cx); - let mut total_additions = 0u32; - let mut total_deletions = 0u32; - - let mut seen_buffers = std::collections::HashSet::new(); - for (_, buffer, _) in snapshot.excerpts() { - let buffer_id = buffer.remote_id(); - if !seen_buffers.insert(buffer_id) { - continue; - } - - let Some(diff) = snapshot.diff_for_buffer_id(buffer_id) else { - continue; - }; - - let base_text = diff.base_text(); - - for hunk in diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, buffer) { - let added_rows = hunk.range.end.row.saturating_sub(hunk.range.start.row); - total_additions += added_rows; - - let base_start = base_text - .offset_to_point(hunk.diff_base_byte_range.start) - .row; - let base_end = base_text.offset_to_point(hunk.diff_base_byte_range.end).row; - let deleted_rows = base_end.saturating_sub(base_start); - - total_deletions += deleted_rows; - } - } - - (total_additions, total_deletions) + self.multibuffer.read(cx).snapshot(cx).total_changed_lines() } fn render_header(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { diff --git a/crates/git_ui/src/file_diff_view.rs b/crates/git_ui/src/file_diff_view.rs index bdd5dee36e2d54888d081cfefed21602ecb8fa1b..6fe3d9484b4b6aca72f39ab5672e24e1430114ec 100644 --- a/crates/git_ui/src/file_diff_view.rs +++ b/crates/git_ui/src/file_diff_view.rs @@ -379,7 +379,7 @@ mod tests { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); }); } diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 1fc4813157f8e64a4e51cc570b906b3a2d456002..d8ef930cb2509b0e92b7fe8f90c4cbaf4121132c 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -2,6 +2,7 @@ use crate::askpass_modal::AskPassModal; use crate::commit_modal::CommitModal; use crate::commit_tooltip::CommitTooltip; use crate::commit_view::CommitView; +use crate::git_panel_settings::GitPanelScrollbarAccessor; use crate::project_diff::{self, BranchDiff, Diff, ProjectDiff}; use crate::remote_output::{self, RemoteAction, SuccessMessage}; use crate::{branch_picker, picker_prompt, render_remote_button}; @@ -12,7 +13,6 @@ use crate::{ use agent_settings::AgentSettings; use anyhow::Context as _; use askpass::AskPassDelegate; -use cloud_llm_client::CompletionIntent; use collections::{BTreeMap, HashMap, HashSet}; use db::kvp::KeyValueStore; use editor::{ @@ -20,6 +20,7 @@ use editor::{ actions::ExpandAllDiffHunks, }; use editor::{EditorStyle, RewrapOptions}; +use feature_flags::{FeatureFlagAppExt as _, GitGraphFeatureFlag}; use file_icons::FileIcons; use futures::StreamExt as _; use git::commit::ParsedCommitMessage; @@ -44,7 +45,8 @@ use gpui::{ use itertools::Itertools; use language::{Buffer, File}; use language_model::{ - ConfiguredModel, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, Role, + CompletionIntent, ConfiguredModel, LanguageModelRegistry, LanguageModelRequest, + LanguageModelRequestMessage, Role, }; use menu; use multi_buffer::ExcerptInfo; @@ -65,7 +67,7 @@ use std::ops::Range; use std::path::Path; use std::{sync::Arc, time::Duration, usize}; use strum::{IntoEnumIterator, VariantNames}; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use time::OffsetDateTime; use ui::{ ButtonLike, Checkbox, CommonAnimationExt, ContextMenu, ElevationIndex, IndentGuideColors, @@ -258,7 +260,6 @@ pub enum Event { #[derive(Serialize, Deserialize)] struct SerializedGitPanel { - width: Option, #[serde(default)] amend_pending: bool, #[serde(default)] @@ -645,7 +646,6 @@ pub struct GitPanel { tracked_count: usize, tracked_staged_count: usize, update_visible_entries_task: Task<()>, - width: Option, pub(crate) workspace: WeakEntity, context_menu: Option<(Entity, Point, Subscription)>, modal_open: bool, @@ -832,7 +832,6 @@ impl GitPanel { tracked_count: 0, tracked_staged_count: 0, update_visible_entries_task: Task::ready(()), - width: None, show_placeholders: false, local_committer: None, local_committer_task: None, @@ -925,7 +924,6 @@ impl GitPanel { } fn serialize(&mut self, cx: &mut Context) { - let width = self.width; let amend_pending = self.amend_pending; let signoff_enabled = self.signoff_enabled; let kvp = KeyValueStore::global(cx); @@ -952,7 +950,6 @@ impl GitPanel { kvp.write_kvp( serialization_key, serde_json::to_string(&SerializedGitPanel { - width, amend_pending, signoff_enabled, })?, @@ -975,16 +972,11 @@ impl GitPanel { let mut dispatch_context = KeyContext::new_with_defaults(); dispatch_context.add("GitPanel"); - if window - .focused(cx) - .is_some_and(|focused| self.focus_handle == focused) - { - dispatch_context.add("menu"); - dispatch_context.add("ChangesList"); - } - if self.commit_editor.read(cx).is_focused(window) { dispatch_context.add("CommitEditor"); + } else if self.focus_handle.contains_focused(window, cx) { + dispatch_context.add("menu"); + dispatch_context.add("ChangesList"); } dispatch_context @@ -4529,7 +4521,7 @@ impl GitPanel { fn render_previous_commit( &self, - window: &mut Window, + _window: &mut Window, cx: &mut Context, ) -> Option { let active_repository = self.active_repository.as_ref()?; @@ -4537,6 +4529,7 @@ impl GitPanel { let commit = branch.most_recent_commit.as_ref()?.clone(); let workspace = self.workspace.clone(); let this = cx.entity(); + let can_open_git_graph = cx.has_flag::(); Some( h_flex() @@ -4614,7 +4607,7 @@ impl GitPanel { ), ) }) - .when(window.is_action_available(&Open, cx), |this| { + .when(can_open_git_graph, |this| { this.child( panel_icon_button("git-graph-button", IconName::GitGraph) .icon_size(IconSize::Small) @@ -4893,7 +4886,7 @@ impl GitPanel { }), ) .custom_scrollbars( - Scrollbars::for_settings::() + Scrollbars::for_settings::() .tracked_scroll_handle(&self.scroll_handle) .with_track_along( ScrollAxes::Horizontal, @@ -5158,7 +5151,9 @@ impl GitPanel { }), ) }) - .child(git_status_icon(status)) + .when(status_style != StatusStyle::LabelColor, |el| { + el.child(git_status_icon(status)) + }) .map(|this| { if tree_view { this.pl(px(depth as f32 * TREE_INDENT)).child( @@ -5567,7 +5562,6 @@ impl GitPanel { if let Some(serialized_panel) = serialized_panel { panel.update(cx, |panel, cx| { - panel.width = serialized_panel.width; panel.amend_pending = serialized_panel.amend_pending; panel.signoff_enabled = serialized_panel.signoff_enabled; cx.notify(); @@ -5796,15 +5790,8 @@ impl Panel for GitPanel { }); } - fn size(&self, _: &Window, cx: &App) -> Pixels { - self.width - .unwrap_or_else(|| GitPanelSettings::get_global(cx).default_width) - } - - fn set_size(&mut self, size: Option, _: &mut Window, cx: &mut Context) { - self.width = size; - self.serialize(cx); - cx.notify(); + fn default_size(&self, _: &Window, cx: &App) -> Pixels { + GitPanelSettings::get_global(cx).default_width } fn icon(&self, _: &Window, cx: &App) -> Option { @@ -5832,7 +5819,7 @@ impl Panel for GitPanel { } fn activation_priority(&self) -> u32 { - 2 + 3 } } @@ -6486,7 +6473,7 @@ mod tests { repository::repo_path, status::{StatusCode, UnmergedStatus, UnmergedStatusCode}, }; - use gpui::{TestAppContext, UpdateGlobal, VisualTestContext}; + use gpui::{TestAppContext, UpdateGlobal, VisualTestContext, px}; use indoc::indoc; use project::FakeFs; use serde_json::json; @@ -6505,7 +6492,7 @@ mod tests { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(LoadThemes::JustBase, cx); + theme_settings::init(LoadThemes::JustBase, cx); editor::init(cx); crate::init(cx); }); @@ -7872,4 +7859,133 @@ mod tests { let message = panel.update(cx, |panel, cx| panel.suggest_commit_message(cx)); assert_eq!(message, Some("Update tracked".to_string())); } + + #[gpui::test] + async fn test_dispatch_context_with_focus_states(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "tracked": "tracked\n", + }), + ) + .await; + + fs.set_head_and_index_for_repo( + path!("/project/.git").as_ref(), + &[("tracked", "old tracked\n".into())], + ); + + let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await; + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window_handle + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window_handle.into(), cx); + let panel = workspace.update_in(cx, GitPanel::new); + + 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; + + // Case 1: Focus the commit editor — should have "CommitEditor" but NOT "menu"/"ChangesList" + panel.update_in(cx, |panel, window, cx| { + panel.focus_editor(&FocusEditor, window, cx); + let editor_is_focused = panel.commit_editor.read(cx).is_focused(window); + assert!( + editor_is_focused, + "commit editor should be focused after focus_editor action" + ); + let context = panel.dispatch_context(window, cx); + assert!( + context.contains("GitPanel"), + "should always have GitPanel context" + ); + assert!( + context.contains("CommitEditor"), + "should have CommitEditor context when commit editor is focused" + ); + assert!( + !context.contains("menu"), + "should not have menu context when commit editor is focused" + ); + assert!( + !context.contains("ChangesList"), + "should not have ChangesList context when commit editor is focused" + ); + }); + + // Case 2: Focus the panel's focus handle directly — should have "menu" and "ChangesList". + // We force a draw via simulate_resize to ensure the dispatch tree is populated, + // since contains_focused() depends on the rendered dispatch tree. + panel.update_in(cx, |panel, window, cx| { + panel.focus_handle.focus(window, cx); + }); + cx.simulate_resize(gpui::size(px(800.), px(600.))); + + panel.update_in(cx, |panel, window, cx| { + let context = panel.dispatch_context(window, cx); + assert!( + context.contains("GitPanel"), + "should always have GitPanel context" + ); + assert!( + context.contains("menu"), + "should have menu context when changes list is focused" + ); + assert!( + context.contains("ChangesList"), + "should have ChangesList context when changes list is focused" + ); + assert!( + !context.contains("CommitEditor"), + "should not have CommitEditor context when changes list is focused" + ); + }); + + // Case 3: Switch back to commit editor and verify context switches correctly + panel.update_in(cx, |panel, window, cx| { + panel.focus_editor(&FocusEditor, window, cx); + }); + + panel.update_in(cx, |panel, window, cx| { + let context = panel.dispatch_context(window, cx); + assert!( + context.contains("CommitEditor"), + "should have CommitEditor after switching focus back to editor" + ); + assert!( + !context.contains("menu"), + "should not have menu after switching focus back to editor" + ); + }); + + // Case 4: Re-focus changes list and verify it transitions back correctly + panel.update_in(cx, |panel, window, cx| { + panel.focus_handle.focus(window, cx); + }); + cx.simulate_resize(gpui::size(px(800.), px(600.))); + + panel.update_in(cx, |panel, window, cx| { + assert!( + panel.focus_handle.contains_focused(window, cx), + "panel focus handle should report contains_focused when directly focused" + ); + let context = panel.dispatch_context(window, cx); + assert!( + context.contains("menu"), + "should have menu context after re-focusing changes list" + ); + assert!( + context.contains("ChangesList"), + "should have ChangesList context after re-focusing changes list" + ); + }); + } } diff --git a/crates/git_ui/src/git_panel_settings.rs b/crates/git_ui/src/git_panel_settings.rs index 16a1113b60a53544834f1484a2fa7e6fcbea9aca..9ccbced249db6707f1b5bca609f5dddea47bbd6a 100644 --- a/crates/git_ui/src/git_panel_settings.rs +++ b/crates/git_ui/src/git_panel_settings.rs @@ -1,4 +1,4 @@ -use editor::EditorSettings; +use editor::{EditorSettings, ui_scrollbar_settings_from_raw}; use gpui::Pixels; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -32,7 +32,10 @@ pub struct GitPanelSettings { pub starts_open: bool, } -impl ScrollbarVisibility for GitPanelSettings { +#[derive(Default)] +pub(crate) struct GitPanelScrollbarAccessor; + +impl ScrollbarVisibility for GitPanelScrollbarAccessor { fn visibility(&self, cx: &ui::App) -> ShowScrollbar { // TODO: This PR should have defined Editor's `scrollbar.axis` // as an Option, not a ScrollbarAxes as it would allow you to @@ -42,7 +45,8 @@ impl ScrollbarVisibility for GitPanelSettings { // so we can show each axis based on the settings. // // We should fix this. PR: https://github.com/zed-industries/zed/pull/19495 - self.scrollbar + GitPanelSettings::get_global(cx) + .scrollbar .show .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show) } @@ -59,7 +63,11 @@ impl Settings for GitPanelSettings { file_icons: git_panel.file_icons.unwrap(), folder_icons: git_panel.folder_icons.unwrap(), scrollbar: ScrollbarSettings { - show: git_panel.scrollbar.unwrap().show.map(Into::into), + show: git_panel + .scrollbar + .unwrap() + .show + .map(ui_scrollbar_settings_from_raw), }, fallback_branch_name: git_panel.fallback_branch_name.unwrap(), sort_by_path: git_panel.sort_by_path.unwrap(), diff --git a/crates/git_ui/src/project_diff.rs b/crates/git_ui/src/project_diff.rs index 6d8b91cc54cc4baeb4fdda594404e04181fe6cf4..ae27b6e51fcb8f72b86f819a1aa4ac05c17c6e5f 100644 --- a/crates/git_ui/src/project_diff.rs +++ b/crates/git_ui/src/project_diff.rs @@ -544,38 +544,7 @@ impl ProjectDiff { } pub fn calculate_changed_lines(&self, cx: &App) -> (u32, u32) { - let snapshot = self.multibuffer.read(cx).snapshot(cx); - let mut total_additions = 0u32; - let mut total_deletions = 0u32; - - let mut seen_buffers = HashSet::default(); - for (_, buffer, _) in snapshot.excerpts() { - let buffer_id = buffer.remote_id(); - if !seen_buffers.insert(buffer_id) { - continue; - } - - let Some(diff) = snapshot.diff_for_buffer_id(buffer_id) else { - continue; - }; - - let base_text = diff.base_text(); - - for hunk in diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, buffer) { - let added_rows = hunk.range.end.row.saturating_sub(hunk.range.start.row); - total_additions += added_rows; - - let base_start = base_text - .offset_to_point(hunk.diff_base_byte_range.start) - .row; - let base_end = base_text.offset_to_point(hunk.diff_base_byte_range.end).row; - let deleted_rows = base_end.saturating_sub(base_start); - - total_deletions += deleted_rows; - } - } - - (total_additions, total_deletions) + self.multibuffer.read(cx).snapshot(cx).total_changed_lines() } /// Returns the total count of review comments across all hunks/files. @@ -1777,7 +1746,7 @@ mod tests { settings.editor.diff_view_style = Some(DiffViewStyle::Unified); }); }); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); crate::init(cx); }); diff --git a/crates/git_ui/src/stash_picker.rs b/crates/git_ui/src/stash_picker.rs index e736dd806a35703991e1fb51e27e3952e5692d99..9987190f45b73f3f1132ce1295de6f412022abe2 100644 --- a/crates/git_ui/src/stash_picker.rs +++ b/crates/git_ui/src/stash_picker.rs @@ -468,7 +468,7 @@ impl PickerDelegate for StashListDelegate { ix: usize, selected: bool, _window: &mut Window, - _cx: &mut Context>, + cx: &mut Context>, ) -> Option { let entry_match = &self.matches[ix]; @@ -501,16 +501,46 @@ impl PickerDelegate for StashListDelegate { .size(LabelSize::Small), ); + let focus_handle = self.focus_handle.clone(); + + let drop_button = |entry_ix: usize| { + IconButton::new(("drop-stash", entry_ix), IconName::Trash) + .icon_size(IconSize::Small) + .tooltip(move |_, cx| { + Tooltip::for_action_in("Drop Stash", &DropStashItem, &focus_handle, cx) + }) + .on_click(cx.listener(move |this, _, window, cx| { + this.delegate.drop_stash_at(entry_ix, window, cx); + })) + }; + Some( ListItem::new(format!("stash-{ix}")) .inset(true) .spacing(ListItemSpacing::Sparse) .toggle_state(selected) - .child(v_flex().w_full().child(stash_label).child(branch_info)) + .child( + h_flex() + .w_full() + .gap_2p5() + .child( + Icon::new(IconName::BoxOpen) + .size(IconSize::Small) + .color(Color::Muted), + ) + .child(div().w_full().child(stash_label).child(branch_info)), + ) .tooltip(Tooltip::text(format!( "stash@{{{}}}", entry_match.entry.index - ))), + ))) + .map(|this| { + if selected { + this.end_slot(drop_button(ix)) + } else { + this.end_hover_slot(drop_button(ix)) + } + }), ) } @@ -602,7 +632,7 @@ mod tests { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); }) } diff --git a/crates/git_ui/src/text_diff_view.rs b/crates/git_ui/src/text_diff_view.rs index 965f41030817d3b7434a6fd02fb3a2de18046823..2dfef13f72681456174737af61380b87caae0ae1 100644 --- a/crates/git_ui/src/text_diff_view.rs +++ b/crates/git_ui/src/text_diff_view.rs @@ -499,7 +499,7 @@ mod tests { settings.editor.diff_view_style = Some(DiffViewStyle::Unified); }); }); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); }); } diff --git a/crates/git_ui/src/worktree_picker.rs b/crates/git_ui/src/worktree_picker.rs index 3806054038151c3dd80a7bb1cfdedf966d4219b5..9486dbc7b6ddb94e59f9da44069d55d8709fbea7 100644 --- a/crates/git_ui/src/worktree_picker.rs +++ b/crates/git_ui/src/worktree_picker.rs @@ -18,9 +18,11 @@ use remote::{RemoteConnectionOptions, remote_client::ConnectionIdentifier}; use remote_connection::{RemoteConnectionModal, connect}; use settings::Settings; use std::{path::PathBuf, sync::Arc}; -use ui::{HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, prelude::*}; -use util::ResultExt; -use workspace::{ModalView, MultiWorkspace, Workspace, notifications::DetachAndPromptErr}; +use ui::{HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*}; +use util::{ResultExt, debug_panic}; +use workspace::{ + ModalView, MultiWorkspace, OpenMode, Workspace, notifications::DetachAndPromptErr, +}; use crate::git_panel::show_error_toast; @@ -115,6 +117,7 @@ impl WorktreeList { this.picker.update(cx, |picker, cx| { picker.delegate.all_worktrees = Some(all_worktrees); picker.delegate.default_branch = default_branch; + picker.delegate.refresh_forbidden_deletion_path(cx); picker.refresh(window, cx); }) })?; @@ -261,6 +264,7 @@ pub struct WorktreeListDelegate { modifiers: Modifiers, focus_handle: FocusHandle, default_branch: Option, + forbidden_deletion_path: Option, } impl WorktreeListDelegate { @@ -280,6 +284,7 @@ impl WorktreeListDelegate { modifiers: Default::default(), focus_handle: cx.focus_handle(), default_branch: None, + forbidden_deletion_path: None, } } @@ -351,7 +356,7 @@ impl WorktreeListDelegate { workspace .update_in(cx, |workspace, window, cx| { workspace.open_workspace_for_paths( - replace_current_window, + OpenMode::Replace, vec![new_worktree_path], window, cx, @@ -404,10 +409,15 @@ impl WorktreeListDelegate { else { return; }; + let open_mode = if replace_current_window { + OpenMode::Replace + } else { + OpenMode::NewWindow + }; if is_local { let open_task = workspace.update(cx, |workspace, cx| { - workspace.open_workspace_for_paths(replace_current_window, vec![path], window, cx) + workspace.open_workspace_for_paths(open_mode, vec![path], window, cx) }); cx.spawn(async move |_, _| { open_task?.await?; @@ -452,7 +462,7 @@ impl WorktreeListDelegate { let Some(entry) = self.matches.get(idx).cloned() else { return; }; - if entry.is_new { + if entry.is_new || self.forbidden_deletion_path.as_ref() == Some(&entry.worktree.path) { return; } let Some(repo) = self.repo.clone() else { @@ -486,6 +496,7 @@ impl WorktreeListDelegate { if let Some(all_worktrees) = &mut picker.delegate.all_worktrees { all_worktrees.retain(|w| w.path != path); } + picker.delegate.refresh_forbidden_deletion_path(cx); if picker.delegate.matches.is_empty() { picker.delegate.selected_index = 0; } else if picker.delegate.selected_index >= picker.delegate.matches.len() { @@ -498,6 +509,29 @@ impl WorktreeListDelegate { }) .detach(); } + + fn refresh_forbidden_deletion_path(&mut self, cx: &App) { + let Some(workspace) = self.workspace.upgrade() else { + debug_panic!("Workspace should always be available or else the picker would be closed"); + self.forbidden_deletion_path = None; + return; + }; + + let visible_worktree_paths = workspace.read_with(cx, |workspace, cx| { + workspace + .project() + .read(cx) + .visible_worktrees(cx) + .map(|worktree| worktree.read(cx).abs_path().to_path_buf()) + .collect::>() + }); + + self.forbidden_deletion_path = if visible_worktree_paths.len() == 1 { + visible_worktree_paths.into_iter().next() + } else { + None + }; + } } async fn open_remote_worktree( @@ -769,38 +803,86 @@ impl PickerDelegate for WorktreeListDelegate { ) }; + let focus_handle = self.focus_handle.clone(); + + let can_delete = + !entry.is_new && self.forbidden_deletion_path.as_ref() != Some(&entry.worktree.path); + + let delete_button = |entry_ix: usize| { + IconButton::new(("delete-worktree", entry_ix), IconName::Trash) + .icon_size(IconSize::Small) + .tooltip(move |_, cx| { + Tooltip::for_action_in("Delete Worktree", &DeleteWorktree, &focus_handle, cx) + }) + .on_click(cx.listener(move |this, _, window, cx| { + this.delegate.delete_at(entry_ix, window, cx); + })) + }; + + let entry_icon = if entry.is_new { + IconName::Plus + } else { + IconName::GitWorktree + }; + Some( ListItem::new(format!("worktree-menu-{ix}")) .inset(true) .spacing(ListItemSpacing::Sparse) .toggle_state(selected) .child( - v_flex() + h_flex() .w_full() + .gap_2p5() .child( - h_flex() - .gap_2() - .justify_between() - .overflow_x_hidden() - .child(branch_name) - .when(!entry.is_new, |this| { - this.child( - Label::new(sha) - .size(LabelSize::Small) - .color(Color::Muted) - .buffer_font(cx) - .into_element(), - ) - }), - ) - .child( - Label::new(sublabel) - .size(LabelSize::Small) + Icon::new(entry_icon) .color(Color::Muted) - .truncate() - .into_any_element(), - ), - ), + .size(IconSize::Small), + ) + .child(v_flex().w_full().child(branch_name).map(|this| { + if entry.is_new { + this.child( + Label::new(sublabel) + .size(LabelSize::Small) + .color(Color::Muted) + .truncate(), + ) + } else { + this.child( + h_flex() + .w_full() + .min_w_0() + .gap_1p5() + .child( + Label::new(sha) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child( + Label::new("•") + .alpha(0.5) + .color(Color::Muted) + .size(LabelSize::Small), + ) + .child( + Label::new(sublabel) + .truncate() + .color(Color::Muted) + .size(LabelSize::Small) + .flex_1(), + ) + .into_any_element(), + ) + } + })), + ) + .when(can_delete, |this| { + if selected { + this.end_slot(delete_button(ix)) + } else { + this.end_hover_slot(delete_button(ix)) + } + }), ) } @@ -812,6 +894,9 @@ impl PickerDelegate for WorktreeListDelegate { let focus_handle = self.focus_handle.clone(); let selected_entry = self.matches.get(self.selected_index); let is_creating = selected_entry.is_some_and(|entry| entry.is_new); + let can_delete = selected_entry.is_some_and(|entry| { + !entry.is_new && self.forbidden_deletion_path.as_ref() != Some(&entry.worktree.path) + }); let footer_container = h_flex() .w_full() @@ -859,18 +944,34 @@ impl PickerDelegate for WorktreeListDelegate { } else { Some( footer_container + .when(can_delete, |this| { + this.child( + Button::new("delete-worktree", "Delete") + .key_binding( + KeyBinding::for_action_in(&DeleteWorktree, &focus_handle, cx) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(|_, window, cx| { + window.dispatch_action(DeleteWorktree.boxed_clone(), cx) + }), + ) + }) .child( - Button::new("delete-worktree", "Delete") + Button::new("open-in-new-window", "Open in New Window") .key_binding( - KeyBinding::for_action_in(&DeleteWorktree, &focus_handle, cx) - .map(|kb| kb.size(rems_from_px(12.))), + KeyBinding::for_action_in( + &menu::SecondaryConfirm, + &focus_handle, + cx, + ) + .map(|kb| kb.size(rems_from_px(12.))), ) .on_click(|_, window, cx| { - window.dispatch_action(DeleteWorktree.boxed_clone(), cx) + window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx) }), ) .child( - Button::new("open-in-new-window", "Open in New Window") + Button::new("open-in-window", "Open") .key_binding( KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx) .map(|kb| kb.size(rems_from_px(12.))), @@ -879,20 +980,6 @@ impl PickerDelegate for WorktreeListDelegate { window.dispatch_action(menu::Confirm.boxed_clone(), cx) }), ) - .child( - Button::new("open-in-window", "Open") - .key_binding( - KeyBinding::for_action_in( - &menu::SecondaryConfirm, - &focus_handle, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click(|_, window, cx| { - window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx) - }), - ) .into_any(), ) } diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index b3812bb7cb5747ff40bd6d05a39b9ee7bebbdda1..915f0fc03e2cc5beaf40c810654724295c41cde8 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -70,14 +70,17 @@ chrono.workspace = true profiling.workspace = true rand.workspace = true raw-window-handle = "0.6" +regex.workspace = true refineable.workspace = true scheduler.workspace = true resvg = { version = "0.45.0", default-features = false, features = [ "text", "system-fonts", "memmap-fonts", + "raster-images" ] } usvg = { version = "0.45.0", default-features = false } +ttf-parser = "0.25" util_macros.workspace = true schemars.workspace = true seahash = "4.1" @@ -95,7 +98,6 @@ gpui_util.workspace = true waker-fn = "1.2.0" lyon = "1.0" pin-project = "1.1.10" -circular-buffer.workspace = true spin = "0.10.0" pollster.workspace = true url.workspace = true @@ -145,12 +147,12 @@ backtrace.workspace = true collections = { workspace = true, features = ["test-support"] } env_logger.workspace = true gpui_platform = { workspace = true, features = ["font-kit"] } +gpui_util = { workspace = true } lyon = { version = "1.0", features = ["extra"] } +proptest = { workspace = true } rand.workspace = true scheduler = { workspace = true, features = ["test-support"] } -unicode-segmentation.workspace = true -gpui_util = { workspace = true } -proptest = { workspace = true } +unicode-segmentation = { workspace = true } [target.'cfg(not(target_family = "wasm"))'.dev-dependencies] http_client = { workspace = true, features = ["test-support"] } @@ -235,6 +237,10 @@ path = "examples/window_shadow.rs" name = "grid_layout" path = "examples/grid_layout.rs" +[[example]] +name = "list_example" +path = "examples/list_example.rs" + [[example]] name = "mouse_pressure" path = "examples/mouse_pressure.rs" diff --git a/crates/gpui/examples/list_example.rs b/crates/gpui/examples/list_example.rs new file mode 100644 index 0000000000000000000000000000000000000000..7aeff7c24ec3755edf1e37f5ff1cc496c9fb597e --- /dev/null +++ b/crates/gpui/examples/list_example.rs @@ -0,0 +1,170 @@ +#![cfg_attr(target_family = "wasm", no_main)] + +use gpui::{ + App, Bounds, Context, ListAlignment, ListState, Render, Window, WindowBounds, WindowOptions, + div, list, prelude::*, px, rgb, size, +}; +use gpui_platform::application; + +const ITEM_COUNT: usize = 40; +const SCROLLBAR_WIDTH: f32 = 12.; + +struct BottomListDemo { + list_state: ListState, +} + +impl BottomListDemo { + fn new() -> Self { + Self { + list_state: ListState::new(ITEM_COUNT, ListAlignment::Bottom, px(500.)).measure_all(), + } + } +} + +impl Render for BottomListDemo { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + let max_offset = self.list_state.max_offset_for_scrollbar().y; + let current_offset = -self.list_state.scroll_px_offset_for_scrollbar().y; + + let viewport_height = self.list_state.viewport_bounds().size.height; + + let raw_fraction = if max_offset > px(0.) { + current_offset / max_offset + } else { + 0. + }; + + let total_height = viewport_height + max_offset; + let thumb_height = if total_height > px(0.) { + px(viewport_height.as_f32() * viewport_height.as_f32() / total_height.as_f32()) + .max(px(30.)) + } else { + px(30.) + }; + + let track_space = viewport_height - thumb_height; + let thumb_top = track_space * raw_fraction; + + let bug_detected = raw_fraction > 1.0; + + div() + .size_full() + .bg(rgb(0xFFFFFF)) + .flex() + .flex_col() + .p_4() + .gap_2() + .child( + div() + .text_sm() + .flex() + .flex_col() + .gap_1() + .child(format!( + "offset: {:.0} / max: {:.0} | fraction: {:.3}", + current_offset.as_f32(), + max_offset.as_f32(), + raw_fraction, + )) + .child( + div() + .text_color(if bug_detected { + rgb(0xCC0000) + } else { + rgb(0x008800) + }) + .child(if bug_detected { + format!( + "BUG: fraction is {:.3} (> 1.0) — thumb is off-track!", + raw_fraction + ) + } else { + "OK: fraction <= 1.0 — thumb is within track.".to_string() + }), + ), + ) + .child( + div() + .flex_1() + .flex() + .flex_row() + .overflow_hidden() + .border_1() + .border_color(rgb(0xCCCCCC)) + .rounded_sm() + .child( + list(self.list_state.clone(), |index, _window, _cx| { + let height = px(30. + (index % 5) as f32 * 10.); + div() + .h(height) + .w_full() + .flex() + .items_center() + .px_3() + .border_b_1() + .border_color(rgb(0xEEEEEE)) + .bg(if index % 2 == 0 { + rgb(0xFAFAFA) + } else { + rgb(0xFFFFFF) + }) + .text_sm() + .child(format!("Item {index}")) + .into_any() + }) + .flex_1(), + ) + // Scrollbar track + .child( + div() + .w(px(SCROLLBAR_WIDTH)) + .h_full() + .flex_shrink_0() + .bg(rgb(0xE0E0E0)) + .relative() + .child( + // Thumb — position is unclamped to expose the bug + div() + .absolute() + .top(thumb_top) + .w_full() + .h(thumb_height) + .bg(if bug_detected { + rgb(0xCC0000) + } else { + rgb(0x888888) + }) + .rounded_sm(), + ), + ), + ) + } +} + +fn run_example() { + application().run(|cx: &mut App| { + let bounds = Bounds::centered(None, size(px(400.), px(500.)), cx); + cx.open_window( + WindowOptions { + focus: true, + window_bounds: Some(WindowBounds::Windowed(bounds)), + ..Default::default() + }, + |_, cx| cx.new(|_| BottomListDemo::new()), + ) + .unwrap(); + cx.activate(true); + }); +} + +#[cfg(not(target_family = "wasm"))] +fn main() { + run_example(); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + gpui_platform::web_init(); + run_example(); +} diff --git a/crates/gpui/examples/on_window_close_quit.rs b/crates/gpui/examples/on_window_close_quit.rs index e71a142d991c87ccbccb9c078fdb50d1fa3dba49..347401c6d924f146fec539c862878d21c4b18e67 100644 --- a/crates/gpui/examples/on_window_close_quit.rs +++ b/crates/gpui/examples/on_window_close_quit.rs @@ -42,7 +42,7 @@ fn run_example() { let mut bounds = Bounds::centered(None, size(px(500.), px(500.0)), cx); cx.bind_keys([KeyBinding::new("cmd-w", CloseWindow, None)]); - cx.on_window_closed(|cx| { + cx.on_window_closed(|cx, _window_id| { if cx.windows().is_empty() { cx.quit(); } diff --git a/crates/gpui/examples/painting.rs b/crates/gpui/examples/painting.rs index 18ef6b9fa3741297ddfebc1b5df3ea4a3594fc05..11c3b333717c6b816cdf2f7d5170ceae0cfd1b1f 100644 --- a/crates/gpui/examples/painting.rs +++ b/crates/gpui/examples/painting.rs @@ -457,7 +457,7 @@ fn run_example() { |window, cx| cx.new(|cx| PaintingViewer::new(window, cx)), ) .unwrap(); - cx.on_window_closed(|cx| { + cx.on_window_closed(|cx, _window_id| { cx.quit(); }) .detach(); diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 882f3532da4a75606335d70a1063a5aff5e320c0..3453364a20ebf59bef6940656f79cbfdaf732c22 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -49,7 +49,8 @@ use crate::{ PlatformKeyboardMapper, Point, Priority, PromptBuilder, PromptButton, PromptHandle, PromptLevel, Render, RenderImage, RenderablePromptHandle, Reservation, ScreenCaptureSource, SharedString, SubscriberSet, Subscription, SvgRenderer, Task, TextRenderingMode, TextSystem, - ThermalState, Window, WindowAppearance, WindowHandle, WindowId, WindowInvalidator, + ThermalState, Window, WindowAppearance, WindowButtonLayout, WindowHandle, WindowId, + WindowInvalidator, colors::{Colors, GlobalColors}, hash, init_app_menus, }; @@ -240,7 +241,7 @@ type Listener = Box bool + 'static>; pub(crate) type KeystrokeObserver = Box bool + 'static>; type QuitHandler = Box LocalBoxFuture<'static, ()> + 'static>; -type WindowClosedHandler = Box; +type WindowClosedHandler = Box; type ReleaseListener = Box; type NewEntityListener = Box, &mut App) + 'static>; @@ -1177,6 +1178,11 @@ impl App { self.platform.window_appearance() } + /// Returns the window button layout configuration when supported. + pub fn button_layout(&self) -> Option { + self.platform.button_layout() + } + /// Reads data from the platform clipboard. pub fn read_from_clipboard(&self) -> Option { self.platform.read_from_clipboard() @@ -1561,7 +1567,7 @@ impl App { cx.windows.remove(id); cx.window_closed_observers.clone().retain(&(), |callback| { - callback(cx); + callback(cx, id); true }); @@ -2035,7 +2041,10 @@ impl App { /// Register a callback to be invoked when a window is closed /// The window is no longer accessible at the point this callback is invoked. - pub fn on_window_closed(&self, mut on_closed: impl FnMut(&mut App) + 'static) -> Subscription { + pub fn on_window_closed( + &self, + mut on_closed: impl FnMut(&mut App, WindowId) + 'static, + ) -> Subscription { let (subscription, activate) = self.window_closed_observers.insert((), Box::new(on_closed)); activate(); subscription @@ -2351,13 +2360,12 @@ impl AppContext for App { let entity = build_entity(&mut Context::new_context(cx, slot.downgrade())); cx.push_effect(Effect::EntityCreated { - entity: handle.clone().into_any(), + entity: handle.into_any(), tid: TypeId::of::(), window: cx.window_update_stack.last().cloned(), }); - cx.entities.insert(slot, entity); - handle + cx.entities.insert(slot, entity) }) } diff --git a/crates/gpui/src/app/context.rs b/crates/gpui/src/app/context.rs index c30a76bd9c8861d4d5b4d9dc4b5893ffeb2eb4b8..c2c74a0d57c8f0abff26ff0d19f6ef4de9e95244 100644 --- a/crates/gpui/src/app/context.rs +++ b/crates/gpui/src/app/context.rs @@ -479,6 +479,24 @@ impl<'a, T: 'static> Context<'a, T> { subscription } + /// Registers a callback to be invoked when the window button layout changes. + pub fn observe_button_layout_changed( + &self, + window: &mut Window, + mut callback: impl FnMut(&mut T, &mut Window, &mut Context) + 'static, + ) -> Subscription { + let view = self.weak_entity(); + let (subscription, activate) = window.button_layout_observers.insert( + (), + Box::new(move |window, cx| { + view.update(cx, |view, cx| callback(view, window, cx)) + .is_ok() + }), + ); + activate(); + subscription + } + /// Register a callback to be invoked when a keystroke is received by the application /// in any window. Note that this fires after all other action and event mechanisms have resolved /// and that this API will not be invoked if the event's propagation is stopped. diff --git a/crates/gpui/src/app/entity_map.rs b/crates/gpui/src/app/entity_map.rs index 766610aac9e8de619694662da9d4c62472a62b1d..cc4eaee492618812f1ee361d549b5e0052dafc68 100644 --- a/crates/gpui/src/app/entity_map.rs +++ b/crates/gpui/src/app/entity_map.rs @@ -894,6 +894,9 @@ pub(crate) struct HandleId { /// created, all participating strong entities in this cycle will effectively /// leak as they cannot be released anymore. /// +/// Cycles can also happen if an entity owns a task or subscription that it +/// itself owns a strong reference to the entity again. +/// /// # Usage /// /// You can use `WeakEntity::assert_released` or `AnyWeakEntity::assert_released` @@ -919,7 +922,7 @@ pub(crate) struct HandleId { /// ``` /// /// This will capture and display backtraces for each leaked handle, helping you -/// identify where handles were created but not released. +/// identify where leaked handles were created. /// /// # How It Works /// @@ -1002,11 +1005,13 @@ impl LeakDetector { /// otherwise it suggests setting the environment variable to get more info. pub fn assert_released(&mut self, entity_id: EntityId) { use std::fmt::Write as _; + if let Some(data) = self.entity_handles.remove(&entity_id) { let mut out = String::new(); for (_, backtrace) in data.handles { if let Some(mut backtrace) = backtrace { backtrace.resolve(); + let backtrace = BacktraceFormatter(backtrace); writeln!(out, "Leaked handle:\n{:?}", backtrace).unwrap(); } else { writeln!( @@ -1016,7 +1021,7 @@ impl LeakDetector { .unwrap(); } } - panic!("{out}"); + panic!("Handles for {} leaked:\n{out}", data.type_name); } } @@ -1054,6 +1059,7 @@ impl LeakDetector { if let Some(backtrace) = backtrace { let mut backtrace = backtrace.clone(); backtrace.resolve(); + let backtrace = BacktraceFormatter(backtrace); writeln!( out, "Leaked handle for entity {} ({entity_id:?}):\n{:?}", @@ -1091,6 +1097,7 @@ impl Drop for LeakDetector { for (_handle, backtrace) in data.handles { if let Some(mut backtrace) = backtrace { backtrace.resolve(); + let backtrace = BacktraceFormatter(backtrace); writeln!( out, "Leaked handle for entity {} ({entity_id:?}):\n{:?}", @@ -1111,6 +1118,71 @@ impl Drop for LeakDetector { } } +#[cfg(any(test, feature = "leak-detection"))] +struct BacktraceFormatter(backtrace::Backtrace); + +#[cfg(any(test, feature = "leak-detection"))] +impl fmt::Debug for BacktraceFormatter { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + use backtrace::{BacktraceFmt, BytesOrWideString, PrintFmt}; + + let style = if fmt.alternate() { + PrintFmt::Full + } else { + PrintFmt::Short + }; + + // When printing paths we try to strip the cwd if it exists, otherwise + // we just print the path as-is. Note that we also only do this for the + // short format, because if it's full we presumably want to print + // everything. + let cwd = std::env::current_dir(); + let mut print_path = move |fmt: &mut fmt::Formatter<'_>, path: BytesOrWideString<'_>| { + let path = path.into_path_buf(); + if style != PrintFmt::Full { + if let Ok(cwd) = &cwd { + if let Ok(suffix) = path.strip_prefix(cwd) { + return fmt::Display::fmt(&suffix.display(), fmt); + } + } + } + fmt::Display::fmt(&path.display(), fmt) + }; + + let mut f = BacktraceFmt::new(fmt, style, &mut print_path); + f.add_context()?; + let mut strip = true; + for frame in self.0.frames() { + if let [symbol, ..] = frame.symbols() + && let Some(name) = symbol.name() + && let Some(filename) = name.as_str() + { + match filename { + "test::run_test_in_process" + | "scheduler::executor::spawn_local_with_source_location::impl$1::poll > > > >,alloc::alloc::Global> > >" => { + strip = true + } + "gpui::app::entity_map::LeakDetector::handle_created" => { + strip = false; + continue; + } + "zed::main" => { + strip = true; + f.frame().backtrace_frame(frame)?; + } + _ => {} + } + } + if strip { + continue; + } + f.frame().backtrace_frame(frame)?; + } + f.finish()?; + Ok(()) + } +} + #[cfg(test)] mod test { use crate::EntityMap; diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index c1bb2011d0bdff432fc5bd0da12b63a79cb9ef5a..cc4f586a3dce937c310e177eefaff1c81c6a4b89 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -15,7 +15,6 @@ //! and Tailwind-like styling that you can use to build your own custom elements. Div is //! constructed by combining these two systems into an all-in-one element. -#[cfg(any(target_os = "linux", target_os = "macos"))] use crate::PinchEvent; use crate::{ AbsoluteLength, Action, AnyDrag, AnyElement, AnyTooltip, AnyView, App, Bounds, ClickEvent, @@ -357,11 +356,7 @@ impl Interactivity { /// Bind the given callback to pinch gesture events during the bubble phase. /// - /// Note: This event is only available on macOS and Wayland (Linux). - /// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held. - /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. - #[cfg(any(target_os = "linux", target_os = "macos"))] pub fn on_pinch(&mut self, listener: impl Fn(&PinchEvent, &mut Window, &mut App) + 'static) { self.pinch_listeners .push(Box::new(move |event, phase, hitbox, window, cx| { @@ -373,11 +368,7 @@ impl Interactivity { /// Bind the given callback to pinch gesture events during the capture phase. /// - /// Note: This event is only available on macOS and Wayland (Linux). - /// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held. - /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. - #[cfg(any(target_os = "linux", target_os = "macos"))] pub fn capture_pinch( &mut self, listener: impl Fn(&PinchEvent, &mut Window, &mut App) + 'static, @@ -675,15 +666,9 @@ impl Interactivity { self.hitbox_behavior = HitboxBehavior::BlockMouseExceptScroll; } - #[cfg(any(target_os = "linux", target_os = "macos"))] fn has_pinch_listeners(&self) -> bool { !self.pinch_listeners.is_empty() } - - #[cfg(not(any(target_os = "linux", target_os = "macos")))] - fn has_pinch_listeners(&self) -> bool { - false - } } /// A trait for elements that want to use the standard GPUI event handlers that don't @@ -957,11 +942,7 @@ pub trait InteractiveElement: Sized { /// Bind the given callback to pinch gesture events during the bubble phase. /// The fluent API equivalent to [`Interactivity::on_pinch`]. /// - /// Note: This event is only available on macOS and Wayland (Linux). - /// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held. - /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. - #[cfg(any(target_os = "linux", target_os = "macos"))] fn on_pinch(mut self, listener: impl Fn(&PinchEvent, &mut Window, &mut App) + 'static) -> Self { self.interactivity().on_pinch(listener); self @@ -970,11 +951,7 @@ pub trait InteractiveElement: Sized { /// Bind the given callback to pinch gesture events during the capture phase. /// The fluent API equivalent to [`Interactivity::capture_pinch`]. /// - /// Note: This event is only available on macOS and Wayland (Linux). - /// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held. - /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. - #[cfg(any(target_os = "linux", target_os = "macos"))] fn capture_pinch( mut self, listener: impl Fn(&PinchEvent, &mut Window, &mut App) + 'static, @@ -1367,7 +1344,6 @@ pub(crate) type MouseMoveListener = pub(crate) type ScrollWheelListener = Box; -#[cfg(any(target_os = "linux", target_os = "macos"))] pub(crate) type PinchListener = Box; @@ -1725,7 +1701,6 @@ pub struct Interactivity { pub(crate) mouse_pressure_listeners: Vec, pub(crate) mouse_move_listeners: Vec, pub(crate) scroll_wheel_listeners: Vec, - #[cfg(any(target_os = "linux", target_os = "macos"))] pub(crate) pinch_listeners: Vec, pub(crate) key_down_listeners: Vec, pub(crate) key_up_listeners: Vec, @@ -2297,7 +2272,6 @@ impl Interactivity { }) } - #[cfg(any(target_os = "linux", target_os = "macos"))] for listener in self.pinch_listeners.drain(..) { let hitbox = hitbox.clone(); window.on_mouse_event(move |event: &PinchEvent, phase, window, cx| { diff --git a/crates/gpui/src/elements/img.rs b/crates/gpui/src/elements/img.rs index 875f9e6dc1cc7d248f9e70488e52480dcca53fa3..ccd4123048c22fda796ec3ae9d367209d4974c38 100644 --- a/crates/gpui/src/elements/img.rs +++ b/crates/gpui/src/elements/img.rs @@ -315,20 +315,24 @@ impl Element for Img { if let Some(state) = &mut state { let frame_count = data.frame_count(); if frame_count > 1 { - let current_time = Instant::now(); - if let Some(last_frame_time) = state.last_frame_time { - let elapsed = current_time - last_frame_time; - let frame_duration = - Duration::from(data.delay(state.frame_index)); - - if elapsed >= frame_duration { - state.frame_index = - (state.frame_index + 1) % frame_count; - state.last_frame_time = - Some(current_time - (elapsed - frame_duration)); + if window.is_window_active() { + let current_time = Instant::now(); + if let Some(last_frame_time) = state.last_frame_time { + let elapsed = current_time - last_frame_time; + let frame_duration = + Duration::from(data.delay(state.frame_index)); + + if elapsed >= frame_duration { + state.frame_index = + (state.frame_index + 1) % frame_count; + state.last_frame_time = + Some(current_time - (elapsed - frame_duration)); + } + } else { + state.last_frame_time = Some(current_time); } } else { - state.last_frame_time = Some(current_time); + state.last_frame_time = None; } } state.started_loading = None; @@ -365,7 +369,10 @@ impl Element for Img { }; } - if global_id.is_some() && data.frame_count() > 1 { + if global_id.is_some() + && data.frame_count() > 1 + && window.is_window_active() + { window.request_animation_frame(); } } @@ -697,7 +704,7 @@ impl Asset for ImageAssetLoader { Ok(Arc::new(RenderImage::new(data))) } else { svg_renderer - .render_single_frame(&bytes, 1.0, true) + .render_single_frame(&bytes, 1.0) .map_err(Into::into) } } diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index b84241e9e0f79fe5cf8a24514cbf57982247a76b..ed441e3b40534690d02b31109e719c60dd5802e0 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -72,6 +72,7 @@ struct StateInner { scrollbar_drag_start_height: Option, measuring_behavior: ListMeasuringBehavior, pending_scroll: Option, + follow_tail: bool, } /// Keeps track of a fractional scroll position within an item for restoration @@ -102,6 +103,9 @@ pub struct ListScrollEvent { /// Whether the list has been scrolled. pub is_scrolled: bool, + + /// Whether the list is currently in follow-tail mode (auto-scrolling to end). + pub is_following_tail: bool, } /// The sizing behavior to apply during layout. @@ -236,6 +240,7 @@ impl ListState { scrollbar_drag_start_height: None, measuring_behavior: ListMeasuringBehavior::default(), pending_scroll: None, + follow_tail: false, }))); this.splice(0..0, item_count); this @@ -394,6 +399,34 @@ impl ListState { }); } + /// Scroll the list to the very end (past the last item). + /// + /// Unlike [`scroll_to_reveal_item`], this uses the total item count as the + /// anchor, so the list's layout pass will walk backwards from the end and + /// always show the bottom of the last item — even when that item is still + /// growing (e.g. during streaming). + pub fn scroll_to_end(&self) { + let state = &mut *self.0.borrow_mut(); + let item_count = state.items.summary().count; + state.logical_scroll_top = Some(ListOffset { + item_ix: item_count, + offset_in_item: px(0.), + }); + } + + /// Set whether the list should automatically follow the tail (auto-scroll to the end). + pub fn set_follow_tail(&self, follow: bool) { + self.0.borrow_mut().follow_tail = follow; + if follow { + self.scroll_to_end(); + } + } + + /// Returns whether the list is currently in follow-tail mode (auto-scrolling to the end). + pub fn is_following_tail(&self) -> bool { + self.0.borrow().follow_tail + } + /// Scroll the list to the given offset pub fn scroll_to(&self, mut scroll_top: ListOffset) { let state = &mut *self.0.borrow_mut(); @@ -493,18 +526,17 @@ impl ListState { /// This value remains constant while dragging to prevent the scrollbar from moving away unexpectedly. pub fn max_offset_for_scrollbar(&self) -> Point { let state = self.0.borrow(); - let bounds = state.last_layout_bounds.unwrap_or_default(); - - let height = state - .scrollbar_drag_start_height - .unwrap_or_else(|| state.items.summary().height); - - point(Pixels::ZERO, Pixels::ZERO.max(height - bounds.size.height)) + point(Pixels::ZERO, state.max_scroll_offset()) } /// Returns the current scroll offset adjusted for the scrollbar pub fn scroll_px_offset_for_scrollbar(&self) -> Point { let state = &self.0.borrow(); + + if state.logical_scroll_top.is_none() && state.alignment == ListAlignment::Bottom { + return Point::new(px(0.), -state.max_scroll_offset()); + } + let logical_scroll_top = state.logical_scroll_top(); let mut cursor = state.items.cursor::(()); @@ -526,6 +558,14 @@ impl ListState { } impl StateInner { + fn max_scroll_offset(&self) -> Pixels { + let bounds = self.last_layout_bounds.unwrap_or_default(); + let height = self + .scrollbar_drag_start_height + .unwrap_or_else(|| self.items.summary().height); + (height - bounds.size.height).max(px(0.)) + } + fn visible_range( items: &SumTree, height: Pixels, @@ -552,7 +592,6 @@ impl StateInner { if self.reset { return; } - let padding = self.last_padding.unwrap_or_default(); let scroll_max = (self.items.summary().height + padding.top + padding.bottom - height).max(px(0.)); @@ -574,6 +613,10 @@ impl StateInner { }); } + if self.follow_tail && delta.y > px(0.) { + self.follow_tail = false; + } + if let Some(handler) = self.scroll_handler.as_mut() { let visible_range = Self::visible_range(&self.items, height, scroll_top); handler( @@ -581,6 +624,7 @@ impl StateInner { visible_range, count: self.items.summary().count, is_scrolled: self.logical_scroll_top.is_some(), + is_following_tail: self.follow_tail, }, window, cx, @@ -670,6 +714,15 @@ impl StateInner { let mut rendered_height = padding.top; let mut max_item_width = px(0.); let mut scroll_top = self.logical_scroll_top(); + + if self.follow_tail { + scroll_top = ListOffset { + item_ix: self.items.summary().count, + offset_in_item: px(0.), + }; + self.logical_scroll_top = Some(scroll_top); + } + let mut rendered_focused_item = false; let available_item_space = size( @@ -951,6 +1004,8 @@ impl StateInner { content_height - self.scrollbar_drag_start_height.unwrap_or(content_height); let new_scroll_top = (point.y - drag_offset).abs().max(px(0.)).min(scroll_max); + self.follow_tail = false; + if self.alignment == ListAlignment::Bottom && new_scroll_top == scroll_max { self.logical_scroll_top = None; } else { @@ -1449,4 +1504,257 @@ mod test { assert_eq!(offset.item_ix, 2); assert_eq!(offset.offset_in_item, px(20.)); } + + #[gpui::test] + fn test_follow_tail_stays_at_bottom_as_items_grow(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + // 10 items, each 50px tall → 500px total content, 200px viewport. + // With follow-tail on, the list should always show the bottom. + let item_height = Rc::new(Cell::new(50usize)); + let state = ListState::new(10, crate::ListAlignment::Top, px(0.)); + + struct TestView { + state: ListState, + item_height: Rc>, + } + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let height = self.item_height.get(); + list(self.state.clone(), move |_, _, _| { + div().h(px(height as f32)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let state_clone = state.clone(); + let item_height_clone = item_height.clone(); + let view = cx.update(|_, cx| { + cx.new(|_| TestView { + state: state_clone, + item_height: item_height_clone, + }) + }); + + state.set_follow_tail(true); + + // First paint — items are 50px, total 500px, viewport 200px. + // Follow-tail should anchor to the end. + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + + // The scroll should be at the bottom: the last visible items fill the + // 200px viewport from the end of 500px of content (offset 300px). + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 6); + assert_eq!(offset.offset_in_item, px(0.)); + assert!(state.is_following_tail()); + + // Simulate items growing (e.g. streaming content makes each item taller). + // 10 items × 80px = 800px total. + item_height.set(80); + state.remeasure(); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.into_any_element() + }); + + // After growth, follow-tail should have re-anchored to the new end. + // 800px total − 200px viewport = 600px offset → item 7 at offset 40px, + // but follow-tail anchors to item_count (10), and layout walks back to + // fill 200px, landing at item 7 (7 × 80 = 560, 800 − 560 = 240 > 200, + // so item 8: 8 × 80 = 640, 800 − 640 = 160 < 200 → keeps walking → + // item 7: offset = 800 − 200 = 600, item_ix = 600/80 = 7, remainder 40). + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 7); + assert_eq!(offset.offset_in_item, px(40.)); + assert!(state.is_following_tail()); + } + + #[gpui::test] + fn test_follow_tail_disengages_on_user_scroll(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + // 10 items × 50px = 500px total, 200px viewport. + let state = ListState::new(10, crate::ListAlignment::Top, px(0.)); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(50.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + state.set_follow_tail(true); + + // Paint with follow-tail — scroll anchored to the bottom. + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, cx| { + cx.new(|_| TestView(state.clone())).into_any_element() + }); + assert!(state.is_following_tail()); + + // Simulate the user scrolling up. + // This should disengage follow-tail. + cx.simulate_event(ScrollWheelEvent { + position: point(px(50.), px(100.)), + delta: ScrollDelta::Pixels(point(px(0.), px(100.))), + ..Default::default() + }); + + assert!( + !state.is_following_tail(), + "follow-tail should disengage when the user scrolls toward the start" + ); + } + + #[gpui::test] + fn test_follow_tail_disengages_on_scrollbar_reposition(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + // 10 items × 50px = 500px total, 200px viewport. + let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all(); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(50.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone()))); + + state.set_follow_tail(true); + + // Paint with follow-tail — scroll anchored to the bottom. + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + assert!(state.is_following_tail()); + + // Simulate the scrollbar moving the viewport to the middle. + // `set_offset_from_scrollbar` accepts a positive distance from the start. + state.set_offset_from_scrollbar(point(px(0.), px(150.))); + + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 3); + assert_eq!(offset.offset_in_item, px(0.)); + assert!( + !state.is_following_tail(), + "follow-tail should disengage when the scrollbar manually repositions the list" + ); + + // A subsequent draw should preserve the user's manual position instead + // of snapping back to the end. + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.into_any_element() + }); + + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 3); + assert_eq!(offset.offset_in_item, px(0.)); + } + + #[gpui::test] + fn test_set_follow_tail_snaps_to_bottom(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + // 10 items × 50px = 500px total, 200px viewport. + let state = ListState::new(10, crate::ListAlignment::Top, px(0.)); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(50.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone()))); + + // Scroll to the middle of the list (item 3). + state.scroll_to(gpui::ListOffset { + item_ix: 3, + offset_in_item: px(0.), + }); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 3); + assert_eq!(offset.offset_in_item, px(0.)); + assert!(!state.is_following_tail()); + + // Enable follow-tail — this should immediately snap the scroll anchor + // to the end, like the user just sent a prompt. + state.set_follow_tail(true); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.into_any_element() + }); + + // After paint, scroll should be at the bottom. + // 500px total − 200px viewport = 300px offset → item 6, offset 0. + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 6); + assert_eq!(offset.offset_in_item, px(0.)); + assert!(state.is_following_tail()); + } + + #[gpui::test] + fn test_bottom_aligned_scrollbar_offset_at_end(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + const ITEMS: usize = 10; + const ITEM_SIZE: f32 = 50.0; + + let state = ListState::new( + ITEMS, + crate::ListAlignment::Bottom, + px(ITEMS as f32 * ITEM_SIZE), + ); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(ITEM_SIZE)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, cx| { + cx.new(|_| TestView(state.clone())).into_any_element() + }); + + // Bottom-aligned lists start pinned to the end: logical_scroll_top returns + // item_ix == item_count, meaning no explicit scroll position has been set. + assert_eq!(state.logical_scroll_top().item_ix, ITEMS); + + let max_offset = state.max_offset_for_scrollbar(); + let scroll_offset = state.scroll_px_offset_for_scrollbar(); + + assert_eq!( + -scroll_offset.y, max_offset.y, + "scrollbar offset ({}) should equal max offset ({}) when list is pinned to bottom", + -scroll_offset.y, max_offset.y, + ); + } } diff --git a/crates/gpui/src/input.rs b/crates/gpui/src/input.rs index c9c0a85cad2283c07af094e0f742c580341758ec..10ca46501d8a8206dee38e4e4a249931591ba631 100644 --- a/crates/gpui/src/input.rs +++ b/crates/gpui/src/input.rs @@ -187,4 +187,9 @@ impl InputHandler for ElementInputHandler { self.view .update(cx, |view, cx| view.accepts_text_input(window, cx)) } + + fn prefers_ime_for_printable_keys(&mut self, window: &mut Window, cx: &mut App) -> bool { + self.view + .update(cx, |view, cx| view.accepts_text_input(window, cx)) + } } diff --git a/crates/gpui/src/interactive.rs b/crates/gpui/src/interactive.rs index 3d3ddb49f70b2f96772627d085c93ce31b6dc0b5..0c7f2f9c97c59f90f8e037f069357dcc3c60c9cd 100644 --- a/crates/gpui/src/interactive.rs +++ b/crates/gpui/src/interactive.rs @@ -473,10 +473,7 @@ impl Default for ScrollDelta { /// A pinch gesture event from the platform, generated when the user performs /// a pinch-to-zoom gesture (typically on a trackpad). /// -/// Note: This event is only available on macOS and Wayland (Linux). -/// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held. #[derive(Clone, Debug, Default)] -#[cfg(any(target_os = "linux", target_os = "macos"))] pub struct PinchEvent { /// The position of the pinch center on the window. pub position: Point, @@ -493,20 +490,15 @@ pub struct PinchEvent { pub phase: TouchPhase, } -#[cfg(any(target_os = "linux", target_os = "macos"))] impl Sealed for PinchEvent {} -#[cfg(any(target_os = "linux", target_os = "macos"))] impl InputEvent for PinchEvent { fn to_platform_input(self) -> PlatformInput { PlatformInput::Pinch(self) } } -#[cfg(any(target_os = "linux", target_os = "macos"))] impl GestureEvent for PinchEvent {} -#[cfg(any(target_os = "linux", target_os = "macos"))] impl MouseEvent for PinchEvent {} -#[cfg(any(target_os = "linux", target_os = "macos"))] impl Deref for PinchEvent { type Target = Modifiers; @@ -675,7 +667,6 @@ pub enum PlatformInput { /// The scroll wheel was used. ScrollWheel(ScrollWheelEvent), /// A pinch gesture was performed. - #[cfg(any(target_os = "linux", target_os = "macos"))] Pinch(PinchEvent), /// Files were dragged and dropped onto the window. FileDrop(FileDropEvent), @@ -693,7 +684,6 @@ impl PlatformInput { PlatformInput::MousePressure(event) => Some(event), PlatformInput::MouseExited(event) => Some(event), PlatformInput::ScrollWheel(event) => Some(event), - #[cfg(any(target_os = "linux", target_os = "macos"))] PlatformInput::Pinch(event) => Some(event), PlatformInput::FileDrop(event) => Some(event), } @@ -710,7 +700,6 @@ impl PlatformInput { PlatformInput::MousePressure(_) => None, PlatformInput::MouseExited(_) => None, PlatformInput::ScrollWheel(_) => None, - #[cfg(any(target_os = "linux", target_os = "macos"))] PlatformInput::Pinch(_) => None, PlatformInput::FileDrop(_) => None, } diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index cd0b74a2c5d2f7d0233aec18509aa0f9f5e5c3a2..806a34040a4ec685c3d5c6ec01f47b5026e349a6 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -37,6 +37,8 @@ use crate::{ ThreadTaskTimings, Window, WindowControlArea, hash, point, px, size, }; use anyhow::Result; +#[cfg(any(target_os = "linux", target_os = "freebsd"))] +use anyhow::bail; use async_task::Runnable; use futures::channel::oneshot; #[cfg(any(test, feature = "test-support"))] @@ -156,6 +158,11 @@ pub trait Platform: 'static { /// Returns the appearance of the application's windows. fn window_appearance(&self) -> WindowAppearance; + /// Returns the window button layout configuration when supported. + fn button_layout(&self) -> Option { + None + } + fn open_url(&self, url: &str); fn on_open_urls(&self, callback: Box)>); fn register_url_scheme(&self, url: &str) -> Task>; @@ -407,6 +414,145 @@ impl Default for WindowControls { } } +/// A window control button type used in [`WindowButtonLayout`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum WindowButton { + /// The minimize button + Minimize, + /// The maximize button + Maximize, + /// The close button + Close, +} + +impl WindowButton { + /// Returns a stable element ID for rendering this button. + pub fn id(&self) -> &'static str { + match self { + WindowButton::Minimize => "minimize", + WindowButton::Maximize => "maximize", + WindowButton::Close => "close", + } + } + + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + fn index(&self) -> usize { + match self { + WindowButton::Minimize => 0, + WindowButton::Maximize => 1, + WindowButton::Close => 2, + } + } +} + +/// Maximum number of [`WindowButton`]s per side in the titlebar. +pub const MAX_BUTTONS_PER_SIDE: usize = 3; + +/// Describes which [`WindowButton`]s appear on each side of the titlebar. +/// +/// On Linux, this is read from the desktop environment's configuration +/// (e.g. GNOME's `gtk-decoration-layout` gsetting) via [`WindowButtonLayout::parse`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WindowButtonLayout { + /// Buttons on the left side of the titlebar. + pub left: [Option; MAX_BUTTONS_PER_SIDE], + /// Buttons on the right side of the titlebar. + pub right: [Option; MAX_BUTTONS_PER_SIDE], +} + +#[cfg(any(target_os = "linux", target_os = "freebsd"))] +impl WindowButtonLayout { + /// Returns Zed's built-in fallback button layout for Linux titlebars. + pub fn linux_default() -> Self { + Self { + left: [None; MAX_BUTTONS_PER_SIDE], + right: [ + Some(WindowButton::Minimize), + Some(WindowButton::Maximize), + Some(WindowButton::Close), + ], + } + } + + /// Parses a GNOME-style `button-layout` string (e.g. `"close,minimize:maximize"`). + pub fn parse(layout_string: &str) -> Result { + fn parse_side( + s: &str, + seen_buttons: &mut [bool; MAX_BUTTONS_PER_SIDE], + unrecognized: &mut Vec, + ) -> [Option; MAX_BUTTONS_PER_SIDE] { + let mut result = [None; MAX_BUTTONS_PER_SIDE]; + let mut i = 0; + for name in s.split(',') { + let trimmed = name.trim(); + if trimmed.is_empty() { + continue; + } + let button = match trimmed { + "minimize" => Some(WindowButton::Minimize), + "maximize" => Some(WindowButton::Maximize), + "close" => Some(WindowButton::Close), + other => { + unrecognized.push(other.to_string()); + None + } + }; + if let Some(button) = button { + if seen_buttons[button.index()] { + continue; + } + if let Some(slot) = result.get_mut(i) { + *slot = Some(button); + seen_buttons[button.index()] = true; + i += 1; + } + } + } + result + } + + let (left_str, right_str) = layout_string.split_once(':').unwrap_or(("", layout_string)); + let mut unrecognized = Vec::new(); + let mut seen_buttons = [false; MAX_BUTTONS_PER_SIDE]; + let layout = Self { + left: parse_side(left_str, &mut seen_buttons, &mut unrecognized), + right: parse_side(right_str, &mut seen_buttons, &mut unrecognized), + }; + + if !unrecognized.is_empty() + && layout.left.iter().all(Option::is_none) + && layout.right.iter().all(Option::is_none) + { + bail!( + "button layout string {:?} contains no valid buttons (unrecognized: {})", + layout_string, + unrecognized.join(", ") + ); + } + + Ok(layout) + } + + /// Formats the layout back into a GNOME-style `button-layout` string. + #[cfg(test)] + pub fn format(&self) -> String { + fn format_side(buttons: &[Option; MAX_BUTTONS_PER_SIDE]) -> String { + buttons + .iter() + .flatten() + .map(|button| match button { + WindowButton::Minimize => "minimize", + WindowButton::Maximize => "maximize", + WindowButton::Close => "close", + }) + .collect::>() + .join(",") + } + + format!("{}:{}", format_side(&self.left), format_side(&self.right)) + } +} + /// A type to describe which sides of the window are currently tiled in some way #[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Default)] pub struct Tiling { @@ -488,6 +634,7 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { fn on_hit_test_window_control(&self, callback: Box Option>); fn on_close(&self, callback: Box); fn on_appearance_changed(&self, callback: Box); + fn on_button_layout_changed(&self, _callback: Box) {} fn draw(&self, scene: &Scene); fn completed_frame(&self) {} fn sprite_atlas(&self) -> Arc; @@ -1095,6 +1242,13 @@ impl PlatformInputHandler { .update(|window, cx| self.handler.accepts_text_input(window, cx)) .unwrap_or(true) } + + #[allow(dead_code)] + pub fn query_prefers_ime_for_printable_keys(&mut self) -> bool { + self.cx + .update(|window, cx| self.handler.prefers_ime_for_printable_keys(window, cx)) + .unwrap_or(false) + } } /// A struct representing a selection in a text buffer, in UTF16 characters. @@ -1208,6 +1362,18 @@ pub trait InputHandler: 'static { fn accepts_text_input(&mut self, _window: &mut Window, _cx: &mut App) -> bool { true } + + /// Returns whether printable keys should be routed to the IME before keybinding + /// matching when a non-ASCII input source (e.g. Japanese, Korean, Chinese IME) + /// is active. This prevents multi-stroke keybindings like `jj` from intercepting + /// keys that the IME should compose. + /// + /// Defaults to `false`. The editor overrides this based on whether it expects + /// character input (e.g. Vim insert mode returns `true`, normal mode returns `false`). + /// The terminal keeps the default `false` so that raw keys reach the terminal process. + fn prefers_ime_for_printable_keys(&mut self, _window: &mut Window, _cx: &mut App) -> bool { + false + } } /// The variables that can be configured when creating a new window @@ -1942,7 +2108,7 @@ impl Image { ImageFormat::Ico => frames_for_image(&self.bytes, image::ImageFormat::Ico)?, ImageFormat::Svg => { return svg_renderer - .render_single_frame(&self.bytes, 1.0, false) + .render_single_frame(&self.bytes, 1.0) .map_err(Into::into); } }; @@ -2023,3 +2189,209 @@ impl From for ClipboardString { } } } + +#[cfg(test)] +mod image_tests { + use super::*; + use std::sync::Arc; + + #[test] + fn test_svg_image_to_image_data_converts_to_bgra() { + let image = Image::from_bytes( + ImageFormat::Svg, + br##" + +"## + .to_vec(), + ); + + let render_image = image.to_image_data(SvgRenderer::new(Arc::new(()))).unwrap(); + let bytes = render_image.as_bytes(0).unwrap(); + + for pixel in bytes.chunks_exact(4) { + assert_eq!(pixel, &[0xF8, 0xBD, 0x38, 0xFF]); + } + } +} + +#[cfg(all(test, any(target_os = "linux", target_os = "freebsd")))] +mod tests { + use super::*; + use std::collections::HashSet; + + #[test] + fn test_window_button_layout_parse_standard() { + let layout = WindowButtonLayout::parse("close,minimize:maximize").unwrap(); + assert_eq!( + layout.left, + [ + Some(WindowButton::Close), + Some(WindowButton::Minimize), + None + ] + ); + assert_eq!(layout.right, [Some(WindowButton::Maximize), None, None]); + } + + #[test] + fn test_window_button_layout_parse_right_only() { + let layout = WindowButtonLayout::parse("minimize,maximize,close").unwrap(); + assert_eq!(layout.left, [None, None, None]); + assert_eq!( + layout.right, + [ + Some(WindowButton::Minimize), + Some(WindowButton::Maximize), + Some(WindowButton::Close) + ] + ); + } + + #[test] + fn test_window_button_layout_parse_left_only() { + let layout = WindowButtonLayout::parse("close,minimize,maximize:").unwrap(); + assert_eq!( + layout.left, + [ + Some(WindowButton::Close), + Some(WindowButton::Minimize), + Some(WindowButton::Maximize) + ] + ); + assert_eq!(layout.right, [None, None, None]); + } + + #[test] + fn test_window_button_layout_parse_with_whitespace() { + let layout = WindowButtonLayout::parse(" close , minimize : maximize ").unwrap(); + assert_eq!( + layout.left, + [ + Some(WindowButton::Close), + Some(WindowButton::Minimize), + None + ] + ); + assert_eq!(layout.right, [Some(WindowButton::Maximize), None, None]); + } + + #[test] + fn test_window_button_layout_parse_empty() { + let layout = WindowButtonLayout::parse("").unwrap(); + assert_eq!(layout.left, [None, None, None]); + assert_eq!(layout.right, [None, None, None]); + } + + #[test] + fn test_window_button_layout_parse_intentionally_empty() { + let layout = WindowButtonLayout::parse(":").unwrap(); + assert_eq!(layout.left, [None, None, None]); + assert_eq!(layout.right, [None, None, None]); + } + + #[test] + fn test_window_button_layout_parse_invalid_buttons() { + let layout = WindowButtonLayout::parse("close,invalid,minimize:maximize,foo").unwrap(); + assert_eq!( + layout.left, + [ + Some(WindowButton::Close), + Some(WindowButton::Minimize), + None + ] + ); + assert_eq!(layout.right, [Some(WindowButton::Maximize), None, None]); + } + + #[test] + fn test_window_button_layout_parse_deduplicates_same_side_buttons() { + let layout = WindowButtonLayout::parse("close,close,minimize").unwrap(); + assert_eq!( + layout.right, + [ + Some(WindowButton::Close), + Some(WindowButton::Minimize), + None + ] + ); + assert_eq!(layout.format(), ":close,minimize"); + } + + #[test] + fn test_window_button_layout_parse_deduplicates_buttons_across_sides() { + let layout = WindowButtonLayout::parse("close:maximize,close,minimize").unwrap(); + assert_eq!(layout.left, [Some(WindowButton::Close), None, None]); + assert_eq!( + layout.right, + [ + Some(WindowButton::Maximize), + Some(WindowButton::Minimize), + None + ] + ); + + let button_ids: Vec<_> = layout + .left + .iter() + .chain(layout.right.iter()) + .flatten() + .map(WindowButton::id) + .collect(); + let unique_button_ids = button_ids.iter().copied().collect::>(); + assert_eq!(unique_button_ids.len(), button_ids.len()); + assert_eq!(layout.format(), "close:maximize,minimize"); + } + + #[test] + fn test_window_button_layout_parse_gnome_style() { + let layout = WindowButtonLayout::parse("close").unwrap(); + assert_eq!(layout.left, [None, None, None]); + assert_eq!(layout.right, [Some(WindowButton::Close), None, None]); + } + + #[test] + fn test_window_button_layout_parse_elementary_style() { + let layout = WindowButtonLayout::parse("close:maximize").unwrap(); + assert_eq!(layout.left, [Some(WindowButton::Close), None, None]); + assert_eq!(layout.right, [Some(WindowButton::Maximize), None, None]); + } + + #[test] + fn test_window_button_layout_round_trip() { + let cases = [ + "close:minimize,maximize", + "minimize,maximize,close:", + ":close", + "close:", + "close:maximize", + ":", + ]; + + for case in cases { + let layout = WindowButtonLayout::parse(case).unwrap(); + assert_eq!(layout.format(), case, "Round-trip failed for: {}", case); + } + } + + #[test] + fn test_window_button_layout_linux_default() { + let layout = WindowButtonLayout::linux_default(); + assert_eq!(layout.left, [None, None, None]); + assert_eq!( + layout.right, + [ + Some(WindowButton::Minimize), + Some(WindowButton::Maximize), + Some(WindowButton::Close) + ] + ); + + let round_tripped = WindowButtonLayout::parse(&layout.format()).unwrap(); + assert_eq!(round_tripped, layout); + } + + #[test] + fn test_window_button_layout_parse_all_invalid() { + assert!(WindowButtonLayout::parse("asdfghjkl").is_err()); + } +} diff --git a/crates/gpui/src/profiler.rs b/crates/gpui/src/profiler.rs index dc6e9a6600f5c172050fd30cfed181ac7ed81ec4..1405b4d04964f5497bb4d7f865d6c4405507b43d 100644 --- a/crates/gpui/src/profiler.rs +++ b/crates/gpui/src/profiler.rs @@ -1,9 +1,8 @@ use scheduler::Instant; use std::{ cell::LazyCell, - collections::HashMap, - hash::Hasher, - hash::{DefaultHasher, Hash}, + collections::{HashMap, VecDeque}, + hash::{DefaultHasher, Hash, Hasher}, sync::Arc, thread::ThreadId, }; @@ -45,7 +44,6 @@ impl ThreadTaskTimings { let timings = &timings.timings; let mut vec = Vec::with_capacity(timings.len()); - let (s1, s2) = timings.as_slices(); vec.extend_from_slice(s1); vec.extend_from_slice(s2); @@ -243,11 +241,14 @@ impl ProfilingCollector { } } -// Allow 20mb of task timing entries -const MAX_TASK_TIMINGS: usize = (20 * 1024 * 1024) / core::mem::size_of::(); +// Allow 16MiB of task timing entries. +// VecDeque grows by doubling its capacity when full, so keep this a power of 2 to avoid wasting +// memory. +const MAX_TASK_TIMINGS: usize = (16 * 1024 * 1024) / core::mem::size_of::(); #[doc(hidden)] -pub type TaskTimings = circular_buffer::CircularBuffer; +pub(crate) type TaskTimings = VecDeque; + #[doc(hidden)] pub type GuardedTaskTimings = spin::Mutex; @@ -287,7 +288,7 @@ thread_local! { pub struct ThreadTimings { pub thread_name: Option, pub thread_id: ThreadId, - pub timings: Box, + pub timings: TaskTimings, pub total_pushed: u64, } @@ -296,10 +297,38 @@ impl ThreadTimings { ThreadTimings { thread_name, thread_id, - timings: TaskTimings::boxed(), + timings: TaskTimings::new(), total_pushed: 0, } } + + /// If this task is the same as the last task, update the end time of the last task. + /// + /// Otherwise, add the new task timing to the list. + pub fn add_task_timing(&mut self, timing: TaskTiming) { + if let Some(last_timing) = self.timings.back_mut() + && last_timing.location == timing.location + && last_timing.start == timing.start + { + last_timing.end = timing.end; + } else { + while self.timings.len() + 1 > MAX_TASK_TIMINGS { + // This should only ever pop one element because it matches the insertion below. + self.timings.pop_front(); + } + self.timings.push_back(timing); + self.total_pushed += 1; + } + } + + pub fn get_thread_task_timings(&self) -> ThreadTaskTimings { + ThreadTaskTimings { + thread_name: self.thread_name.clone(), + thread_id: self.thread_id, + timings: self.timings.iter().cloned().collect(), + total_pushed: self.total_pushed, + } + } } impl Drop for ThreadTimings { @@ -318,19 +347,13 @@ impl Drop for ThreadTimings { } #[doc(hidden)] -#[allow(dead_code)] // Used by Linux and Windows dispatchers, not macOS pub fn add_task_timing(timing: TaskTiming) { THREAD_TIMINGS.with(|timings| { - let mut timings = timings.lock(); - - if let Some(last_timing) = timings.timings.back_mut() { - if last_timing.location == timing.location && last_timing.start == timing.start { - last_timing.end = timing.end; - return; - } - } - - timings.timings.push_back(timing); - timings.total_pushed += 1; + timings.lock().add_task_timing(timing); }); } + +#[doc(hidden)] +pub fn get_current_thread_task_timings() -> ThreadTaskTimings { + THREAD_TIMINGS.with(|timings| timings.lock().get_thread_task_timings()) +} diff --git a/crates/gpui/src/subscription.rs b/crates/gpui/src/subscription.rs index cf44b68d2bcbf7ca7d02c4b9e956f15079f8bdb6..b0c55a3966192ea0a15011e3cd2d14498c680c46 100644 --- a/crates/gpui/src/subscription.rs +++ b/crates/gpui/src/subscription.rs @@ -1,9 +1,8 @@ -use collections::{BTreeMap, BTreeSet}; +use collections::BTreeMap; use gpui_util::post_inc; use std::{ cell::{Cell, RefCell}, fmt::Debug, - mem, rc::Rc, }; @@ -19,12 +18,12 @@ impl Clone for SubscriberSet { struct SubscriberSetState { subscribers: BTreeMap>>>, - dropped_subscribers: BTreeSet<(EmitterKey, usize)>, next_subscriber_id: usize, } struct Subscriber { active: Rc>, + dropped: Rc>, callback: Callback, } @@ -36,7 +35,6 @@ where pub fn new() -> Self { Self(Rc::new(RefCell::new(SubscriberSetState { subscribers: Default::default(), - dropped_subscribers: Default::default(), next_subscriber_id: 0, }))) } @@ -51,6 +49,7 @@ where callback: Callback, ) -> (Subscription, impl FnOnce() + use) { let active = Rc::new(Cell::new(false)); + let dropped = Rc::new(Cell::new(false)); let mut lock = self.0.borrow_mut(); let subscriber_id = post_inc(&mut lock.next_subscriber_id); lock.subscribers @@ -61,6 +60,7 @@ where subscriber_id, Subscriber { active: active.clone(), + dropped: dropped.clone(), callback, }, ); @@ -68,9 +68,10 @@ where let subscription = Subscription { unsubscribe: Some(Box::new(move || { + dropped.set(true); + let mut lock = this.borrow_mut(); let Some(subscribers) = lock.subscribers.get_mut(&emitter_key) else { - // remove was called with this emitter_key return; }; @@ -79,14 +80,7 @@ where if subscribers.is_empty() { lock.subscribers.remove(&emitter_key); } - return; } - - // We didn't manage to remove the subscription, which means it was dropped - // while invoking the callback. Mark it as dropped so that we can remove it - // later. - lock.dropped_subscribers - .insert((emitter_key, subscriber_id)); })), }; (subscription, move || active.set(true)) @@ -128,11 +122,14 @@ where }; subscribers.retain(|_, subscriber| { - if subscriber.active.get() { - f(&mut subscriber.callback) - } else { - true + if !subscriber.active.get() { + return true; } + if subscriber.dropped.get() { + return false; + } + let keep = f(&mut subscriber.callback); + keep && !subscriber.dropped.get() }); let mut lock = self.0.borrow_mut(); @@ -141,12 +138,6 @@ where subscribers.extend(new_subscribers); } - // Remove any dropped subscriptions that were dropped while invoking the callback. - for (dropped_emitter, dropped_subscription_id) in mem::take(&mut lock.dropped_subscribers) { - debug_assert_eq!(*emitter, dropped_emitter); - subscribers.remove(&dropped_subscription_id); - } - if !subscribers.is_empty() { lock.subscribers.insert(emitter.clone(), Some(subscribers)); } @@ -207,3 +198,154 @@ impl std::fmt::Debug for Subscription { f.debug_struct("Subscription").finish() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Global, TestApp}; + + #[test] + fn test_unsubscribe_during_callback_with_insert() { + struct TestGlobal; + impl Global for TestGlobal {} + + let mut app = TestApp::new(); + app.set_global(TestGlobal); + + let observer_a_count = Rc::new(Cell::new(0usize)); + let observer_b_count = Rc::new(Cell::new(0usize)); + + let sub_a: Rc>> = Default::default(); + let sub_b: Rc>> = Default::default(); + + // Observer A fires first (lower subscriber_id). It drops itself and + // inserts a new observer for the same global. + *sub_a.borrow_mut() = Some(app.update({ + let count = observer_a_count.clone(); + let sub_a = sub_a.clone(); + move |cx| { + cx.observe_global::(move |cx| { + count.set(count.get() + 1); + sub_a.borrow_mut().take(); + cx.observe_global::(|_| {}).detach(); + }) + } + })); + + // Observer B fires second. It just drops itself. + *sub_b.borrow_mut() = Some(app.update({ + let count = observer_b_count.clone(); + let sub_b = sub_b.clone(); + move |cx| { + cx.observe_global::(move |_cx| { + count.set(count.get() + 1); + sub_b.borrow_mut().take(); + }) + } + })); + + // Both fire once. + app.update(|cx| cx.set_global(TestGlobal)); + assert_eq!(observer_a_count.get(), 1); + assert_eq!(observer_b_count.get(), 1); + + // Neither should fire again — both dropped their subscriptions. + app.update(|cx| cx.set_global(TestGlobal)); + assert_eq!(observer_a_count.get(), 1); + assert_eq!(observer_b_count.get(), 1, "orphaned subscriber fired again"); + } + + #[test] + fn test_callback_dropped_by_earlier_callback_does_not_fire() { + struct TestGlobal; + impl Global for TestGlobal {} + + let mut app = TestApp::new(); + app.set_global(TestGlobal); + + let observer_b_count = Rc::new(Cell::new(0usize)); + let sub_b: Rc>> = Default::default(); + + // Observer A fires first and drops B's subscription. + app.update({ + let sub_b = sub_b.clone(); + move |cx| { + cx.observe_global::(move |_cx| { + sub_b.borrow_mut().take(); + }) + .detach(); + } + }); + + // Observer B fires second — but A already dropped it. + *sub_b.borrow_mut() = Some(app.update({ + let count = observer_b_count.clone(); + move |cx| { + cx.observe_global::(move |_cx| { + count.set(count.get() + 1); + }) + } + })); + + app.update(|cx| cx.set_global(TestGlobal)); + assert_eq!( + observer_b_count.get(), + 0, + "B should not fire — A dropped its subscription" + ); + } + + #[test] + fn test_self_drop_during_callback() { + struct TestGlobal; + impl Global for TestGlobal {} + + let mut app = TestApp::new(); + app.set_global(TestGlobal); + + let count = Rc::new(Cell::new(0usize)); + let sub: Rc>> = Default::default(); + + *sub.borrow_mut() = Some(app.update({ + let count = count.clone(); + let sub = sub.clone(); + move |cx| { + cx.observe_global::(move |_cx| { + count.set(count.get() + 1); + sub.borrow_mut().take(); + }) + } + })); + + app.update(|cx| cx.set_global(TestGlobal)); + assert_eq!(count.get(), 1); + + app.update(|cx| cx.set_global(TestGlobal)); + assert_eq!(count.get(), 1, "should not fire after self-drop"); + } + + #[test] + fn test_subscription_drop() { + struct TestGlobal; + impl Global for TestGlobal {} + + let mut app = TestApp::new(); + app.set_global(TestGlobal); + + let count = Rc::new(Cell::new(0usize)); + + let subscription = app.update({ + let count = count.clone(); + move |cx| { + cx.observe_global::(move |_cx| { + count.set(count.get() + 1); + }) + } + }); + + drop(subscription); + + app.update(|cx| cx.set_global(TestGlobal)); + assert_eq!(count.get(), 0, "should not fire after drop"); + } +} diff --git a/crates/gpui/src/svg_renderer.rs b/crates/gpui/src/svg_renderer.rs index f82530f8d10fab074dd5e116114cf028a8a19cfe..8653ab9b162031772ab29367b60ff988e33cd823 100644 --- a/crates/gpui/src/svg_renderer.rs +++ b/crates/gpui/src/svg_renderer.rs @@ -10,6 +10,73 @@ use std::{ sync::{Arc, LazyLock}, }; +#[cfg(target_os = "macos")] +const EMOJI_FONT_FAMILIES: &[&str] = &["Apple Color Emoji", ".AppleColorEmojiUI"]; + +#[cfg(target_os = "windows")] +const EMOJI_FONT_FAMILIES: &[&str] = &["Segoe UI Emoji", "Segoe UI Symbol"]; + +#[cfg(any(target_os = "linux", target_os = "freebsd"))] +const EMOJI_FONT_FAMILIES: &[&str] = &[ + "Noto Color Emoji", + "Emoji One", + "Twitter Color Emoji", + "JoyPixels", +]; + +#[cfg(not(any( + target_os = "macos", + target_os = "windows", + target_os = "linux", + target_os = "freebsd", +)))] +const EMOJI_FONT_FAMILIES: &[&str] = &[]; + +fn is_emoji_presentation(c: char) -> bool { + static EMOJI_PRESENTATION_REGEX: LazyLock = + LazyLock::new(|| regex::Regex::new("\\p{Emoji_Presentation}").unwrap()); + let mut buf = [0u8; 4]; + EMOJI_PRESENTATION_REGEX.is_match(c.encode_utf8(&mut buf)) +} + +fn font_has_char(db: &usvg::fontdb::Database, id: usvg::fontdb::ID, ch: char) -> bool { + db.with_face_data(id, |font_data, face_index| { + ttf_parser::Face::parse(font_data, face_index) + .ok() + .and_then(|face| face.glyph_index(ch)) + .is_some() + }) + .unwrap_or(false) +} + +fn select_emoji_font( + ch: char, + fonts: &[usvg::fontdb::ID], + db: &usvg::fontdb::Database, + families: &[&str], +) -> Option { + for family_name in families { + let query = usvg::fontdb::Query { + families: &[usvg::fontdb::Family::Name(family_name)], + weight: usvg::fontdb::Weight(400), + stretch: usvg::fontdb::Stretch::Normal, + style: usvg::fontdb::Style::Normal, + }; + + let Some(id) = db.query(&query) else { + continue; + }; + + if fonts.contains(&id) || !font_has_char(db, id, ch) { + continue; + } + + return Some(id); + } + + None +} + /// When rendering SVGs, we render them at twice the size to get a higher-quality result. pub const SMOOTH_SVG_SCALE_FACTOR: f32 = 2.; @@ -52,10 +119,23 @@ impl SvgRenderer { default_font_resolver(font, db) }, ); + let default_fallback_selection = usvg::FontResolver::default_fallback_selector(); + let fallback_selection = Box::new( + move |ch: char, fonts: &[usvg::fontdb::ID], db: &mut Arc| { + if is_emoji_presentation(ch) { + if let Some(id) = select_emoji_font(ch, fonts, db.as_ref(), EMOJI_FONT_FAMILIES) + { + return Some(id); + } + } + + default_fallback_selection(ch, fonts, db) + }, + ); let options = usvg::Options { font_resolver: usvg::FontResolver { select_font: font_resolver, - select_fallback: usvg::FontResolver::default_fallback_selector(), + select_fallback: fallback_selection, }, ..Default::default() }; @@ -70,7 +150,6 @@ impl SvgRenderer { &self, bytes: &[u8], scale_factor: f32, - to_brga: bool, ) -> Result, usvg::Error> { self.render_pixmap( bytes, @@ -81,10 +160,8 @@ impl SvgRenderer { image::ImageBuffer::from_raw(pixmap.width(), pixmap.height(), pixmap.take()) .unwrap(); - if to_brga { - for pixel in buffer.chunks_exact_mut(4) { - swap_rgba_pa_to_bgra(pixel); - } + for pixel in buffer.chunks_exact_mut(4) { + swap_rgba_pa_to_bgra(pixel); } let mut image = RenderImage::new(SmallVec::from_const([Frame::new(buffer)])); @@ -148,3 +225,73 @@ impl SvgRenderer { Ok(pixmap) } } + +#[cfg(test)] +mod tests { + use super::*; + + const IBM_PLEX_REGULAR: &[u8] = + include_bytes!("../../../assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf"); + const LILEX_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/lilex/Lilex-Regular.ttf"); + + #[test] + fn test_is_emoji_presentation() { + let cases = [ + ("a", false), + ("Z", false), + ("1", false), + ("#", false), + ("*", false), + ("漢", false), + ("中", false), + ("カ", false), + ("©", false), + ("♥", false), + ("😀", true), + ("✅", true), + ("🇺🇸", true), + // SVG fallback is not cluster-aware yet + ("©️", false), + ("♥️", false), + ("1️⃣", false), + ]; + for (s, expected) in cases { + assert_eq!( + is_emoji_presentation(s.chars().next().unwrap()), + expected, + "for char {:?}", + s + ); + } + } + + #[test] + fn test_select_emoji_font_skips_family_without_glyph() { + let mut db = usvg::fontdb::Database::new(); + + db.load_font_data(IBM_PLEX_REGULAR.to_vec()); + db.load_font_data(LILEX_REGULAR.to_vec()); + + let ibm_plex_sans = db + .query(&usvg::fontdb::Query { + families: &[usvg::fontdb::Family::Name("IBM Plex Sans")], + weight: usvg::fontdb::Weight(400), + stretch: usvg::fontdb::Stretch::Normal, + style: usvg::fontdb::Style::Normal, + }) + .unwrap(); + let lilex = db + .query(&usvg::fontdb::Query { + families: &[usvg::fontdb::Family::Name("Lilex")], + weight: usvg::fontdb::Weight(400), + stretch: usvg::fontdb::Stretch::Normal, + style: usvg::fontdb::Style::Normal, + }) + .unwrap(); + let selected = select_emoji_font('│', &[], &db, &["IBM Plex Sans", "Lilex"]).unwrap(); + + assert_eq!(selected, lilex); + assert!(!font_has_char(&db, ibm_plex_sans, '│')); + assert!(font_has_char(&db, selected, '│')); + } +} diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 8be228c38ccef26115bdfceff69cb0502c564fd7..48c381e5275e950bd6754541fedbab03ae3d64c2 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -951,6 +951,7 @@ pub struct Window { pub(crate) bounds_observers: SubscriberSet<(), AnyObserver>, appearance: WindowAppearance, pub(crate) appearance_observers: SubscriberSet<(), AnyObserver>, + pub(crate) button_layout_observers: SubscriberSet<(), AnyObserver>, active: Rc>, hovered: Rc>, pub(crate) needs_present: Rc>, @@ -1288,6 +1289,14 @@ impl Window { .log_err(); } })); + platform_window.on_button_layout_changed(Box::new({ + let mut cx = cx.to_async(); + move || { + handle + .update(&mut cx, |_, window, cx| window.button_layout_changed(cx)) + .log_err(); + } + })); platform_window.on_active_status_change(Box::new({ let mut cx = cx.to_async(); move |active| { @@ -1442,6 +1451,7 @@ impl Window { bounds_observers: SubscriberSet::new(), appearance, appearance_observers: SubscriberSet::new(), + button_layout_observers: SubscriberSet::new(), active, hovered, needs_present, @@ -1534,6 +1544,22 @@ impl Window { subscription } + /// Registers a callback to be invoked when the window button layout changes. + pub fn observe_button_layout_changed( + &self, + mut callback: impl FnMut(&mut Window, &mut App) + 'static, + ) -> Subscription { + let (subscription, activate) = self.button_layout_observers.insert( + (), + Box::new(move |window, cx| { + callback(window, cx); + true + }), + ); + activate(); + subscription + } + /// Replaces the root entity of the window with a new one. pub fn replace_root( &mut self, @@ -1956,6 +1982,12 @@ impl Window { .retain(&(), |callback| callback(self, cx)); } + pub(crate) fn button_layout_changed(&mut self, cx: &mut App) { + self.button_layout_observers + .clone() + .retain(&(), |callback| callback(self, cx)); + } + /// Returns the appearance of the current window. pub fn appearance(&self) -> WindowAppearance { self.appearance @@ -4114,7 +4146,6 @@ impl Window { self.modifiers = scroll_wheel.modifiers; PlatformInput::ScrollWheel(scroll_wheel) } - #[cfg(any(target_os = "linux", target_os = "macos"))] PlatformInput::Pinch(pinch) => { self.mouse_position = pinch.position; self.modifiers = pinch.modifiers; diff --git a/crates/gpui_linux/src/linux/dispatcher.rs b/crates/gpui_linux/src/linux/dispatcher.rs index a72276cc7658a399505fa62bd2d5fe7b41e43e14..22df5799ddf9c77bfdbc7b09accbea117de6d130 100644 --- a/crates/gpui_linux/src/linux/dispatcher.rs +++ b/crates/gpui_linux/src/linux/dispatcher.rs @@ -13,7 +13,7 @@ use std::{ use gpui::{ GLOBAL_THREAD_TIMINGS, PlatformDispatcher, Priority, PriorityQueueReceiver, - PriorityQueueSender, RunnableVariant, THREAD_TIMINGS, TaskTiming, ThreadTaskTimings, profiler, + PriorityQueueSender, RunnableVariant, TaskTiming, ThreadTaskTimings, profiler, }; struct TimerAfter { @@ -135,25 +135,7 @@ impl PlatformDispatcher for LinuxDispatcher { } fn get_current_thread_timings(&self) -> gpui::ThreadTaskTimings { - THREAD_TIMINGS.with(|timings| { - let timings = timings.lock(); - let thread_name = timings.thread_name.clone(); - let total_pushed = timings.total_pushed; - let timings = &timings.timings; - - let mut vec = Vec::with_capacity(timings.len()); - - let (s1, s2) = timings.as_slices(); - vec.extend_from_slice(s1); - vec.extend_from_slice(s2); - - gpui::ThreadTaskTimings { - thread_name, - thread_id: std::thread::current().id(), - timings: vec, - total_pushed, - } - }) + gpui::profiler::get_current_thread_task_timings() } fn is_main_thread(&self) -> bool { diff --git a/crates/gpui_linux/src/linux/platform.rs b/crates/gpui_linux/src/linux/platform.rs index 633e0245602cb54c5066c67a1730c4554dfb5960..e3c947bcb9d33389faa354df1a83ae6419650ba8 100644 --- a/crates/gpui_linux/src/linux/platform.rs +++ b/crates/gpui_linux/src/linux/platform.rs @@ -26,7 +26,8 @@ use gpui::{ Action, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DisplayId, ForegroundExecutor, Keymap, Menu, MenuItem, OwnedMenu, PathPromptOptions, Platform, PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, - PlatformWindow, Result, RunnableVariant, Task, ThermalState, WindowAppearance, WindowParams, + PlatformWindow, Result, RunnableVariant, Task, ThermalState, WindowAppearance, + WindowButtonLayout, WindowParams, }; #[cfg(any(feature = "wayland", feature = "x11"))] use gpui::{Pixels, Point, px}; @@ -114,6 +115,7 @@ pub(crate) struct LinuxCommon { pub(crate) text_system: Arc, pub(crate) appearance: WindowAppearance, pub(crate) auto_hide_scrollbars: bool, + pub(crate) button_layout: WindowButtonLayout, pub(crate) callbacks: PlatformHandlers, pub(crate) signal: LoopSignal, pub(crate) menus: Vec, @@ -140,6 +142,7 @@ impl LinuxCommon { text_system, appearance: WindowAppearance::Light, auto_hide_scrollbars: false, + button_layout: WindowButtonLayout::linux_default(), callbacks, signal, menus: Vec::new(), @@ -601,6 +604,10 @@ impl Platform for LinuxPlatform

{ self.inner.with_common(|common| common.appearance) } + fn button_layout(&self) -> Option { + Some(self.inner.with_common(|common| common.button_layout)) + } + fn register_url_scheme(&self, _: &str) -> Task> { Task::ready(Err(anyhow!("register_url_scheme unimplemented"))) } diff --git a/crates/gpui_linux/src/linux/wayland/client.rs b/crates/gpui_linux/src/linux/wayland/client.rs index 49e6e835508e1511771656bdd3b52dcfb86cfaa3..b65a203dd3448ba191b7e2f5ae0f5b6c396545a8 100644 --- a/crates/gpui_linux/src/linux/wayland/client.rs +++ b/crates/gpui_linux/src/linux/wayland/client.rs @@ -95,8 +95,8 @@ use gpui::{ ForegroundExecutor, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseExitEvent, MouseMoveEvent, MouseUpEvent, NavigationDirection, Pixels, PlatformDisplay, PlatformInput, PlatformKeyboardLayout, PlatformWindow, Point, - ScrollDelta, ScrollWheelEvent, SharedString, Size, TaskTiming, TouchPhase, WindowParams, point, - profiler, px, size, + ScrollDelta, ScrollWheelEvent, SharedString, Size, TaskTiming, TouchPhase, WindowButtonLayout, + WindowParams, point, profiler, px, size, }; use gpui_wgpu::{CompositorGpuHint, GpuContext}; use wayland_protocols::wp::linux_dmabuf::zv1::client::{ @@ -567,6 +567,19 @@ impl WaylandClient { } } } + XDPEvent::ButtonLayout(layout_str) => { + if let Some(client) = client.0.upgrade() { + let layout = WindowButtonLayout::parse(&layout_str) + .log_err() + .unwrap_or_else(WindowButtonLayout::linux_default); + let mut client = client.borrow_mut(); + client.common.button_layout = layout; + + for window in client.windows.values_mut() { + window.set_button_layout(); + } + } + } XDPEvent::CursorTheme(theme) => { if let Some(client) = client.0.upgrade() { let mut client = client.borrow_mut(); @@ -862,7 +875,9 @@ impl LinuxClient for WaylandClient { }; if state.mouse_focused_window.is_some() || state.keyboard_focused_window.is_some() { state.clipboard.set_primary(item); - let serial = state.serial_tracker.get(SerialKind::KeyPress); + let serial = state + .serial_tracker + .latest_of(&[SerialKind::KeyPress, SerialKind::MousePress]); let data_source = primary_selection_manager.create_source(&state.globals.qh, ()); for mime_type in TEXT_MIME_TYPES { data_source.offer(mime_type.to_string()); @@ -882,7 +897,9 @@ impl LinuxClient for WaylandClient { }; if state.mouse_focused_window.is_some() || state.keyboard_focused_window.is_some() { state.clipboard.set(item); - let serial = state.serial_tracker.get(SerialKind::KeyPress); + let serial = state + .serial_tracker + .latest_of(&[SerialKind::KeyPress, SerialKind::MousePress]); let data_source = data_device_manager.create_data_source(&state.globals.qh, ()); for mime_type in TEXT_MIME_TYPES { data_source.offer(mime_type.to_string()); diff --git a/crates/gpui_linux/src/linux/wayland/serial.rs b/crates/gpui_linux/src/linux/wayland/serial.rs index eadc7a9ca97c6f3c78f8a5609deb27e891e52949..ed38f14bd130c4fb6db178fe218bae1327355476 100644 --- a/crates/gpui_linux/src/linux/wayland/serial.rs +++ b/crates/gpui_linux/src/linux/wayland/serial.rs @@ -46,4 +46,16 @@ impl SerialTracker { .map(|serial_data| serial_data.serial) .unwrap_or(0) } + + /// Returns the latest tracked serial of the provided [`SerialKind`]s + /// + /// Will return 0 if not tracked. + pub fn latest_of(&self, kinds: &[SerialKind]) -> u32 { + kinds + .iter() + .filter_map(|kind| self.serials.get(kind)) + .max_by_key(|serial_data| serial_data.serial) + .map(|serial_data| serial_data.serial) + .unwrap_or(0) + } } diff --git a/crates/gpui_linux/src/linux/wayland/window.rs b/crates/gpui_linux/src/linux/wayland/window.rs index 189ef91e6005b73801fa4be3b1f152ffe3952ff9..f3a5322f25654492d66c26d037f9da5279dac3aa 100644 --- a/crates/gpui_linux/src/linux/wayland/window.rs +++ b/crates/gpui_linux/src/linux/wayland/window.rs @@ -50,6 +50,7 @@ pub(crate) struct Callbacks { should_close: Option bool>>, close: Option>, appearance_changed: Option>, + button_layout_changed: Option>, } #[derive(Debug, Clone, Copy)] @@ -115,6 +116,7 @@ pub struct WaylandWindowState { handle: AnyWindowHandle, active: bool, hovered: bool, + pub(crate) force_render_after_recovery: bool, in_progress_configure: Option, resize_throttle: bool, in_progress_window_controls: Option, @@ -388,6 +390,7 @@ impl WaylandWindowState { handle, active: false, hovered: false, + force_render_after_recovery: false, in_progress_window_controls: None, window_controls: WindowControls::default(), client_inset: None, @@ -569,11 +572,16 @@ impl WaylandWindowStatePtr { let mut state = self.state.borrow_mut(); state.surface.frame(&state.globals.qh, state.surface.id()); state.resize_throttle = false; + let force_render = state.force_render_after_recovery; + state.force_render_after_recovery = false; drop(state); let mut cb = self.callbacks.borrow_mut(); if let Some(fun) = cb.request_frame.as_mut() { - fun(Default::default()); + fun(RequestFrameOptions { + force_render, + ..Default::default() + }); } } @@ -1038,6 +1046,14 @@ impl WaylandWindowStatePtr { } } + pub fn set_button_layout(&self) { + let callback = self.callbacks.borrow_mut().button_layout_changed.take(); + if let Some(mut fun) = callback { + fun(); + self.callbacks.borrow_mut().button_layout_changed = Some(fun); + } + } + pub fn primary_output_scale(&self) -> i32 { self.state.borrow_mut().primary_output_scale() } @@ -1335,6 +1351,10 @@ impl PlatformWindow for WaylandWindow { self.0.callbacks.borrow_mut().appearance_changed = Some(callback); } + fn on_button_layout_changed(&self, callback: Box) { + self.0.callbacks.borrow_mut().button_layout_changed = Some(callback); + } + fn draw(&self, scene: &Scene) { let mut state = self.borrow_mut(); @@ -1359,6 +1379,7 @@ impl PlatformWindow for WaylandWindow { // The current scene references atlas textures that were cleared during recovery. // Skip this frame and let the next frame rebuild the scene with fresh textures. + state.force_render_after_recovery = true; return; } diff --git a/crates/gpui_linux/src/linux/x11/client.rs b/crates/gpui_linux/src/linux/x11/client.rs index 77f154201d3af6bb7504349e579a5be6b4edcbb5..57871e6ef32b937a7a47662f8022293a57bc3fe2 100644 --- a/crates/gpui_linux/src/linux/x11/client.rs +++ b/crates/gpui_linux/src/linux/x11/client.rs @@ -62,7 +62,7 @@ use gpui::{ AnyWindowHandle, Bounds, ClipboardItem, CursorStyle, DisplayId, FileDropEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, Pixels, PlatformDisplay, PlatformInput, PlatformKeyboardLayout, PlatformWindow, Point, RequestFrameOptions, ScrollDelta, Size, - TouchPhase, WindowParams, point, px, + TouchPhase, WindowButtonLayout, WindowParams, point, px, }; use gpui_wgpu::{CompositorGpuHint, GpuContext}; @@ -176,6 +176,7 @@ pub struct X11ClientState { pub(crate) last_mouse_button: Option, pub(crate) last_location: Point, pub(crate) current_count: usize, + pub(crate) pinch_scale: f32, pub(crate) gpu_context: GpuContext, pub(crate) compositor_gpu: Option, @@ -342,11 +343,12 @@ impl X11Client { xcb_connection.prefetch_extension_information(render::X11_EXTENSION_NAME)?; xcb_connection.prefetch_extension_information(xinput::X11_EXTENSION_NAME)?; - // Announce to X server that XInput up to 2.1 is supported. To increase this to 2.2 and - // beyond, support for touch events would need to be added. + // Announce to X server that XInput up to 2.4 is supported. + // Version 2.4 is needed for gesture events (GesturePinchBegin/Update/End). + // If the server only supports an older version, gesture events simply won't be delivered. let xinput_version = get_reply( || "XInput XiQueryVersion failed", - xcb_connection.xinput_xi_query_version(2, 1), + xcb_connection.xinput_xi_query_version(2, 4), )?; assert!( xinput_version.major_version >= 2, @@ -472,6 +474,15 @@ impl X11Client { window.window.set_appearance(appearance); } } + XDPEvent::ButtonLayout(layout_str) => { + let layout = WindowButtonLayout::parse(&layout_str) + .log_err() + .unwrap_or_else(WindowButtonLayout::linux_default); + client.with_common(|common| common.button_layout = layout); + for window in client.0.borrow_mut().windows.values_mut() { + window.window.set_button_layout(); + } + } XDPEvent::CursorTheme(_) | XDPEvent::CursorSize(_) => { // noop, X11 manages this for us. } @@ -493,6 +504,7 @@ impl X11Client { last_mouse_button: None, last_location: Point::new(px(0.0), px(0.0)), current_count: 0, + pinch_scale: 1.0, gpu_context: Rc::new(RefCell::new(None)), compositor_gpu, scale_factor, @@ -1315,6 +1327,64 @@ impl X11Client { reset_pointer_device_scroll_positions(pointer); } } + Event::XinputGesturePinchBegin(event) => { + let window = self.get_window(event.event)?; + let mut state = self.0.borrow_mut(); + state.pinch_scale = 1.0; + let modifiers = modifiers_from_xinput_info(event.mods); + state.modifiers = modifiers; + let position = point( + px(event.event_x as f32 / u16::MAX as f32 / state.scale_factor), + px(event.event_y as f32 / u16::MAX as f32 / state.scale_factor), + ); + drop(state); + window.handle_input(PlatformInput::Pinch(gpui::PinchEvent { + position, + delta: 0.0, + modifiers, + phase: gpui::TouchPhase::Started, + })); + } + Event::XinputGesturePinchUpdate(event) => { + let window = self.get_window(event.event)?; + let mut state = self.0.borrow_mut(); + let modifiers = modifiers_from_xinput_info(event.mods); + state.modifiers = modifiers; + let position = point( + px(event.event_x as f32 / u16::MAX as f32 / state.scale_factor), + px(event.event_y as f32 / u16::MAX as f32 / state.scale_factor), + ); + // scale is in FP16.16 format: divide by 65536 to get the float value + let new_absolute_scale = event.scale as f32 / 65536.0; + let previous_scale = state.pinch_scale; + let zoom_delta = new_absolute_scale - previous_scale; + state.pinch_scale = new_absolute_scale; + drop(state); + window.handle_input(PlatformInput::Pinch(gpui::PinchEvent { + position, + delta: zoom_delta, + modifiers, + phase: gpui::TouchPhase::Moved, + })); + } + Event::XinputGesturePinchEnd(event) => { + let window = self.get_window(event.event)?; + let mut state = self.0.borrow_mut(); + state.pinch_scale = 1.0; + let modifiers = modifiers_from_xinput_info(event.mods); + state.modifiers = modifiers; + let position = point( + px(event.event_x as f32 / u16::MAX as f32 / state.scale_factor), + px(event.event_y as f32 / u16::MAX as f32 / state.scale_factor), + ); + drop(state); + window.handle_input(PlatformInput::Pinch(gpui::PinchEvent { + position, + delta: 0.0, + modifiers, + phase: gpui::TouchPhase::Ended, + })); + } _ => {} }; @@ -1874,11 +1944,14 @@ impl X11ClientState { if let Some(window) = state.windows.get_mut(&x_window) { let expose_event_received = window.expose_event_received; window.expose_event_received = false; + let force_render = std::mem::take( + &mut window.window.state.borrow_mut().force_render_after_recovery, + ); let window = window.window.clone(); drop(state); window.refresh(RequestFrameOptions { require_presentation: expose_event_received, - force_render: false, + force_render, }); } xcb_connection diff --git a/crates/gpui_linux/src/linux/x11/window.rs b/crates/gpui_linux/src/linux/x11/window.rs index 689a652b918e7bc1e793d19b66d954eaa6277a6e..79bd7666e0eca36459c925be1628f542a30162f5 100644 --- a/crates/gpui_linux/src/linux/x11/window.rs +++ b/crates/gpui_linux/src/linux/x11/window.rs @@ -250,6 +250,7 @@ pub struct Callbacks { should_close: Option bool>>, close: Option>, appearance_changed: Option>, + button_layout_changed: Option>, } pub struct X11WindowState { @@ -276,6 +277,7 @@ pub struct X11WindowState { hidden: bool, active: bool, hovered: bool, + pub(crate) force_render_after_recovery: bool, fullscreen: bool, client_side_decorations_supported: bool, decorations: WindowDecorations, @@ -669,7 +671,13 @@ impl X11WindowState { | xinput::XIEventMask::BUTTON_PRESS | xinput::XIEventMask::BUTTON_RELEASE | xinput::XIEventMask::ENTER - | xinput::XIEventMask::LEAVE, + | xinput::XIEventMask::LEAVE + // x11rb 0.13 doesn't define XIEventMask constants for gesture + // events, so we construct them from the event opcodes (each + // XInput event type N maps to mask bit N). + | xinput::XIEventMask::from(1u32 << xinput::GESTURE_PINCH_BEGIN_EVENT) + | xinput::XIEventMask::from(1u32 << xinput::GESTURE_PINCH_UPDATE_EVENT) + | xinput::XIEventMask::from(1u32 << xinput::GESTURE_PINCH_END_EVENT), ], }], ), @@ -749,6 +757,7 @@ impl X11WindowState { input_handler: None, active: false, hovered: false, + force_render_after_recovery: false, fullscreen: false, maximized_vertical: false, maximized_horizontal: false, @@ -1256,6 +1265,14 @@ impl X11WindowStatePtr { self.callbacks.borrow_mut().appearance_changed = Some(fun); } } + + pub fn set_button_layout(&self) { + let callback = self.callbacks.borrow_mut().button_layout_changed.take(); + if let Some(mut fun) = callback { + fun(); + self.callbacks.borrow_mut().button_layout_changed = Some(fun); + } + } } impl PlatformWindow for X11Window { @@ -1602,6 +1619,10 @@ impl PlatformWindow for X11Window { self.0.callbacks.borrow_mut().appearance_changed = Some(callback); } + fn on_button_layout_changed(&self, callback: Box) { + self.0.callbacks.borrow_mut().button_layout_changed = Some(callback); + } + fn draw(&self, scene: &Scene) { let mut inner = self.0.state.borrow_mut(); @@ -1624,6 +1645,7 @@ impl PlatformWindow for X11Window { // The current scene references atlas textures that were cleared during recovery. // Skip this frame and let the next frame rebuild the scene with fresh textures. + inner.force_render_after_recovery = true; return; } diff --git a/crates/gpui_linux/src/linux/xdg_desktop_portal.rs b/crates/gpui_linux/src/linux/xdg_desktop_portal.rs index 911ac319db2b2a803a5e5e715f7a04f8cb128d7a..9b5d72476b61e81ce1d90d79de9286539060c0ba 100644 --- a/crates/gpui_linux/src/linux/xdg_desktop_portal.rs +++ b/crates/gpui_linux/src/linux/xdg_desktop_portal.rs @@ -15,6 +15,7 @@ pub enum Event { CursorTheme(String), #[cfg_attr(feature = "x11", allow(dead_code))] CursorSize(u32), + ButtonLayout(String), } pub struct XDPEventSource { @@ -51,6 +52,13 @@ impl XDPEventSource { sender.send(Event::CursorSize(initial_size as u32))?; } + if let Ok(initial_layout) = settings + .read::("org.gnome.desktop.wm.preferences", "button-layout") + .await + { + sender.send(Event::ButtonLayout(initial_layout))?; + } + if let Ok(mut cursor_theme_changed) = settings .receive_setting_changed_with_args( "org.gnome.desktop.interface", @@ -89,6 +97,25 @@ impl XDPEventSource { .detach(); } + if let Ok(mut button_layout_changed) = settings + .receive_setting_changed_with_args( + "org.gnome.desktop.wm.preferences", + "button-layout", + ) + .await + { + let sender = sender.clone(); + background + .spawn(async move { + while let Some(layout) = button_layout_changed.next().await { + let layout = layout?; + sender.send(Event::ButtonLayout(layout))?; + } + anyhow::Ok(()) + }) + .detach(); + } + let mut appearance_changed = settings.receive_color_scheme_changed().await?; while let Some(scheme) = appearance_changed.next().await { sender.send(Event::WindowAppearance( diff --git a/crates/gpui_macos/src/dispatcher.rs b/crates/gpui_macos/src/dispatcher.rs index dd6f546f68b88efe6babc13e2d923d634eff5825..f4b80ec7cbaf6deeebad1f7b6448463c9e132afe 100644 --- a/crates/gpui_macos/src/dispatcher.rs +++ b/crates/gpui_macos/src/dispatcher.rs @@ -1,7 +1,7 @@ use dispatch2::{DispatchQueue, DispatchQueueGlobalPriority, DispatchTime, GlobalQueueIdentifier}; use gpui::{ - GLOBAL_THREAD_TIMINGS, PlatformDispatcher, Priority, RunnableMeta, RunnableVariant, - THREAD_TIMINGS, TaskTiming, ThreadTaskTimings, + GLOBAL_THREAD_TIMINGS, PlatformDispatcher, Priority, RunnableMeta, RunnableVariant, TaskTiming, + ThreadTaskTimings, add_task_timing, }; use mach2::{ kern_return::KERN_SUCCESS, @@ -42,25 +42,7 @@ impl PlatformDispatcher for MacDispatcher { } fn get_current_thread_timings(&self) -> ThreadTaskTimings { - THREAD_TIMINGS.with(|timings| { - let timings = timings.lock(); - let thread_name = timings.thread_name.clone(); - let total_pushed = timings.total_pushed; - let timings = &timings.timings; - - let mut vec = Vec::with_capacity(timings.len()); - - let (s1, s2) = timings.as_slices(); - vec.extend_from_slice(s1); - vec.extend_from_slice(s2); - - ThreadTaskTimings { - thread_name, - thread_id: std::thread::current().id(), - timings: vec, - total_pushed, - } - }) + gpui::profiler::get_current_thread_task_timings() } fn is_main_thread(&self) -> bool { @@ -204,33 +186,16 @@ extern "C" fn trampoline(context: *mut c_void) { let location = runnable.metadata().location; let start = Instant::now(); - let timing = TaskTiming { + let mut timing = TaskTiming { location, start, end: None, }; - 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 { - return; - } - } - - timings.push_back(timing); - }); + add_task_timing(timing); runnable.run(); - let end = Instant::now(); - THREAD_TIMINGS.with(|timings| { - let mut timings = timings.lock(); - let timings = &mut timings.timings; - let Some(last_timing) = timings.iter_mut().rev().next() else { - return; - }; - last_timing.end = Some(end); - }); + timing.end = Some(Instant::now()); + add_task_timing(timing); } diff --git a/crates/gpui_macos/src/metal_atlas.rs b/crates/gpui_macos/src/metal_atlas.rs index eacd9407fe2e447abbd05dc8cdb2e9f7660cf3cf..e6b8443c520e1b47006104085fdc26a5415d85f6 100644 --- a/crates/gpui_macos/src/metal_atlas.rs +++ b/crates/gpui_macos/src/metal_atlas.rs @@ -61,7 +61,7 @@ impl PlatformAtlas for MetalAtlas { fn remove(&self, key: &AtlasKey) { let mut lock = self.0.lock(); - let Some(id) = lock.tiles_by_key.get(key).map(|v| v.texture_id) else { + let Some(id) = lock.tiles_by_key.remove(key).map(|v| v.texture_id) else { return; }; @@ -81,10 +81,8 @@ impl PlatformAtlas for MetalAtlas { if let Some(mut texture) = texture_slot.take() { texture.decrement_ref_count(); - if texture.is_unreferenced() { textures.free_list.push(id.index as usize); - lock.tiles_by_key.remove(key); } else { *texture_slot = Some(texture); } @@ -271,3 +269,81 @@ fn point_from_etagere(value: etagere::Point) -> Point { struct AssertSend(T); unsafe impl Send for AssertSend {} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::PlatformAtlas; + use std::borrow::Cow; + + fn create_atlas() -> Option { + let device = metal::Device::system_default()?; + Some(MetalAtlas::new(device, true)) + } + + fn make_image_key(image_id: usize, frame_index: usize) -> AtlasKey { + AtlasKey::Image(gpui::RenderImageParams { + image_id: gpui::ImageId(image_id), + frame_index, + }) + } + + fn insert_tile(atlas: &MetalAtlas, key: &AtlasKey, size: Size) -> AtlasTile { + atlas + .get_or_insert_with(key, &mut || { + let byte_count = (size.width.0 as usize) * (size.height.0 as usize) * 4; + Ok(Some((size, Cow::Owned(vec![0u8; byte_count])))) + }) + .expect("allocation should succeed") + .expect("callback returns Some") + } + + #[test] + fn test_remove_clears_stale_keys_from_tiles_by_key() { + let Some(atlas) = create_atlas() else { + return; + }; + + let small = Size { + width: DevicePixels(64), + height: DevicePixels(64), + }; + + let key_a = make_image_key(1, 0); + let key_b = make_image_key(2, 0); + let key_c = make_image_key(3, 0); + + let tile_a = insert_tile(&atlas, &key_a, small); + let tile_b = insert_tile(&atlas, &key_b, small); + let tile_c = insert_tile(&atlas, &key_c, small); + + assert_eq!(tile_a.texture_id, tile_b.texture_id); + assert_eq!(tile_b.texture_id, tile_c.texture_id); + + // Remove A: texture still has B and C, so it stays. + // The key for A must be removed from tiles_by_key. + atlas.remove(&key_a); + + // Remove B: texture still has C. + atlas.remove(&key_b); + + // Remove C: texture becomes unreferenced and is deleted. + atlas.remove(&key_c); + + // Re-inserting A must allocate a fresh tile on a new texture, + // NOT return a stale tile referencing the deleted texture. + let tile_a2 = insert_tile(&atlas, &key_a, small); + + // The texture must actually exist — this would panic before the fix. + let _texture = atlas.metal_texture(tile_a2.texture_id); + } + + #[test] + fn test_remove_nonexistent_key_is_noop() { + let Some(atlas) = create_atlas() else { + return; + }; + let key = make_image_key(999, 0); + atlas.remove(&key); + } +} diff --git a/crates/gpui_macos/src/platform.rs b/crates/gpui_macos/src/platform.rs index 4d30f82bc0555d38e9bbfbc3d8887806049f8314..5bae3cfb6aa73c99038e0017332a046035dc1589 100644 --- a/crates/gpui_macos/src/platform.rs +++ b/crates/gpui_macos/src/platform.rs @@ -1389,6 +1389,7 @@ unsafe fn ns_url_to_path(url: id) -> Result { #[link(name = "Carbon", kind = "framework")] unsafe extern "C" { pub(super) fn TISCopyCurrentKeyboardLayoutInputSource() -> *mut Object; + pub(super) fn TISCopyCurrentKeyboardInputSource() -> *mut Object; pub(super) fn TISGetInputSourceProperty( inputSource: *mut Object, propertyKey: *const c_void, @@ -1410,6 +1411,9 @@ unsafe extern "C" { pub(super) static kTISPropertyUnicodeKeyLayoutData: CFStringRef; pub(super) static kTISPropertyInputSourceID: CFStringRef; pub(super) static kTISPropertyLocalizedName: CFStringRef; + pub(super) static kTISPropertyInputSourceIsASCIICapable: CFStringRef; + pub(super) static kTISPropertyInputSourceType: CFStringRef; + pub(super) static kTISTypeKeyboardInputMode: CFStringRef; } mod security { diff --git a/crates/gpui_macos/src/shaders.metal b/crates/gpui_macos/src/shaders.metal index 3c6adac3359ac41ee0cc265480dae6e63a2c2136..4dc2d334e1e0929cc2ee7369ecce2f074b919366 100644 --- a/crates/gpui_macos/src/shaders.metal +++ b/crates/gpui_macos/src/shaders.metal @@ -1215,6 +1215,20 @@ float4 fill_color(Background background, break; } } + + // Dither to reduce banding in gradients (especially dark/alpha). + // Triangular-distributed noise breaks up 8-bit quantization steps. + // ±2/255 for RGB (enough for dark-on-dark compositing), + // ±3/255 for alpha (needs more because alpha × dark color = tiny steps). + { + float2 seed = position * 0.6180339887; // golden ratio spread + float r1 = fract(sin(dot(seed, float2(12.9898, 78.233))) * 43758.5453); + float r2 = fract(sin(dot(seed, float2(39.3460, 11.135))) * 24634.6345); + float tri = r1 + r2 - 1.0; // triangular PDF, range [-1, +1] + color.rgb += tri * 2.0 / 255.0; + color.a += tri * 3.0 / 255.0; + } + break; } case 2: { diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs index 002c4719f768d698b5c8b599f039579cfe78640c..398cf46eab09dc8412ffdda8eb550b8ad4e09b40 100644 --- a/crates/gpui_macos/src/window.rs +++ b/crates/gpui_macos/src/window.rs @@ -1,5 +1,7 @@ use crate::{ - BoolExt, DisplayLink, MacDisplay, NSRange, NSStringExt, events::platform_input_from_native, + BoolExt, DisplayLink, MacDisplay, NSRange, NSStringExt, TISCopyCurrentKeyboardInputSource, + TISGetInputSourceProperty, events::platform_input_from_native, + kTISPropertyInputSourceIsASCIICapable, kTISPropertyInputSourceType, kTISTypeKeyboardInputMode, ns_string, renderer, }; #[cfg(any(test, feature = "test-support"))] @@ -34,6 +36,9 @@ use gpui::{ #[cfg(any(test, feature = "test-support"))] use image::RgbaImage; +use core_foundation::base::{CFRelease, CFTypeRef}; +use core_foundation_sys::base::CFEqual; +use core_foundation_sys::number::{CFBooleanGetValue, CFBooleanRef}; use core_graphics::display::{CGDirectDisplayID, CGPoint, CGRect}; use ctor::ctor; use futures::channel::oneshot; @@ -55,7 +60,10 @@ use std::{ path::PathBuf, ptr::{self, NonNull}, rc::Rc, - sync::{Arc, Weak}, + sync::{ + Arc, Weak, + atomic::{AtomicBool, Ordering}, + }, time::Duration, }; use util::ResultExt; @@ -180,6 +188,14 @@ unsafe fn build_classes() { sel!(mouseDragged:), handle_view_event as extern "C" fn(&Object, Sel, id), ); + decl.add_method( + sel!(rightMouseDragged:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + sel!(otherMouseDragged:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); decl.add_method( sel!(scrollWheel:), handle_view_event as extern "C" fn(&Object, Sel, id), @@ -440,6 +456,7 @@ struct MacWindowState { select_previous_tab_callback: Option>, toggle_tab_bar_callback: Option>, activated_least_once: bool, + closed: Arc, // The parent window if this window is a sheet (Dialog kind) sheet_parent: Option, } @@ -764,6 +781,7 @@ impl MacWindow { select_previous_tab_callback: None, toggle_tab_bar_callback: None, activated_least_once: false, + closed: Arc::new(AtomicBool::new(false)), sheet_parent: None, }))); @@ -1020,6 +1038,17 @@ impl Drop for MacWindow { } } +/// Calls `f` if the window is not closed. +/// +/// This should be used when spawning foreground tasks interacting with the +/// window, as some messages will end hard faulting if dispatched to no longer +/// valid window handles. +fn if_window_not_closed(closed: Arc, f: impl FnOnce()) { + if !closed.load(Ordering::Acquire) { + f(); + } +} + impl PlatformWindow for MacWindow { fn bounds(&self) -> Bounds { self.0.as_ref().lock().bounds() @@ -1040,14 +1069,15 @@ impl PlatformWindow for MacWindow { fn resize(&mut self, size: Size) { let this = self.0.lock(); let window = this.native_window; + let closed = this.closed.clone(); this.foreground_executor .spawn(async move { - unsafe { + if_window_not_closed(closed, || unsafe { window.setContentSize_(NSSize { width: size.width.as_f32() as f64, height: size.height.as_f32() as f64, }); - } + }) }) .detach(); } @@ -1260,15 +1290,21 @@ impl PlatformWindow for MacWindow { } }); let block = block.copy(); - let native_window = self.0.lock().native_window; - let executor = self.0.lock().foreground_executor.clone(); + let lock = self.0.lock(); + let native_window = lock.native_window; + let closed = lock.closed.clone(); + let executor = lock.foreground_executor.clone(); executor .spawn(async move { - let _: () = msg_send![ - alert, - beginSheetModalForWindow: native_window - completionHandler: block - ]; + if !closed.load(Ordering::Acquire) { + let _: () = msg_send![ + alert, + beginSheetModalForWindow: native_window + completionHandler: block + ]; + } else { + let _: () = msg_send![alert, release]; + } }) .detach(); @@ -1277,12 +1313,16 @@ impl PlatformWindow for MacWindow { } fn activate(&self) { - let window = self.0.lock().native_window; - let executor = self.0.lock().foreground_executor.clone(); + let lock = self.0.lock(); + let window = lock.native_window; + let closed = lock.closed.clone(); + let executor = lock.foreground_executor.clone(); executor .spawn(async move { - unsafe { - let _: () = msg_send![window, makeKeyAndOrderFront: nil]; + if !closed.load(Ordering::Acquire) { + unsafe { + let _: () = msg_send![window, makeKeyAndOrderFront: nil]; + } } }) .detach(); @@ -1420,11 +1460,12 @@ impl PlatformWindow for MacWindow { fn zoom(&self) { let this = self.0.lock(); let window = this.native_window; + let closed = this.closed.clone(); this.foreground_executor .spawn(async move { - unsafe { + if_window_not_closed(closed, || unsafe { window.zoom_(nil); - } + }) }) .detach(); } @@ -1432,11 +1473,12 @@ impl PlatformWindow for MacWindow { fn toggle_fullscreen(&self) { let this = self.0.lock(); let window = this.native_window; + let closed = this.closed.clone(); this.foreground_executor .spawn(async move { - unsafe { + if_window_not_closed(closed, || unsafe { window.toggleFullScreen_(nil); - } + }) }) .detach(); } @@ -1577,45 +1619,48 @@ impl PlatformWindow for MacWindow { fn titlebar_double_click(&self) { let this = self.0.lock(); let window = this.native_window; + let closed = this.closed.clone(); this.foreground_executor .spawn(async move { - unsafe { - let defaults: id = NSUserDefaults::standardUserDefaults(); - 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() { - msg_send![dict, objectForKey: key] - } else { - nil - }; + if_window_not_closed(closed, || { + unsafe { + let defaults: id = NSUserDefaults::standardUserDefaults(); + 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() { + msg_send![dict, objectForKey: key] + } else { + nil + }; - let action_str = if !action.is_null() { - CStr::from_ptr(NSString::UTF8String(action)).to_string_lossy() - } else { - "".into() - }; + let action_str = if !action.is_null() { + CStr::from_ptr(NSString::UTF8String(action)).to_string_lossy() + } else { + "".into() + }; - match action_str.as_ref() { - "None" => { - // "Do Nothing" selected, so do no action - } - "Minimize" => { - window.miniaturize_(nil); - } - "Maximize" => { - window.zoom_(nil); - } - "Fill" => { - // There is no documented API for "Fill" action, so we'll just zoom the window - window.zoom_(nil); - } - _ => { - window.zoom_(nil); + match action_str.as_ref() { + "None" => { + // "Do Nothing" selected, so do no action + } + "Minimize" => { + window.miniaturize_(nil); + } + "Maximize" => { + window.zoom_(nil); + } + "Fill" => { + // There is no documented API for "Fill" action, so we'll just zoom the window + window.zoom_(nil); + } + _ => { + window.zoom_(nil); + } } } - } + }) }) .detach(); } @@ -1750,6 +1795,45 @@ extern "C" fn handle_key_up(this: &Object, _: Sel, native_event: id) { // - in vim mode `option-4` should go to end of line (same as $) // Japanese (Romaji) layout: // - type `a i left down up enter enter` should create an unmarked text "愛" +// - In vim mode with `jj` bound to `vim::NormalBefore` in insert mode, typing 'j i' with +// Japanese IME should produce "じ" (ji), not "jい" + +/// Returns true if the current keyboard input source is a composition-based IME +/// (e.g. Japanese Hiragana, Korean, Chinese Pinyin) that produces non-ASCII output. +/// +/// This checks two properties: +/// 1. The source type is `kTISTypeKeyboardInputMode` (an IME input mode, not a plain +/// keyboard layout). This excludes non-ASCII layouts like Armenian and Ukrainian +/// that map keys directly without composition. +/// 2. The source is not ASCII-capable, which excludes modes like Japanese Romaji that +/// produce ASCII characters and should allow multi-stroke keybindings like `jj`. +unsafe fn is_ime_input_source_active() -> bool { + unsafe { + let source = TISCopyCurrentKeyboardInputSource(); + if source.is_null() { + return false; + } + + let source_type = + TISGetInputSourceProperty(source, kTISPropertyInputSourceType as *const c_void); + let is_input_mode = !source_type.is_null() + && CFEqual( + source_type as CFTypeRef, + kTISTypeKeyboardInputMode as CFTypeRef, + ) != 0; + + let is_ascii = TISGetInputSourceProperty( + source, + kTISPropertyInputSourceIsASCIICapable as *const c_void, + ); + let is_ascii_capable = !is_ascii.is_null() && CFBooleanGetValue(is_ascii as CFBooleanRef); + + CFRelease(source as CFTypeRef); + + is_input_mode && !is_ascii_capable + } +} + extern "C" fn handle_key_event(this: &Object, native_event: id, key_equivalent: bool) -> BOOL { let window_state = unsafe { get_window_state(this) }; let mut lock = window_state.as_ref().lock(); @@ -1801,7 +1885,28 @@ extern "C" fn handle_key_event(this: &Object, native_event: id, key_equivalent: // and keys with function, as the input handler swallows them. // and keys with platform (Cmd), so that Cmd+key events (e.g. Cmd+`) are not // consumed by the IME on non-QWERTY / dead-key layouts. + // We also send printable keys to the IME first when an IME input source (e.g. Japanese, + // Korean, Chinese) is active and the input handler accepts text input. This prevents + // multi-stroke keybindings like `jj` from intercepting keys that the IME should compose + // (e.g. typing 'ji' should produce 'じ', not 'jい'). If the IME doesn't handle the key, + // it calls `doCommandBySelector:` which routes it back to keybinding matching. + let is_ime_printable_key = !is_composing + && key_down_event + .keystroke + .key_char + .as_ref() + .is_some_and(|key_char| key_char.chars().all(|c| !c.is_control())) + && !key_down_event.keystroke.modifiers.control + && !key_down_event.keystroke.modifiers.function + && !key_down_event.keystroke.modifiers.platform + && unsafe { is_ime_input_source_active() } + && with_input_handler(this, |input_handler| { + input_handler.query_prefers_ime_for_printable_keys() + }) + .unwrap_or(false); + if is_composing + || is_ime_printable_key || (key_down_event.keystroke.key_char.is_none() && !key_down_event.keystroke.modifiers.control && !key_down_event.keystroke.modifiers.function @@ -2185,6 +2290,7 @@ extern "C" fn close_window(this: &Object, _: Sel) { let close_callback = { let window_state = get_window_state(this); let mut lock = window_state.as_ref().lock(); + lock.closed.store(true, Ordering::Release); lock.close_callback.take() }; diff --git a/crates/gpui_platform/Cargo.toml b/crates/gpui_platform/Cargo.toml index cfb47b1851b9e792c31fad9aca79b3671095b603..22d44a96b21112336f3bee669c218c2291f78b65 100644 --- a/crates/gpui_platform/Cargo.toml +++ b/crates/gpui_platform/Cargo.toml @@ -28,6 +28,7 @@ gpui_macos.workspace = true [target.'cfg(target_os = "windows")'.dependencies] gpui_windows.workspace = true +gpui = { workspace = true, features = ["windows-manifest"] } [target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies] gpui_linux.workspace = true diff --git a/crates/gpui_windows/src/direct_manipulation.rs b/crates/gpui_windows/src/direct_manipulation.rs new file mode 100644 index 0000000000000000000000000000000000000000..08a1e5243e19e1ea6464ceb224754bee93573ea2 --- /dev/null +++ b/crates/gpui_windows/src/direct_manipulation.rs @@ -0,0 +1,359 @@ +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +use ::util::ResultExt; +use anyhow::Result; +use gpui::*; +use windows::Win32::{ + Foundation::*, + Graphics::{DirectManipulation::*, Gdi::*}, + System::Com::*, + UI::{Input::Pointer::*, WindowsAndMessaging::*}, +}; + +use crate::*; + +/// Default viewport size in pixels. The actual content size doesn't matter +/// because we're using the viewport only for gesture recognition, not for +/// visual output. +const DEFAULT_VIEWPORT_SIZE: i32 = 1000; + +pub(crate) struct DirectManipulationHandler { + manager: IDirectManipulationManager, + update_manager: IDirectManipulationUpdateManager, + viewport: IDirectManipulationViewport, + _handler_cookie: u32, + window: HWND, + scale_factor: Rc>, + pending_events: Rc>>, +} + +impl DirectManipulationHandler { + pub fn new(window: HWND, scale_factor: f32) -> Result { + unsafe { + let manager: IDirectManipulationManager = + CoCreateInstance(&DirectManipulationManager, None, CLSCTX_INPROC_SERVER)?; + + let update_manager: IDirectManipulationUpdateManager = manager.GetUpdateManager()?; + + let viewport: IDirectManipulationViewport = manager.CreateViewport(None, window)?; + + let configuration = DIRECTMANIPULATION_CONFIGURATION_INTERACTION + | DIRECTMANIPULATION_CONFIGURATION_TRANSLATION_X + | DIRECTMANIPULATION_CONFIGURATION_TRANSLATION_Y + | DIRECTMANIPULATION_CONFIGURATION_TRANSLATION_INERTIA + | DIRECTMANIPULATION_CONFIGURATION_RAILS_X + | DIRECTMANIPULATION_CONFIGURATION_RAILS_Y + | DIRECTMANIPULATION_CONFIGURATION_SCALING; + viewport.ActivateConfiguration(configuration)?; + + viewport.SetViewportOptions( + DIRECTMANIPULATION_VIEWPORT_OPTIONS_MANUALUPDATE + | DIRECTMANIPULATION_VIEWPORT_OPTIONS_DISABLEPIXELSNAPPING, + )?; + + let mut rect = RECT { + left: 0, + top: 0, + right: DEFAULT_VIEWPORT_SIZE, + bottom: DEFAULT_VIEWPORT_SIZE, + }; + viewport.SetViewportRect(&mut rect)?; + + manager.Activate(window)?; + viewport.Enable()?; + + let scale_factor = Rc::new(Cell::new(scale_factor)); + let pending_events = Rc::new(RefCell::new(Vec::new())); + + let event_handler: IDirectManipulationViewportEventHandler = + DirectManipulationEventHandler::new( + window, + Rc::clone(&scale_factor), + Rc::clone(&pending_events), + ) + .into(); + + let handler_cookie = viewport.AddEventHandler(Some(window), &event_handler)?; + + update_manager.Update(None)?; + + Ok(Self { + manager, + update_manager, + viewport, + _handler_cookie: handler_cookie, + window, + scale_factor, + pending_events, + }) + } + } + + pub fn set_scale_factor(&self, scale_factor: f32) { + self.scale_factor.set(scale_factor); + } + + pub fn on_pointer_hit_test(&self, wparam: WPARAM) { + unsafe { + let pointer_id = wparam.loword() as u32; + let mut pointer_type = POINTER_INPUT_TYPE::default(); + if GetPointerType(pointer_id, &mut pointer_type).is_ok() && pointer_type == PT_TOUCHPAD + { + self.viewport.SetContact(pointer_id).log_err(); + } + } + } + + pub fn update(&self) { + unsafe { + self.update_manager.Update(None).log_err(); + } + } + + pub fn drain_events(&self) -> Vec { + std::mem::take(&mut *self.pending_events.borrow_mut()) + } +} + +impl Drop for DirectManipulationHandler { + fn drop(&mut self) { + unsafe { + self.viewport.Stop().log_err(); + self.viewport.Abandon().log_err(); + self.manager.Deactivate(self.window).log_err(); + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum GestureKind { + None, + Scroll, + Pinch, +} + +#[windows_core::implement(IDirectManipulationViewportEventHandler)] +struct DirectManipulationEventHandler { + window: HWND, + scale_factor: Rc>, + gesture_kind: Cell, + last_scale: Cell, + last_x_offset: Cell, + last_y_offset: Cell, + scroll_phase: Cell, + pending_events: Rc>>, +} + +impl DirectManipulationEventHandler { + fn new( + window: HWND, + scale_factor: Rc>, + pending_events: Rc>>, + ) -> Self { + Self { + window, + scale_factor, + gesture_kind: Cell::new(GestureKind::None), + last_scale: Cell::new(1.0), + last_x_offset: Cell::new(0.0), + last_y_offset: Cell::new(0.0), + scroll_phase: Cell::new(TouchPhase::Started), + pending_events, + } + } + + fn end_gesture(&self) { + let position = self.mouse_position(); + let modifiers = current_modifiers(); + match self.gesture_kind.get() { + GestureKind::Scroll => { + self.pending_events + .borrow_mut() + .push(PlatformInput::ScrollWheel(ScrollWheelEvent { + position, + delta: ScrollDelta::Pixels(point(px(0.0), px(0.0))), + modifiers, + touch_phase: TouchPhase::Ended, + })); + } + GestureKind::Pinch => { + self.pending_events + .borrow_mut() + .push(PlatformInput::Pinch(PinchEvent { + position, + delta: 0.0, + modifiers, + phase: TouchPhase::Ended, + })); + } + GestureKind::None => {} + } + self.gesture_kind.set(GestureKind::None); + } + + fn mouse_position(&self) -> Point { + let scale_factor = self.scale_factor.get(); + unsafe { + let mut point: POINT = std::mem::zeroed(); + let _ = GetCursorPos(&mut point); + let _ = ScreenToClient(self.window, &mut point); + logical_point(point.x as f32, point.y as f32, scale_factor) + } + } +} + +impl IDirectManipulationViewportEventHandler_Impl for DirectManipulationEventHandler_Impl { + fn OnViewportStatusChanged( + &self, + viewport: windows_core::Ref<'_, IDirectManipulationViewport>, + current: DIRECTMANIPULATION_STATUS, + previous: DIRECTMANIPULATION_STATUS, + ) -> windows_core::Result<()> { + if current == previous { + return Ok(()); + } + + // A new gesture interrupted inertia, so end the old sequence. + if current == DIRECTMANIPULATION_RUNNING && previous == DIRECTMANIPULATION_INERTIA { + self.end_gesture(); + } + + if current == DIRECTMANIPULATION_READY { + self.end_gesture(); + + // Reset the content transform so the viewport is ready for the next gesture. + // ZoomToRect triggers a second RUNNING -> READY cycle, so prevent an infinite loop here. + if self.last_scale.get() != 1.0 + || self.last_x_offset.get() != 0.0 + || self.last_y_offset.get() != 0.0 + { + if let Some(viewport) = viewport.as_ref() { + unsafe { + viewport + .ZoomToRect( + 0.0, + 0.0, + DEFAULT_VIEWPORT_SIZE as f32, + DEFAULT_VIEWPORT_SIZE as f32, + false, + ) + .log_err(); + } + } + } + + self.last_scale.set(1.0); + self.last_x_offset.set(0.0); + self.last_y_offset.set(0.0); + } + + Ok(()) + } + + fn OnViewportUpdated( + &self, + _viewport: windows_core::Ref<'_, IDirectManipulationViewport>, + ) -> windows_core::Result<()> { + Ok(()) + } + + fn OnContentUpdated( + &self, + _viewport: windows_core::Ref<'_, IDirectManipulationViewport>, + content: windows_core::Ref<'_, IDirectManipulationContent>, + ) -> windows_core::Result<()> { + let content = content.as_ref().ok_or(E_POINTER)?; + + // Get the 6-element content transform: [scale, 0, 0, scale, tx, ty] + let mut xform = [0.0f32; 6]; + unsafe { + content.GetContentTransform(&mut xform)?; + } + + let scale = xform[0]; + let scale_factor = self.scale_factor.get(); + let x_offset = xform[4] / scale_factor; + let y_offset = xform[5] / scale_factor; + + if scale == 0.0 { + return Ok(()); + } + + let last_scale = self.last_scale.get(); + let last_x = self.last_x_offset.get(); + let last_y = self.last_y_offset.get(); + + if float_equals(scale, last_scale) + && float_equals(x_offset, last_x) + && float_equals(y_offset, last_y) + { + return Ok(()); + } + + let position = self.mouse_position(); + let modifiers = current_modifiers(); + + // Direct Manipulation reports both translation and scale in every content update. + // Translation values can shift during a pinch due to the zoom center shifting. + // We classify each gesture as either scroll or pinch and only emit one type of event. + // We allow Scroll -> Pinch (a pinch can start with a small pan) but not the reverse. + if !float_equals(scale, 1.0) { + if self.gesture_kind.get() != GestureKind::Pinch { + self.end_gesture(); + self.gesture_kind.set(GestureKind::Pinch); + self.pending_events + .borrow_mut() + .push(PlatformInput::Pinch(PinchEvent { + position, + delta: 0.0, + modifiers, + phase: TouchPhase::Started, + })); + } + } else if self.gesture_kind.get() == GestureKind::None { + self.gesture_kind.set(GestureKind::Scroll); + self.scroll_phase.set(TouchPhase::Started); + } + + match self.gesture_kind.get() { + GestureKind::Scroll => { + let dx = x_offset - last_x; + let dy = y_offset - last_y; + let touch_phase = self.scroll_phase.get(); + self.scroll_phase.set(TouchPhase::Moved); + self.pending_events + .borrow_mut() + .push(PlatformInput::ScrollWheel(ScrollWheelEvent { + position, + delta: ScrollDelta::Pixels(point(px(dx), px(dy))), + modifiers, + touch_phase, + })); + } + GestureKind::Pinch => { + let scale_delta = scale / last_scale; + self.pending_events + .borrow_mut() + .push(PlatformInput::Pinch(PinchEvent { + position, + delta: scale_delta - 1.0, + modifiers, + phase: TouchPhase::Moved, + })); + } + GestureKind::None => {} + } + + self.last_scale.set(scale); + self.last_x_offset.set(x_offset); + self.last_y_offset.set(y_offset); + + Ok(()) + } +} + +fn float_equals(f1: f32, f2: f32) -> bool { + const EPSILON_SCALE: f32 = 0.00001; + (f1 - f2).abs() < EPSILON_SCALE * f1.abs().max(f2.abs()).max(EPSILON_SCALE) +} diff --git a/crates/gpui_windows/src/directx_atlas.rs b/crates/gpui_windows/src/directx_atlas.rs index a2ded660ca232a32b2a609c6185a95433c803d9c..03acadb8607ed3e7d957e7faa73960724fa09888 100644 --- a/crates/gpui_windows/src/directx_atlas.rs +++ b/crates/gpui_windows/src/directx_atlas.rs @@ -116,7 +116,6 @@ impl PlatformAtlas for DirectXAtlas { texture.decrement_ref_count(); if texture.is_unreferenced() { textures.free_list.push(texture.id.index as usize); - lock.tiles_by_key.remove(key); } else { *texture_slot = Some(texture); } diff --git a/crates/gpui_windows/src/dispatcher.rs b/crates/gpui_windows/src/dispatcher.rs index a5cfd9dc10d9afcce9580565943c28cb83dc9dab..60b9898cef3076fa64898ebcb7223616150bf01b 100644 --- a/crates/gpui_windows/src/dispatcher.rs +++ b/crates/gpui_windows/src/dispatcher.rs @@ -24,7 +24,7 @@ use windows::{ use crate::{HWND, SafeHwnd, WM_GPUI_TASK_DISPATCHED_ON_MAIN_THREAD}; use gpui::{ GLOBAL_THREAD_TIMINGS, PlatformDispatcher, Priority, PriorityQueueSender, RunnableVariant, - THREAD_TIMINGS, TaskTiming, ThreadTaskTimings, TimerResolutionGuard, + TaskTiming, ThreadTaskTimings, TimerResolutionGuard, }; pub(crate) struct WindowsDispatcher { @@ -106,25 +106,7 @@ impl PlatformDispatcher for WindowsDispatcher { } fn get_current_thread_timings(&self) -> gpui::ThreadTaskTimings { - THREAD_TIMINGS.with(|timings| { - let timings = timings.lock(); - let thread_name = timings.thread_name.clone(); - let total_pushed = timings.total_pushed; - let timings = &timings.timings; - - let mut vec = Vec::with_capacity(timings.len()); - - let (s1, s2) = timings.as_slices(); - vec.extend_from_slice(s1); - vec.extend_from_slice(s2); - - gpui::ThreadTaskTimings { - thread_name, - thread_id: std::thread::current().id(), - timings: vec, - total_pushed, - } - }) + gpui::profiler::get_current_thread_task_timings() } fn is_main_thread(&self) -> bool { diff --git a/crates/gpui_windows/src/events.rs b/crates/gpui_windows/src/events.rs index 3506ae2a2cc22d57c4cefba1a4c5a1850c411453..21eb6bed899687e1c639efdc40788c229fdc4728 100644 --- a/crates/gpui_windows/src/events.rs +++ b/crates/gpui_windows/src/events.rs @@ -111,6 +111,7 @@ impl WindowsWindowInner { WM_GPUI_CURSOR_STYLE_CHANGED => self.handle_cursor_changed(lparam), WM_GPUI_FORCE_UPDATE_WINDOW => self.draw_window(handle, true), WM_GPUI_GPU_DEVICE_LOST => self.handle_device_lost(lparam), + DM_POINTERHITTEST => self.handle_dm_pointer_hit_test(wparam), _ => None, }; if let Some(n) = handled { @@ -758,6 +759,10 @@ impl WindowsWindowInner { self.state.scale_factor.set(new_scale_factor); self.state.border_offset.update(handle).log_err(); + self.state + .direct_manipulation + .set_scale_factor(new_scale_factor); + if is_maximized { // Get the monitor and its work area at the new DPI let monitor = unsafe { MonitorFromWindow(handle, MONITOR_DEFAULTTONEAREST) }; @@ -1139,14 +1144,32 @@ impl WindowsWindowInner { Some(0) } + fn handle_dm_pointer_hit_test(&self, wparam: WPARAM) -> Option { + self.state.direct_manipulation.on_pointer_hit_test(wparam); + None + } + #[inline] fn draw_window(&self, handle: HWND, force_render: bool) -> Option { let mut request_frame = self.state.callbacks.request_frame.take()?; - // we are instructing gpui to force render a frame, this will - // re-populate all the gpu textures for us so we can resume drawing in - // case we disabled drawing earlier due to a device loss - self.state.renderer.borrow_mut().mark_drawable(); + self.state.direct_manipulation.update(); + + let events = self.state.direct_manipulation.drain_events(); + if !events.is_empty() { + if let Some(mut func) = self.state.callbacks.input.take() { + for event in events { + func(event); + } + self.state.callbacks.input.set(Some(func)); + } + } + + if force_render { + // Re-enable drawing after a device loss recovery. The forced render + // will rebuild the scene with fresh atlas textures. + self.state.renderer.borrow_mut().mark_drawable(); + } request_frame(RequestFrameOptions { require_presentation: false, force_render, diff --git a/crates/gpui_windows/src/gpui_windows.rs b/crates/gpui_windows/src/gpui_windows.rs index af7408569ab1c88fc5f433795da99354942d89f2..0af5411d20e4fbb9d326e833641a2d4e5369dcb2 100644 --- a/crates/gpui_windows/src/gpui_windows.rs +++ b/crates/gpui_windows/src/gpui_windows.rs @@ -2,6 +2,7 @@ mod clipboard; mod destination_list; +mod direct_manipulation; mod direct_write; mod directx_atlas; mod directx_devices; diff --git a/crates/gpui_windows/src/platform.rs b/crates/gpui_windows/src/platform.rs index 182107138579be858272329a75a9daededd438e4..7e9f1e77487b4185fbad9e1dc66cfcb1c8191e61 100644 --- a/crates/gpui_windows/src/platform.rs +++ b/crates/gpui_windows/src/platform.rs @@ -1326,7 +1326,15 @@ unsafe extern "system" fn window_procedure( } let inner = unsafe { &*ptr }; let result = if let Some(inner) = inner.upgrade() { - inner.handle_msg(hwnd, msg, wparam, lparam) + if cfg!(debug_assertions) { + let inner = std::panic::AssertUnwindSafe(inner); + match std::panic::catch_unwind(|| { inner }.handle_msg(hwnd, msg, wparam, lparam)) { + Ok(result) => result, + Err(_) => std::process::abort(), + } + } else { + inner.handle_msg(hwnd, msg, wparam, lparam) + } } else { unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) } }; diff --git a/crates/gpui_windows/src/shaders.hlsl b/crates/gpui_windows/src/shaders.hlsl index f508387daf9c98ffcce521209d2c981cf04db983..646cfd61cc37c31fade09d427c6d7c8f87519fa6 100644 --- a/crates/gpui_windows/src/shaders.hlsl +++ b/crates/gpui_windows/src/shaders.hlsl @@ -384,6 +384,20 @@ float4 gradient_color(Background background, break; } } + + // Dither to reduce banding in gradients (especially dark/alpha). + // Triangular-distributed noise breaks up 8-bit quantization steps. + // ±2/255 for RGB (enough for dark-on-dark compositing), + // ±3/255 for alpha (needs more because alpha × dark color = tiny steps). + { + float2 seed = position * 0.6180339887; // golden ratio spread + float r1 = frac(sin(dot(seed, float2(12.9898, 78.233))) * 43758.5453); + float r2 = frac(sin(dot(seed, float2(39.3460, 11.135))) * 24634.6345); + float tri = r1 + r2 - 1.0; // triangular PDF, range [-1, +1] + color.rgb += tri * 2.0 / 255.0; + color.a += tri * 3.0 / 255.0; + } + break; } case 2: { diff --git a/crates/gpui_windows/src/window.rs b/crates/gpui_windows/src/window.rs index 62e88c47dfc10fedf6d636e2c6d6cbdcdc2e37c5..3a55100dfb75e961f57b977297bfcd2dc2ae2701 100644 --- a/crates/gpui_windows/src/window.rs +++ b/crates/gpui_windows/src/window.rs @@ -26,6 +26,7 @@ use windows::{ core::*, }; +use crate::direct_manipulation::DirectManipulationHandler; use crate::*; use gpui::*; @@ -57,6 +58,7 @@ pub struct WindowsWindowState { pub last_reported_modifiers: Cell>, pub last_reported_capslock: Cell>, pub hovered: Cell, + pub direct_manipulation: DirectManipulationHandler, pub renderer: RefCell, @@ -131,6 +133,9 @@ impl WindowsWindowState { let fullscreen = None; let initial_placement = None; + let direct_manipulation = DirectManipulationHandler::new(hwnd, scale_factor) + .context("initializing Direct Manipulation")?; + Ok(Self { origin: Cell::new(origin), logical_size: Cell::new(logical_size), @@ -157,6 +162,7 @@ impl WindowsWindowState { initial_placement: Cell::new(initial_placement), hwnd, invalidate_devices, + direct_manipulation, }) } diff --git a/crates/grammars/Cargo.toml b/crates/grammars/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..13b3bf5c94bb459e49e5b1f337fe95b1b216829a --- /dev/null +++ b/crates/grammars/Cargo.toml @@ -0,0 +1,60 @@ +[package] +name = "grammars" +version = "0.1.0" +edition = "2024" +publish = false + +[lints] +workspace = true + +[lib] +path = "src/grammars.rs" + +[dependencies] +language_core.workspace = true +rust-embed.workspace = true +anyhow.workspace = true +toml.workspace = true +util.workspace = true + +tree-sitter = { workspace = true, optional = true } +tree-sitter-bash = { workspace = true, optional = true } +tree-sitter-c = { workspace = true, optional = true } +tree-sitter-cpp = { workspace = true, optional = true } +tree-sitter-css = { workspace = true, optional = true } +tree-sitter-diff = { workspace = true, optional = true } +tree-sitter-gitcommit = { workspace = true, optional = true } +tree-sitter-go = { workspace = true, optional = true } +tree-sitter-go-mod = { workspace = true, optional = true } +tree-sitter-gowork = { workspace = true, optional = true } +tree-sitter-jsdoc = { workspace = true, optional = true } +tree-sitter-json = { workspace = true, optional = true } +tree-sitter-md = { workspace = true, optional = true } +tree-sitter-python = { workspace = true, optional = true } +tree-sitter-regex = { workspace = true, optional = true } +tree-sitter-rust = { workspace = true, optional = true } +tree-sitter-typescript = { workspace = true, optional = true } +tree-sitter-yaml = { workspace = true, optional = true } + +[features] +load-grammars = [ + "tree-sitter", + "tree-sitter-bash", + "tree-sitter-c", + "tree-sitter-cpp", + "tree-sitter-css", + "tree-sitter-diff", + "tree-sitter-gitcommit", + "tree-sitter-go", + "tree-sitter-go-mod", + "tree-sitter-gowork", + "tree-sitter-jsdoc", + "tree-sitter-json", + "tree-sitter-md", + "tree-sitter-python", + "tree-sitter-regex", + "tree-sitter-rust", + "tree-sitter-typescript", + "tree-sitter-yaml", +] +test-support = ["load-grammars"] diff --git a/crates/eval/LICENSE-GPL b/crates/grammars/LICENSE-GPL similarity index 100% rename from crates/eval/LICENSE-GPL rename to crates/grammars/LICENSE-GPL diff --git a/crates/languages/src/bash/brackets.scm b/crates/grammars/src/bash/brackets.scm similarity index 100% rename from crates/languages/src/bash/brackets.scm rename to crates/grammars/src/bash/brackets.scm diff --git a/crates/languages/src/bash/config.toml b/crates/grammars/src/bash/config.toml similarity index 97% rename from crates/languages/src/bash/config.toml rename to crates/grammars/src/bash/config.toml index 8ff4802aee5124201d013e0b2f0b01c7046e55a0..06574629f186800f4d95244d7c4129cbc6505d22 100644 --- a/crates/languages/src/bash/config.toml +++ b/crates/grammars/src/bash/config.toml @@ -2,6 +2,7 @@ name = "Shell Script" code_fence_block_name = "bash" grammar = "bash" path_suffixes = ["sh", "bash", "bashrc", "bash_profile", "bash_aliases", "bash_logout", "bats", "profile", "zsh", "zshrc", "zshenv", "zsh_profile", "zsh_aliases", "zsh_histfile", "zlogin", "zprofile", ".env", "PKGBUILD", "APKBUILD"] +modeline_aliases = ["sh", "shell", "zsh", "fish"] line_comments = ["# "] first_line_pattern = '^#!.*\b(?:ash|bash|bats|dash|sh|zsh)\b' autoclose_before = "}])" diff --git a/crates/languages/src/bash/highlights.scm b/crates/grammars/src/bash/highlights.scm similarity index 100% rename from crates/languages/src/bash/highlights.scm rename to crates/grammars/src/bash/highlights.scm diff --git a/crates/languages/src/bash/indents.scm b/crates/grammars/src/bash/indents.scm similarity index 100% rename from crates/languages/src/bash/indents.scm rename to crates/grammars/src/bash/indents.scm diff --git a/crates/languages/src/bash/injections.scm b/crates/grammars/src/bash/injections.scm similarity index 100% rename from crates/languages/src/bash/injections.scm rename to crates/grammars/src/bash/injections.scm diff --git a/crates/languages/src/bash/overrides.scm b/crates/grammars/src/bash/overrides.scm similarity index 100% rename from crates/languages/src/bash/overrides.scm rename to crates/grammars/src/bash/overrides.scm diff --git a/crates/languages/src/bash/redactions.scm b/crates/grammars/src/bash/redactions.scm similarity index 100% rename from crates/languages/src/bash/redactions.scm rename to crates/grammars/src/bash/redactions.scm diff --git a/crates/languages/src/bash/runnables.scm b/crates/grammars/src/bash/runnables.scm similarity index 100% rename from crates/languages/src/bash/runnables.scm rename to crates/grammars/src/bash/runnables.scm diff --git a/crates/languages/src/bash/textobjects.scm b/crates/grammars/src/bash/textobjects.scm similarity index 100% rename from crates/languages/src/bash/textobjects.scm rename to crates/grammars/src/bash/textobjects.scm diff --git a/crates/languages/src/c/brackets.scm b/crates/grammars/src/c/brackets.scm similarity index 100% rename from crates/languages/src/c/brackets.scm rename to crates/grammars/src/c/brackets.scm diff --git a/crates/languages/src/c/config.toml b/crates/grammars/src/c/config.toml similarity index 96% rename from crates/languages/src/c/config.toml rename to crates/grammars/src/c/config.toml index c490269b12309632d2fd8fb944ed48ee74c46075..a3b55f4f2d4fe3bfb19100e5877661c5841126a9 100644 --- a/crates/languages/src/c/config.toml +++ b/crates/grammars/src/c/config.toml @@ -17,4 +17,3 @@ brackets = [ ] debuggers = ["CodeLLDB", "GDB"] documentation_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 } -import_path_strip_regex = "^<|>$" diff --git a/crates/languages/src/c/highlights.scm b/crates/grammars/src/c/highlights.scm similarity index 100% rename from crates/languages/src/c/highlights.scm rename to crates/grammars/src/c/highlights.scm diff --git a/crates/languages/src/c/indents.scm b/crates/grammars/src/c/indents.scm similarity index 100% rename from crates/languages/src/c/indents.scm rename to crates/grammars/src/c/indents.scm diff --git a/crates/languages/src/c/injections.scm b/crates/grammars/src/c/injections.scm similarity index 100% rename from crates/languages/src/c/injections.scm rename to crates/grammars/src/c/injections.scm diff --git a/crates/languages/src/c/outline.scm b/crates/grammars/src/c/outline.scm similarity index 100% rename from crates/languages/src/c/outline.scm rename to crates/grammars/src/c/outline.scm diff --git a/crates/languages/src/c/overrides.scm b/crates/grammars/src/c/overrides.scm similarity index 100% rename from crates/languages/src/c/overrides.scm rename to crates/grammars/src/c/overrides.scm diff --git a/crates/languages/src/c/runnables.scm b/crates/grammars/src/c/runnables.scm similarity index 100% rename from crates/languages/src/c/runnables.scm rename to crates/grammars/src/c/runnables.scm diff --git a/crates/languages/src/c/textobjects.scm b/crates/grammars/src/c/textobjects.scm similarity index 100% rename from crates/languages/src/c/textobjects.scm rename to crates/grammars/src/c/textobjects.scm diff --git a/crates/languages/src/cpp/brackets.scm b/crates/grammars/src/cpp/brackets.scm similarity index 100% rename from crates/languages/src/cpp/brackets.scm rename to crates/grammars/src/cpp/brackets.scm diff --git a/crates/languages/src/cpp/config.toml b/crates/grammars/src/cpp/config.toml similarity index 96% rename from crates/languages/src/cpp/config.toml rename to crates/grammars/src/cpp/config.toml index b8e1136725b8633f07e5f864a867dc4d7dc5d77e..138d4a78e45f153eaa2eeb72a91654416154ed33 100644 --- a/crates/languages/src/cpp/config.toml +++ b/crates/grammars/src/cpp/config.toml @@ -1,6 +1,7 @@ name = "C++" grammar = "cpp" path_suffixes = ["cc", "ccm", "hh", "cpp", "cppm", "h", "hpp", "cxx", "cxxm", "hxx", "c++", "c++m", "h++", "ipp", "inl", "ino", "ixx", "cu", "cuh", "C", "H"] +modeline_aliases = ["c++", "cpp", "cxx"] line_comments = ["// ", "/// ", "//! "] first_line_pattern = '^//.*-\*-\s*C\+\+\s*-\*-' decrease_indent_patterns = [ @@ -18,4 +19,3 @@ brackets = [ ] debuggers = ["CodeLLDB", "GDB"] documentation_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 } -import_path_strip_regex = "^<|>$" diff --git a/crates/languages/src/cpp/highlights.scm b/crates/grammars/src/cpp/highlights.scm similarity index 100% rename from crates/languages/src/cpp/highlights.scm rename to crates/grammars/src/cpp/highlights.scm diff --git a/crates/languages/src/cpp/indents.scm b/crates/grammars/src/cpp/indents.scm similarity index 100% rename from crates/languages/src/cpp/indents.scm rename to crates/grammars/src/cpp/indents.scm diff --git a/crates/languages/src/cpp/injections.scm b/crates/grammars/src/cpp/injections.scm similarity index 100% rename from crates/languages/src/cpp/injections.scm rename to crates/grammars/src/cpp/injections.scm diff --git a/crates/languages/src/cpp/outline.scm b/crates/grammars/src/cpp/outline.scm similarity index 100% rename from crates/languages/src/cpp/outline.scm rename to crates/grammars/src/cpp/outline.scm diff --git a/crates/languages/src/cpp/overrides.scm b/crates/grammars/src/cpp/overrides.scm similarity index 100% rename from crates/languages/src/cpp/overrides.scm rename to crates/grammars/src/cpp/overrides.scm diff --git a/crates/languages/src/cpp/semantic_token_rules.json b/crates/grammars/src/cpp/semantic_token_rules.json similarity index 100% rename from crates/languages/src/cpp/semantic_token_rules.json rename to crates/grammars/src/cpp/semantic_token_rules.json diff --git a/crates/languages/src/cpp/textobjects.scm b/crates/grammars/src/cpp/textobjects.scm similarity index 100% rename from crates/languages/src/cpp/textobjects.scm rename to crates/grammars/src/cpp/textobjects.scm diff --git a/crates/languages/src/css/brackets.scm b/crates/grammars/src/css/brackets.scm similarity index 100% rename from crates/languages/src/css/brackets.scm rename to crates/grammars/src/css/brackets.scm diff --git a/crates/languages/src/css/config.toml b/crates/grammars/src/css/config.toml similarity index 100% rename from crates/languages/src/css/config.toml rename to crates/grammars/src/css/config.toml diff --git a/crates/languages/src/css/highlights.scm b/crates/grammars/src/css/highlights.scm similarity index 100% rename from crates/languages/src/css/highlights.scm rename to crates/grammars/src/css/highlights.scm diff --git a/crates/languages/src/css/indents.scm b/crates/grammars/src/css/indents.scm similarity index 100% rename from crates/languages/src/css/indents.scm rename to crates/grammars/src/css/indents.scm diff --git a/crates/languages/src/css/injections.scm b/crates/grammars/src/css/injections.scm similarity index 100% rename from crates/languages/src/css/injections.scm rename to crates/grammars/src/css/injections.scm diff --git a/crates/languages/src/css/outline.scm b/crates/grammars/src/css/outline.scm similarity index 100% rename from crates/languages/src/css/outline.scm rename to crates/grammars/src/css/outline.scm diff --git a/crates/languages/src/css/overrides.scm b/crates/grammars/src/css/overrides.scm similarity index 100% rename from crates/languages/src/css/overrides.scm rename to crates/grammars/src/css/overrides.scm diff --git a/crates/languages/src/css/textobjects.scm b/crates/grammars/src/css/textobjects.scm similarity index 100% rename from crates/languages/src/css/textobjects.scm rename to crates/grammars/src/css/textobjects.scm diff --git a/crates/languages/src/diff/config.toml b/crates/grammars/src/diff/config.toml similarity index 100% rename from crates/languages/src/diff/config.toml rename to crates/grammars/src/diff/config.toml diff --git a/crates/languages/src/diff/highlights.scm b/crates/grammars/src/diff/highlights.scm similarity index 100% rename from crates/languages/src/diff/highlights.scm rename to crates/grammars/src/diff/highlights.scm diff --git a/crates/languages/src/diff/injections.scm b/crates/grammars/src/diff/injections.scm similarity index 100% rename from crates/languages/src/diff/injections.scm rename to crates/grammars/src/diff/injections.scm diff --git a/crates/languages/src/gitcommit/config.toml b/crates/grammars/src/gitcommit/config.toml similarity index 100% rename from crates/languages/src/gitcommit/config.toml rename to crates/grammars/src/gitcommit/config.toml diff --git a/crates/languages/src/gitcommit/highlights.scm b/crates/grammars/src/gitcommit/highlights.scm similarity index 100% rename from crates/languages/src/gitcommit/highlights.scm rename to crates/grammars/src/gitcommit/highlights.scm diff --git a/crates/languages/src/gitcommit/injections.scm b/crates/grammars/src/gitcommit/injections.scm similarity index 100% rename from crates/languages/src/gitcommit/injections.scm rename to crates/grammars/src/gitcommit/injections.scm diff --git a/crates/languages/src/go/brackets.scm b/crates/grammars/src/go/brackets.scm similarity index 100% rename from crates/languages/src/go/brackets.scm rename to crates/grammars/src/go/brackets.scm diff --git a/crates/languages/src/go/config.toml b/crates/grammars/src/go/config.toml similarity index 96% rename from crates/languages/src/go/config.toml rename to crates/grammars/src/go/config.toml index c8589b14d68aa66cd189940c65618b7736b4bfd7..36f885d75fe623eb16b306f0481ac7677ab0d35b 100644 --- a/crates/languages/src/go/config.toml +++ b/crates/grammars/src/go/config.toml @@ -1,6 +1,7 @@ name = "Go" grammar = "go" path_suffixes = ["go"] +modeline_aliases = ["golang"] line_comments = ["// "] first_line_pattern = '^//.*\bgo run\b' autoclose_before = ";:.,=}])>" diff --git a/crates/languages/src/go/debugger.scm b/crates/grammars/src/go/debugger.scm similarity index 100% rename from crates/languages/src/go/debugger.scm rename to crates/grammars/src/go/debugger.scm diff --git a/crates/languages/src/go/highlights.scm b/crates/grammars/src/go/highlights.scm similarity index 100% rename from crates/languages/src/go/highlights.scm rename to crates/grammars/src/go/highlights.scm diff --git a/crates/languages/src/go/indents.scm b/crates/grammars/src/go/indents.scm similarity index 100% rename from crates/languages/src/go/indents.scm rename to crates/grammars/src/go/indents.scm diff --git a/crates/languages/src/go/injections.scm b/crates/grammars/src/go/injections.scm similarity index 100% rename from crates/languages/src/go/injections.scm rename to crates/grammars/src/go/injections.scm diff --git a/crates/languages/src/go/outline.scm b/crates/grammars/src/go/outline.scm similarity index 100% rename from crates/languages/src/go/outline.scm rename to crates/grammars/src/go/outline.scm diff --git a/crates/languages/src/go/overrides.scm b/crates/grammars/src/go/overrides.scm similarity index 100% rename from crates/languages/src/go/overrides.scm rename to crates/grammars/src/go/overrides.scm diff --git a/crates/languages/src/go/runnables.scm b/crates/grammars/src/go/runnables.scm similarity index 100% rename from crates/languages/src/go/runnables.scm rename to crates/grammars/src/go/runnables.scm diff --git a/crates/languages/src/go/semantic_token_rules.json b/crates/grammars/src/go/semantic_token_rules.json similarity index 50% rename from crates/languages/src/go/semantic_token_rules.json rename to crates/grammars/src/go/semantic_token_rules.json index 627a5c5f187b47918e6a56069c5ed1bda8583aa6..612076463c25cf9219589aa83160e537cd743061 100644 --- a/crates/languages/src/go/semantic_token_rules.json +++ b/crates/grammars/src/go/semantic_token_rules.json @@ -3,5 +3,10 @@ "token_type": "variable", "token_modifiers": ["readonly"], "style": ["constant"] + }, + { + "token_type": "string", + "token_modifiers": ["format"], + "style": ["string.special"] } ] diff --git a/crates/languages/src/go/textobjects.scm b/crates/grammars/src/go/textobjects.scm similarity index 100% rename from crates/languages/src/go/textobjects.scm rename to crates/grammars/src/go/textobjects.scm diff --git a/crates/languages/src/gomod/config.toml b/crates/grammars/src/gomod/config.toml similarity index 100% rename from crates/languages/src/gomod/config.toml rename to crates/grammars/src/gomod/config.toml diff --git a/crates/languages/src/gomod/highlights.scm b/crates/grammars/src/gomod/highlights.scm similarity index 100% rename from crates/languages/src/gomod/highlights.scm rename to crates/grammars/src/gomod/highlights.scm diff --git a/crates/languages/src/gomod/injections.scm b/crates/grammars/src/gomod/injections.scm similarity index 100% rename from crates/languages/src/gomod/injections.scm rename to crates/grammars/src/gomod/injections.scm diff --git a/crates/languages/src/gomod/structure.scm b/crates/grammars/src/gomod/structure.scm similarity index 100% rename from crates/languages/src/gomod/structure.scm rename to crates/grammars/src/gomod/structure.scm diff --git a/crates/languages/src/gowork/config.toml b/crates/grammars/src/gowork/config.toml similarity index 100% rename from crates/languages/src/gowork/config.toml rename to crates/grammars/src/gowork/config.toml diff --git a/crates/languages/src/gowork/highlights.scm b/crates/grammars/src/gowork/highlights.scm similarity index 100% rename from crates/languages/src/gowork/highlights.scm rename to crates/grammars/src/gowork/highlights.scm diff --git a/crates/languages/src/gowork/injections.scm b/crates/grammars/src/gowork/injections.scm similarity index 100% rename from crates/languages/src/gowork/injections.scm rename to crates/grammars/src/gowork/injections.scm diff --git a/crates/grammars/src/grammars.rs b/crates/grammars/src/grammars.rs new file mode 100644 index 0000000000000000000000000000000000000000..00d6e6281c45b10a5dcfbd188b5848c63cc0cd75 --- /dev/null +++ b/crates/grammars/src/grammars.rs @@ -0,0 +1,108 @@ +use anyhow::Context as _; +use language_core::{LanguageConfig, LanguageQueries, QUERY_FILENAME_PREFIXES}; +use rust_embed::RustEmbed; +use util::asset_str; + +#[derive(RustEmbed)] +#[folder = "src/"] +#[exclude = "*.rs"] +struct GrammarDir; + +/// Register all built-in native tree-sitter grammars with the provided registration function. +/// +/// Each grammar is registered as a `(&str, tree_sitter_language::LanguageFn)` pair. +/// This must be called before loading language configs/queries. +#[cfg(feature = "load-grammars")] +pub fn native_grammars() -> Vec<(&'static str, tree_sitter::Language)> { + vec![ + ("bash", tree_sitter_bash::LANGUAGE.into()), + ("c", tree_sitter_c::LANGUAGE.into()), + ("cpp", tree_sitter_cpp::LANGUAGE.into()), + ("css", tree_sitter_css::LANGUAGE.into()), + ("diff", tree_sitter_diff::LANGUAGE.into()), + ("go", tree_sitter_go::LANGUAGE.into()), + ("gomod", tree_sitter_go_mod::LANGUAGE.into()), + ("gowork", tree_sitter_gowork::LANGUAGE.into()), + ("jsdoc", tree_sitter_jsdoc::LANGUAGE.into()), + ("json", tree_sitter_json::LANGUAGE.into()), + ("jsonc", tree_sitter_json::LANGUAGE.into()), + ("markdown", tree_sitter_md::LANGUAGE.into()), + ("markdown-inline", tree_sitter_md::INLINE_LANGUAGE.into()), + ("python", tree_sitter_python::LANGUAGE.into()), + ("regex", tree_sitter_regex::LANGUAGE.into()), + ("rust", tree_sitter_rust::LANGUAGE.into()), + ("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()), + ( + "typescript", + tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), + ), + ("yaml", tree_sitter_yaml::LANGUAGE.into()), + ("gitcommit", tree_sitter_gitcommit::LANGUAGE.into()), + ] +} + +/// Load and parse the `config.toml` for a given language name. +pub fn load_config(name: &str) -> LanguageConfig { + let config_toml = String::from_utf8( + GrammarDir::get(&format!("{}/config.toml", name)) + .unwrap_or_else(|| panic!("missing config for language {:?}", name)) + .data + .to_vec(), + ) + .unwrap(); + + let config: LanguageConfig = ::toml::from_str(&config_toml) + .with_context(|| format!("failed to load config.toml for language {name:?}")) + .unwrap(); + + config +} + +/// Load and parse the `config.toml` for a given language name, stripping fields +/// that require grammar support when grammars are not loaded. +pub fn load_config_for_feature(name: &str, grammars_loaded: bool) -> LanguageConfig { + let config = load_config(name); + + if grammars_loaded { + config + } else { + LanguageConfig { + name: config.name, + matcher: config.matcher, + jsx_tag_auto_close: config.jsx_tag_auto_close, + ..Default::default() + } + } +} + +/// Get a raw embedded file by path (relative to `src/`). +/// +/// Returns the file data as bytes, or `None` if the file does not exist. +pub fn get_file(path: &str) -> Option { + GrammarDir::get(path) +} + +/// Load all `.scm` query files for a given language name into a `LanguageQueries`. +/// +/// Multiple `.scm` files with the same prefix (e.g. `highlights.scm` and +/// `highlights_extra.scm`) are concatenated together with their contents appended. +pub fn load_queries(name: &str) -> LanguageQueries { + let mut result = LanguageQueries::default(); + for path in GrammarDir::iter() { + if let Some(remainder) = path.strip_prefix(name).and_then(|p| p.strip_prefix('/')) { + if !remainder.ends_with(".scm") { + continue; + } + for (prefix, query) in QUERY_FILENAME_PREFIXES { + if remainder.starts_with(prefix) { + let contents = asset_str::(path.as_ref()); + match query(&mut result) { + None => *query(&mut result) = Some(contents), + Some(existing) => existing.to_mut().push_str(contents.as_ref()), + } + } + } + } + } + result +} diff --git a/crates/languages/src/javascript/brackets.scm b/crates/grammars/src/javascript/brackets.scm similarity index 100% rename from crates/languages/src/javascript/brackets.scm rename to crates/grammars/src/javascript/brackets.scm diff --git a/crates/languages/src/javascript/config.toml b/crates/grammars/src/javascript/config.toml similarity index 97% rename from crates/languages/src/javascript/config.toml rename to crates/grammars/src/javascript/config.toml index 265f362ce4b655371471649c03c5a4a201da320c..118024494a7b8f98bcff9354fd3d27f4fc1dcfc4 100644 --- a/crates/languages/src/javascript/config.toml +++ b/crates/grammars/src/javascript/config.toml @@ -1,6 +1,7 @@ name = "JavaScript" grammar = "tsx" path_suffixes = ["js", "jsx", "mjs", "cjs"] +modeline_aliases = ["js", "js2"] # [/ ] is so we match "env node" or "/node" but not "ts-node" first_line_pattern = '^#!.*\b(?:[/ ]node|deno run.*--ext[= ]js)\b' line_comments = ["// "] @@ -23,7 +24,6 @@ tab_size = 2 scope_opt_in_language_servers = ["tailwindcss-language-server", "emmet-language-server"] prettier_parser_name = "babel" debuggers = ["JavaScript"] -import_path_strip_regex = "(?:/index)?\\.[jt]s$" [jsx_tag_auto_close] open_tag_node_name = "jsx_opening_element" diff --git a/crates/languages/src/javascript/debugger.scm b/crates/grammars/src/javascript/debugger.scm similarity index 100% rename from crates/languages/src/javascript/debugger.scm rename to crates/grammars/src/javascript/debugger.scm diff --git a/crates/languages/src/javascript/highlights.scm b/crates/grammars/src/javascript/highlights.scm similarity index 100% rename from crates/languages/src/javascript/highlights.scm rename to crates/grammars/src/javascript/highlights.scm diff --git a/crates/languages/src/javascript/indents.scm b/crates/grammars/src/javascript/indents.scm similarity index 100% rename from crates/languages/src/javascript/indents.scm rename to crates/grammars/src/javascript/indents.scm diff --git a/crates/languages/src/javascript/injections.scm b/crates/grammars/src/javascript/injections.scm similarity index 100% rename from crates/languages/src/javascript/injections.scm rename to crates/grammars/src/javascript/injections.scm diff --git a/crates/languages/src/javascript/outline.scm b/crates/grammars/src/javascript/outline.scm similarity index 100% rename from crates/languages/src/javascript/outline.scm rename to crates/grammars/src/javascript/outline.scm diff --git a/crates/languages/src/javascript/overrides.scm b/crates/grammars/src/javascript/overrides.scm similarity index 100% rename from crates/languages/src/javascript/overrides.scm rename to crates/grammars/src/javascript/overrides.scm diff --git a/crates/languages/src/javascript/runnables.scm b/crates/grammars/src/javascript/runnables.scm similarity index 100% rename from crates/languages/src/javascript/runnables.scm rename to crates/grammars/src/javascript/runnables.scm diff --git a/crates/languages/src/javascript/textobjects.scm b/crates/grammars/src/javascript/textobjects.scm similarity index 100% rename from crates/languages/src/javascript/textobjects.scm rename to crates/grammars/src/javascript/textobjects.scm diff --git a/crates/languages/src/jsdoc/brackets.scm b/crates/grammars/src/jsdoc/brackets.scm similarity index 100% rename from crates/languages/src/jsdoc/brackets.scm rename to crates/grammars/src/jsdoc/brackets.scm diff --git a/crates/languages/src/jsdoc/config.toml b/crates/grammars/src/jsdoc/config.toml similarity index 100% rename from crates/languages/src/jsdoc/config.toml rename to crates/grammars/src/jsdoc/config.toml diff --git a/crates/languages/src/jsdoc/highlights.scm b/crates/grammars/src/jsdoc/highlights.scm similarity index 100% rename from crates/languages/src/jsdoc/highlights.scm rename to crates/grammars/src/jsdoc/highlights.scm diff --git a/crates/languages/src/json/brackets.scm b/crates/grammars/src/json/brackets.scm similarity index 100% rename from crates/languages/src/json/brackets.scm rename to crates/grammars/src/json/brackets.scm diff --git a/crates/languages/src/json/config.toml b/crates/grammars/src/json/config.toml similarity index 100% rename from crates/languages/src/json/config.toml rename to crates/grammars/src/json/config.toml diff --git a/crates/languages/src/json/highlights.scm b/crates/grammars/src/json/highlights.scm similarity index 100% rename from crates/languages/src/json/highlights.scm rename to crates/grammars/src/json/highlights.scm diff --git a/crates/languages/src/json/indents.scm b/crates/grammars/src/json/indents.scm similarity index 100% rename from crates/languages/src/json/indents.scm rename to crates/grammars/src/json/indents.scm diff --git a/crates/languages/src/json/outline.scm b/crates/grammars/src/json/outline.scm similarity index 100% rename from crates/languages/src/json/outline.scm rename to crates/grammars/src/json/outline.scm diff --git a/crates/languages/src/json/overrides.scm b/crates/grammars/src/json/overrides.scm similarity index 100% rename from crates/languages/src/json/overrides.scm rename to crates/grammars/src/json/overrides.scm diff --git a/crates/languages/src/json/redactions.scm b/crates/grammars/src/json/redactions.scm similarity index 100% rename from crates/languages/src/json/redactions.scm rename to crates/grammars/src/json/redactions.scm diff --git a/crates/languages/src/json/runnables.scm b/crates/grammars/src/json/runnables.scm similarity index 100% rename from crates/languages/src/json/runnables.scm rename to crates/grammars/src/json/runnables.scm diff --git a/crates/languages/src/json/textobjects.scm b/crates/grammars/src/json/textobjects.scm similarity index 100% rename from crates/languages/src/json/textobjects.scm rename to crates/grammars/src/json/textobjects.scm diff --git a/crates/languages/src/jsonc/brackets.scm b/crates/grammars/src/jsonc/brackets.scm similarity index 100% rename from crates/languages/src/jsonc/brackets.scm rename to crates/grammars/src/jsonc/brackets.scm diff --git a/crates/languages/src/jsonc/config.toml b/crates/grammars/src/jsonc/config.toml similarity index 100% rename from crates/languages/src/jsonc/config.toml rename to crates/grammars/src/jsonc/config.toml diff --git a/crates/languages/src/jsonc/highlights.scm b/crates/grammars/src/jsonc/highlights.scm similarity index 100% rename from crates/languages/src/jsonc/highlights.scm rename to crates/grammars/src/jsonc/highlights.scm diff --git a/crates/languages/src/jsonc/indents.scm b/crates/grammars/src/jsonc/indents.scm similarity index 100% rename from crates/languages/src/jsonc/indents.scm rename to crates/grammars/src/jsonc/indents.scm diff --git a/crates/languages/src/jsonc/injections.scm b/crates/grammars/src/jsonc/injections.scm similarity index 100% rename from crates/languages/src/jsonc/injections.scm rename to crates/grammars/src/jsonc/injections.scm diff --git a/crates/languages/src/jsonc/outline.scm b/crates/grammars/src/jsonc/outline.scm similarity index 100% rename from crates/languages/src/jsonc/outline.scm rename to crates/grammars/src/jsonc/outline.scm diff --git a/crates/languages/src/jsonc/overrides.scm b/crates/grammars/src/jsonc/overrides.scm similarity index 100% rename from crates/languages/src/jsonc/overrides.scm rename to crates/grammars/src/jsonc/overrides.scm diff --git a/crates/languages/src/jsonc/redactions.scm b/crates/grammars/src/jsonc/redactions.scm similarity index 100% rename from crates/languages/src/jsonc/redactions.scm rename to crates/grammars/src/jsonc/redactions.scm diff --git a/crates/languages/src/jsonc/textobjects.scm b/crates/grammars/src/jsonc/textobjects.scm similarity index 100% rename from crates/languages/src/jsonc/textobjects.scm rename to crates/grammars/src/jsonc/textobjects.scm diff --git a/crates/languages/src/markdown-inline/config.toml b/crates/grammars/src/markdown-inline/config.toml similarity index 100% rename from crates/languages/src/markdown-inline/config.toml rename to crates/grammars/src/markdown-inline/config.toml diff --git a/crates/languages/src/markdown-inline/highlights.scm b/crates/grammars/src/markdown-inline/highlights.scm similarity index 100% rename from crates/languages/src/markdown-inline/highlights.scm rename to crates/grammars/src/markdown-inline/highlights.scm diff --git a/crates/languages/src/markdown-inline/injections.scm b/crates/grammars/src/markdown-inline/injections.scm similarity index 100% rename from crates/languages/src/markdown-inline/injections.scm rename to crates/grammars/src/markdown-inline/injections.scm diff --git a/crates/languages/src/markdown/brackets.scm b/crates/grammars/src/markdown/brackets.scm similarity index 100% rename from crates/languages/src/markdown/brackets.scm rename to crates/grammars/src/markdown/brackets.scm diff --git a/crates/languages/src/markdown/config.toml b/crates/grammars/src/markdown/config.toml similarity index 98% rename from crates/languages/src/markdown/config.toml rename to crates/grammars/src/markdown/config.toml index 5e7acd230b6f191aebff609bbc1087fbff8d3909..27dd1821e414fb8e068c3c1975ec6189d80c0350 100644 --- a/crates/languages/src/markdown/config.toml +++ b/crates/grammars/src/markdown/config.toml @@ -1,6 +1,7 @@ name = "Markdown" grammar = "markdown" path_suffixes = ["md", "mdx", "mdwn", "mdc", "markdown", "MD"] +modeline_aliases = ["md"] completion_query_characters = ["-"] block_comment = { start = "", tab_size = 0 } autoclose_before = ";:.,=}])>" diff --git a/crates/languages/src/markdown/highlights.scm b/crates/grammars/src/markdown/highlights.scm similarity index 100% rename from crates/languages/src/markdown/highlights.scm rename to crates/grammars/src/markdown/highlights.scm diff --git a/crates/languages/src/markdown/indents.scm b/crates/grammars/src/markdown/indents.scm similarity index 100% rename from crates/languages/src/markdown/indents.scm rename to crates/grammars/src/markdown/indents.scm diff --git a/crates/languages/src/markdown/injections.scm b/crates/grammars/src/markdown/injections.scm similarity index 100% rename from crates/languages/src/markdown/injections.scm rename to crates/grammars/src/markdown/injections.scm diff --git a/crates/languages/src/markdown/outline.scm b/crates/grammars/src/markdown/outline.scm similarity index 100% rename from crates/languages/src/markdown/outline.scm rename to crates/grammars/src/markdown/outline.scm diff --git a/crates/languages/src/markdown/textobjects.scm b/crates/grammars/src/markdown/textobjects.scm similarity index 100% rename from crates/languages/src/markdown/textobjects.scm rename to crates/grammars/src/markdown/textobjects.scm diff --git a/crates/languages/src/python/brackets.scm b/crates/grammars/src/python/brackets.scm similarity index 100% rename from crates/languages/src/python/brackets.scm rename to crates/grammars/src/python/brackets.scm diff --git a/crates/languages/src/python/config.toml b/crates/grammars/src/python/config.toml similarity index 98% rename from crates/languages/src/python/config.toml rename to crates/grammars/src/python/config.toml index d96a5ea5fefd0814c4b0787251e5cf6e4c166d5e..0c2072393bf6cc1db6b152d80779cd7c81af1a7e 100644 --- a/crates/languages/src/python/config.toml +++ b/crates/grammars/src/python/config.toml @@ -2,6 +2,7 @@ name = "Python" grammar = "python" path_suffixes = ["py", "pyi", "mpy"] first_line_pattern = '^#!.*((\bpython[0-9.]*\b)|(\buv run\b))' +modeline_aliases = ["py"] line_comments = ["# "] autoclose_before = ";:.,=}])>" brackets = [ @@ -35,4 +36,3 @@ decrease_indent_patterns = [ { pattern = "^\\s*except\\b.*:\\s*(#.*)?", valid_after = ["try", "except"] }, { pattern = "^\\s*finally\\b.*:\\s*(#.*)?", valid_after = ["try", "except", "else"] }, ] -import_path_strip_regex = "/__init__\\.py$" diff --git a/crates/languages/src/python/debugger.scm b/crates/grammars/src/python/debugger.scm similarity index 100% rename from crates/languages/src/python/debugger.scm rename to crates/grammars/src/python/debugger.scm diff --git a/crates/languages/src/python/highlights.scm b/crates/grammars/src/python/highlights.scm similarity index 100% rename from crates/languages/src/python/highlights.scm rename to crates/grammars/src/python/highlights.scm diff --git a/crates/languages/src/python/indents.scm b/crates/grammars/src/python/indents.scm similarity index 100% rename from crates/languages/src/python/indents.scm rename to crates/grammars/src/python/indents.scm diff --git a/crates/languages/src/python/injections.scm b/crates/grammars/src/python/injections.scm similarity index 100% rename from crates/languages/src/python/injections.scm rename to crates/grammars/src/python/injections.scm diff --git a/crates/languages/src/python/outline.scm b/crates/grammars/src/python/outline.scm similarity index 100% rename from crates/languages/src/python/outline.scm rename to crates/grammars/src/python/outline.scm diff --git a/crates/languages/src/python/overrides.scm b/crates/grammars/src/python/overrides.scm similarity index 100% rename from crates/languages/src/python/overrides.scm rename to crates/grammars/src/python/overrides.scm diff --git a/crates/languages/src/python/runnables.scm b/crates/grammars/src/python/runnables.scm similarity index 100% rename from crates/languages/src/python/runnables.scm rename to crates/grammars/src/python/runnables.scm diff --git a/crates/grammars/src/python/semantic_token_rules.json b/crates/grammars/src/python/semantic_token_rules.json new file mode 100644 index 0000000000000000000000000000000000000000..b73bae962fe00f1ffde22852d7809d6d8228af63 --- /dev/null +++ b/crates/grammars/src/python/semantic_token_rules.json @@ -0,0 +1,15 @@ +[ + { + "token_type": "selfParameter", + "style": ["variable.special"] + }, + { + "token_type": "clsParameter", + "style": ["variable.special"] + }, + // ty specific + { + "token_type": "builtinConstant", + "style": ["constant.builtin"] + } +] diff --git a/crates/languages/src/python/textobjects.scm b/crates/grammars/src/python/textobjects.scm similarity index 100% rename from crates/languages/src/python/textobjects.scm rename to crates/grammars/src/python/textobjects.scm diff --git a/crates/languages/src/regex/brackets.scm b/crates/grammars/src/regex/brackets.scm similarity index 100% rename from crates/languages/src/regex/brackets.scm rename to crates/grammars/src/regex/brackets.scm diff --git a/crates/languages/src/regex/config.toml b/crates/grammars/src/regex/config.toml similarity index 100% rename from crates/languages/src/regex/config.toml rename to crates/grammars/src/regex/config.toml diff --git a/crates/languages/src/regex/highlights.scm b/crates/grammars/src/regex/highlights.scm similarity index 100% rename from crates/languages/src/regex/highlights.scm rename to crates/grammars/src/regex/highlights.scm diff --git a/crates/languages/src/rust/brackets.scm b/crates/grammars/src/rust/brackets.scm similarity index 100% rename from crates/languages/src/rust/brackets.scm rename to crates/grammars/src/rust/brackets.scm diff --git a/crates/languages/src/rust/config.toml b/crates/grammars/src/rust/config.toml similarity index 92% rename from crates/languages/src/rust/config.toml rename to crates/grammars/src/rust/config.toml index 826a219e9868a3f76a063efe8c91cec0be14c2da..f739b370f4b5c3fe7bc53f4818ffabedfa1bbd0b 100644 --- a/crates/languages/src/rust/config.toml +++ b/crates/grammars/src/rust/config.toml @@ -1,6 +1,7 @@ name = "Rust" grammar = "rust" path_suffixes = ["rs"] +modeline_aliases = ["rs", "rustic"] line_comments = ["// ", "/// ", "//! "] autoclose_before = ";:.,=}])>" brackets = [ @@ -17,5 +18,3 @@ brackets = [ collapsed_placeholder = " /* ... */ " debuggers = ["CodeLLDB", "GDB"] documentation_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 } -ignored_import_segments = ["crate", "super"] -import_path_strip_regex = "/(lib|mod)\\.rs$" diff --git a/crates/languages/src/rust/debugger.scm b/crates/grammars/src/rust/debugger.scm similarity index 100% rename from crates/languages/src/rust/debugger.scm rename to crates/grammars/src/rust/debugger.scm diff --git a/crates/languages/src/rust/highlights.scm b/crates/grammars/src/rust/highlights.scm similarity index 100% rename from crates/languages/src/rust/highlights.scm rename to crates/grammars/src/rust/highlights.scm diff --git a/crates/languages/src/rust/indents.scm b/crates/grammars/src/rust/indents.scm similarity index 100% rename from crates/languages/src/rust/indents.scm rename to crates/grammars/src/rust/indents.scm diff --git a/crates/languages/src/rust/injections.scm b/crates/grammars/src/rust/injections.scm similarity index 97% rename from crates/languages/src/rust/injections.scm rename to crates/grammars/src/rust/injections.scm index c50694dc9e0b90d3e31bc1147e59eea7ff402efa..89d839282d3388f450f9ebdb923167f0986f349c 100644 --- a/crates/languages/src/rust/injections.scm +++ b/crates/grammars/src/rust/injections.scm @@ -10,7 +10,7 @@ (scoped_identifier (identifier) @_macro_name .) ] - (#not-any-of? @_macro_name "view" "html" "bsn") + (#not-any-of? @_macro_name "view" "html") (token_tree) @injection.content (#set! injection.language "rust")) diff --git a/crates/languages/src/rust/outline.scm b/crates/grammars/src/rust/outline.scm similarity index 100% rename from crates/languages/src/rust/outline.scm rename to crates/grammars/src/rust/outline.scm diff --git a/crates/languages/src/rust/overrides.scm b/crates/grammars/src/rust/overrides.scm similarity index 100% rename from crates/languages/src/rust/overrides.scm rename to crates/grammars/src/rust/overrides.scm diff --git a/crates/languages/src/rust/runnables.scm b/crates/grammars/src/rust/runnables.scm similarity index 100% rename from crates/languages/src/rust/runnables.scm rename to crates/grammars/src/rust/runnables.scm diff --git a/crates/languages/src/rust/semantic_token_rules.json b/crates/grammars/src/rust/semantic_token_rules.json similarity index 100% rename from crates/languages/src/rust/semantic_token_rules.json rename to crates/grammars/src/rust/semantic_token_rules.json diff --git a/crates/languages/src/rust/textobjects.scm b/crates/grammars/src/rust/textobjects.scm similarity index 100% rename from crates/languages/src/rust/textobjects.scm rename to crates/grammars/src/rust/textobjects.scm diff --git a/crates/languages/src/tsx/brackets.scm b/crates/grammars/src/tsx/brackets.scm similarity index 100% rename from crates/languages/src/tsx/brackets.scm rename to crates/grammars/src/tsx/brackets.scm diff --git a/crates/languages/src/tsx/config.toml b/crates/grammars/src/tsx/config.toml similarity index 98% rename from crates/languages/src/tsx/config.toml rename to crates/grammars/src/tsx/config.toml index d0a4eb6532db621d741df2fbc99125e1c037ccdf..42438fdf890a98f319244332f384f574e02c2904 100644 --- a/crates/languages/src/tsx/config.toml +++ b/crates/grammars/src/tsx/config.toml @@ -1,6 +1,7 @@ name = "TSX" grammar = "tsx" path_suffixes = ["tsx"] +modeline_aliases = ["typescript-txs"] line_comments = ["// "] block_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 } documentation_comment = { start = "/**", prefix = "* ", end = "*/", tab_size = 1 } diff --git a/crates/languages/src/tsx/debugger.scm b/crates/grammars/src/tsx/debugger.scm similarity index 100% rename from crates/languages/src/tsx/debugger.scm rename to crates/grammars/src/tsx/debugger.scm diff --git a/crates/languages/src/tsx/highlights.scm b/crates/grammars/src/tsx/highlights.scm similarity index 100% rename from crates/languages/src/tsx/highlights.scm rename to crates/grammars/src/tsx/highlights.scm diff --git a/crates/languages/src/tsx/indents.scm b/crates/grammars/src/tsx/indents.scm similarity index 100% rename from crates/languages/src/tsx/indents.scm rename to crates/grammars/src/tsx/indents.scm diff --git a/crates/languages/src/tsx/injections.scm b/crates/grammars/src/tsx/injections.scm similarity index 100% rename from crates/languages/src/tsx/injections.scm rename to crates/grammars/src/tsx/injections.scm diff --git a/crates/languages/src/tsx/outline.scm b/crates/grammars/src/tsx/outline.scm similarity index 100% rename from crates/languages/src/tsx/outline.scm rename to crates/grammars/src/tsx/outline.scm diff --git a/crates/languages/src/tsx/overrides.scm b/crates/grammars/src/tsx/overrides.scm similarity index 100% rename from crates/languages/src/tsx/overrides.scm rename to crates/grammars/src/tsx/overrides.scm diff --git a/crates/languages/src/tsx/runnables.scm b/crates/grammars/src/tsx/runnables.scm similarity index 100% rename from crates/languages/src/tsx/runnables.scm rename to crates/grammars/src/tsx/runnables.scm diff --git a/crates/languages/src/tsx/textobjects.scm b/crates/grammars/src/tsx/textobjects.scm similarity index 100% rename from crates/languages/src/tsx/textobjects.scm rename to crates/grammars/src/tsx/textobjects.scm diff --git a/crates/languages/src/typescript/brackets.scm b/crates/grammars/src/typescript/brackets.scm similarity index 100% rename from crates/languages/src/typescript/brackets.scm rename to crates/grammars/src/typescript/brackets.scm diff --git a/crates/languages/src/typescript/config.toml b/crates/grammars/src/typescript/config.toml similarity index 96% rename from crates/languages/src/typescript/config.toml rename to crates/grammars/src/typescript/config.toml index 67656e6a538da6c8860e9ab1b08fd6e6ee9cabbd..473a347cdd611d096e5fb3b584c2f0990da185de 100644 --- a/crates/languages/src/typescript/config.toml +++ b/crates/grammars/src/typescript/config.toml @@ -1,6 +1,7 @@ name = "TypeScript" grammar = "typescript" path_suffixes = ["ts", "cts", "mts"] +modeline_aliases = ["ts"] first_line_pattern = '^#!.*\b(?:deno run|ts-node|bun|tsx|[/ ]node)\b' line_comments = ["// "] block_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 } @@ -22,7 +23,6 @@ prettier_parser_name = "typescript" tab_size = 2 debuggers = ["JavaScript"] scope_opt_in_language_servers = ["tailwindcss-language-server"] -import_path_strip_regex = "(?:/index)?\\.[jt]s$" [overrides.string] completion_query_characters = ["-", "."] diff --git a/crates/languages/src/typescript/debugger.scm b/crates/grammars/src/typescript/debugger.scm similarity index 100% rename from crates/languages/src/typescript/debugger.scm rename to crates/grammars/src/typescript/debugger.scm diff --git a/crates/languages/src/typescript/highlights.scm b/crates/grammars/src/typescript/highlights.scm similarity index 100% rename from crates/languages/src/typescript/highlights.scm rename to crates/grammars/src/typescript/highlights.scm diff --git a/crates/languages/src/typescript/indents.scm b/crates/grammars/src/typescript/indents.scm similarity index 100% rename from crates/languages/src/typescript/indents.scm rename to crates/grammars/src/typescript/indents.scm diff --git a/crates/languages/src/typescript/injections.scm b/crates/grammars/src/typescript/injections.scm similarity index 100% rename from crates/languages/src/typescript/injections.scm rename to crates/grammars/src/typescript/injections.scm diff --git a/crates/languages/src/typescript/outline.scm b/crates/grammars/src/typescript/outline.scm similarity index 100% rename from crates/languages/src/typescript/outline.scm rename to crates/grammars/src/typescript/outline.scm diff --git a/crates/languages/src/typescript/overrides.scm b/crates/grammars/src/typescript/overrides.scm similarity index 100% rename from crates/languages/src/typescript/overrides.scm rename to crates/grammars/src/typescript/overrides.scm diff --git a/crates/languages/src/typescript/runnables.scm b/crates/grammars/src/typescript/runnables.scm similarity index 100% rename from crates/languages/src/typescript/runnables.scm rename to crates/grammars/src/typescript/runnables.scm diff --git a/crates/languages/src/typescript/textobjects.scm b/crates/grammars/src/typescript/textobjects.scm similarity index 100% rename from crates/languages/src/typescript/textobjects.scm rename to crates/grammars/src/typescript/textobjects.scm diff --git a/crates/languages/src/yaml/brackets.scm b/crates/grammars/src/yaml/brackets.scm similarity index 100% rename from crates/languages/src/yaml/brackets.scm rename to crates/grammars/src/yaml/brackets.scm diff --git a/crates/languages/src/yaml/config.toml b/crates/grammars/src/yaml/config.toml similarity index 96% rename from crates/languages/src/yaml/config.toml rename to crates/grammars/src/yaml/config.toml index 9a07a560b06766ac00dd73b6210023c4cddd491d..95fe81d04dbbb88e1c7deed7a84895cddb7dea1d 100644 --- a/crates/languages/src/yaml/config.toml +++ b/crates/grammars/src/yaml/config.toml @@ -1,6 +1,7 @@ name = "YAML" grammar = "yaml" path_suffixes = ["yml", "yaml", "pixi.lock", "clang-format", "clangd", "bst"] +modeline_aliases = ["yml"] line_comments = ["# "] autoclose_before = ",]}" brackets = [ diff --git a/crates/languages/src/yaml/highlights.scm b/crates/grammars/src/yaml/highlights.scm similarity index 100% rename from crates/languages/src/yaml/highlights.scm rename to crates/grammars/src/yaml/highlights.scm diff --git a/crates/languages/src/yaml/injections.scm b/crates/grammars/src/yaml/injections.scm similarity index 100% rename from crates/languages/src/yaml/injections.scm rename to crates/grammars/src/yaml/injections.scm diff --git a/crates/languages/src/yaml/outline.scm b/crates/grammars/src/yaml/outline.scm similarity index 100% rename from crates/languages/src/yaml/outline.scm rename to crates/grammars/src/yaml/outline.scm diff --git a/crates/languages/src/yaml/overrides.scm b/crates/grammars/src/yaml/overrides.scm similarity index 100% rename from crates/languages/src/yaml/overrides.scm rename to crates/grammars/src/yaml/overrides.scm diff --git a/crates/languages/src/yaml/redactions.scm b/crates/grammars/src/yaml/redactions.scm similarity index 100% rename from crates/languages/src/yaml/redactions.scm rename to crates/grammars/src/yaml/redactions.scm diff --git a/crates/languages/src/yaml/textobjects.scm b/crates/grammars/src/yaml/textobjects.scm similarity index 100% rename from crates/languages/src/yaml/textobjects.scm rename to crates/grammars/src/yaml/textobjects.scm diff --git a/crates/languages/src/zed-keybind-context/brackets.scm b/crates/grammars/src/zed-keybind-context/brackets.scm similarity index 100% rename from crates/languages/src/zed-keybind-context/brackets.scm rename to crates/grammars/src/zed-keybind-context/brackets.scm diff --git a/crates/languages/src/zed-keybind-context/config.toml b/crates/grammars/src/zed-keybind-context/config.toml similarity index 100% rename from crates/languages/src/zed-keybind-context/config.toml rename to crates/grammars/src/zed-keybind-context/config.toml diff --git a/crates/languages/src/zed-keybind-context/highlights.scm b/crates/grammars/src/zed-keybind-context/highlights.scm similarity index 100% rename from crates/languages/src/zed-keybind-context/highlights.scm rename to crates/grammars/src/zed-keybind-context/highlights.scm diff --git a/crates/http_client/src/github.rs b/crates/http_client/src/github.rs index e52e2f1d2555de477cd4597826bc3bd8308faf89..6d2150c8566706188b907e0c9c9ddb8e603f867b 100644 --- a/crates/http_client/src/github.rs +++ b/crates/http_client/src/github.rs @@ -144,6 +144,7 @@ pub async fn get_release_by_tag_name( #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum AssetKind { TarGz, + TarBz2, Gz, Zip, } @@ -158,6 +159,7 @@ pub fn build_asset_url(repo_name_with_owner: &str, tag: &str, kind: AssetKind) - "{tag}.{extension}", extension = match kind { AssetKind::TarGz => "tar.gz", + AssetKind::TarBz2 => "tar.bz2", AssetKind::Gz => "gz", AssetKind::Zip => "zip", } diff --git a/crates/http_client/src/github_download.rs b/crates/http_client/src/github_download.rs index 2ef615ff64c2b564e5c254b9c6ef21413d18bcf2..47ae2c2b36b1ab37b56ab70735c2ce018bc5e275 100644 --- a/crates/http_client/src/github_download.rs +++ b/crates/http_client/src/github_download.rs @@ -5,7 +5,7 @@ use std::{ }; use anyhow::{Context, Result}; -use async_compression::futures::bufread::GzipDecoder; +use async_compression::futures::bufread::{BzDecoder, GzipDecoder}; use futures::{AsyncRead, AsyncSeek, AsyncSeekExt, AsyncWrite, io::BufReader}; use sha2::{Digest, Sha256}; @@ -119,7 +119,7 @@ async fn extract_to_staging( fn staging_path(parent: &Path, asset_kind: AssetKind) -> Result { match asset_kind { - AssetKind::TarGz | AssetKind::Zip => { + AssetKind::TarGz | AssetKind::TarBz2 | AssetKind::Zip => { let dir = tempfile::Builder::new() .prefix(".tmp-github-download-") .tempdir_in(parent) @@ -141,7 +141,7 @@ fn staging_path(parent: &Path, asset_kind: AssetKind) -> Result { async fn cleanup_staging_path(staging_path: &Path, asset_kind: AssetKind) { match asset_kind { - AssetKind::TarGz | AssetKind::Zip => { + AssetKind::TarGz | AssetKind::TarBz2 | AssetKind::Zip => { if let Err(err) = async_fs::remove_dir_all(staging_path).await { log::warn!("failed to remove staging directory {staging_path:?}: {err:?}"); } @@ -170,6 +170,7 @@ async fn stream_response_archive( ) -> Result<()> { match asset_kind { AssetKind::TarGz => extract_tar_gz(destination_path, url, response).await?, + AssetKind::TarBz2 => extract_tar_bz2(destination_path, url, response).await?, AssetKind::Gz => extract_gz(destination_path, url, response).await?, AssetKind::Zip => { util::archive::extract_zip(destination_path, response).await?; @@ -186,6 +187,7 @@ async fn stream_file_archive( ) -> Result<()> { match asset_kind { AssetKind::TarGz => extract_tar_gz(destination_path, url, file_archive).await?, + AssetKind::TarBz2 => extract_tar_bz2(destination_path, url, file_archive).await?, AssetKind::Gz => extract_gz(destination_path, url, file_archive).await?, #[cfg(not(windows))] AssetKind::Zip => { @@ -213,6 +215,20 @@ async fn extract_tar_gz( Ok(()) } +async fn extract_tar_bz2( + destination_path: &Path, + url: &str, + from: impl AsyncRead + Unpin, +) -> Result<(), anyhow::Error> { + let decompressed_bytes = BzDecoder::new(BufReader::new(from)); + let archive = async_tar::Archive::new(decompressed_bytes); + archive + .unpack(&destination_path) + .await + .with_context(|| format!("extracting {url} to {destination_path:?}"))?; + Ok(()) +} + async fn extract_gz( destination_path: &Path, url: &str, diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index 1aefd7fde212ac068562c9a2fc6b612ca9b06330..89932125c1bfbc05202038c1abac2a6380e19e93 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -9,7 +9,6 @@ use strum::{EnumIter, EnumString, IntoStaticStr}; #[strum(serialize_all = "snake_case")] pub enum IconName { AcpRegistry, - Ai, AiAnthropic, AiBedrock, AiClaude, @@ -54,6 +53,7 @@ pub enum IconName { Book, BookCopy, Box, + BoxOpen, CaseSensitive, Chat, Check, @@ -69,7 +69,6 @@ pub enum IconName { Close, CloudDownload, Code, - Cog, Command, Control, Copilot, @@ -106,7 +105,6 @@ pub enum IconName { EditorSublime, EditorVsCode, Ellipsis, - EllipsisVertical, Envelope, Eraser, Escape, @@ -133,6 +131,7 @@ pub enum IconName { FileTree, Filter, Flame, + Focus, Folder, FolderOpen, FolderPlus, @@ -177,7 +176,6 @@ pub enum IconName { MagnifyingGlass, Maximize, Menu, - MenuAlt, MenuAltTemp, Mic, MicMute, @@ -219,7 +217,6 @@ pub enum IconName { Send, Server, Settings, - ShieldCheck, Shift, SignalHigh, SignalLow, @@ -236,16 +233,9 @@ pub enum IconName { Star, StarFilled, Stop, - SwatchBook, - SweepAi, - SweepAiDisabled, - SweepAiDown, - SweepAiError, - SweepAiUp, Tab, Terminal, TerminalAlt, - TerminalGhost, TextSnippet, TextThread, ThinkingMode, @@ -268,8 +258,6 @@ pub enum IconName { ToolHammer, ToolNotification, ToolPencil, - ToolRead, - ToolRegex, ToolSearch, ToolTerminal, ToolThink, @@ -296,7 +284,6 @@ pub enum IconName { ZedPredictUp, ZedSrcCustom, ZedSrcExtension, - ZedXCopilot, } impl IconName { diff --git a/crates/image_viewer/Cargo.toml b/crates/image_viewer/Cargo.toml index 92386e8ba8a38f79711ee50343a6e7cf4a393cbd..8d9df8c9edd194f43c3cd4c157f6c7fecc494de4 100644 --- a/crates/image_viewer/Cargo.toml +++ b/crates/image_viewer/Cargo.toml @@ -26,7 +26,7 @@ log.workspace = true project.workspace = true serde.workspace = true settings.workspace = true -theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index 8d619c82dfdac660a10210e375a8edf9bb97eee9..dc8d22b67270a58155c05eaf25cb450166e8eb51 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -6,20 +6,18 @@ use std::path::Path; use anyhow::Context as _; use editor::{EditorSettings, items::entry_git_aware_label_color}; use file_icons::FileIcons; -#[cfg(any(target_os = "linux", target_os = "macos"))] -use gpui::PinchEvent; use gpui::{ AnyElement, App, Bounds, Context, DispatchPhase, Element, ElementId, Entity, EventEmitter, FocusHandle, Focusable, Font, GlobalElementId, InspectorElementId, InteractiveElement, IntoElement, LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, - ParentElement, Pixels, Point, Render, ScrollDelta, ScrollWheelEvent, Style, Styled, Task, - WeakEntity, Window, actions, checkerboard, div, img, point, px, size, + ParentElement, PinchEvent, Pixels, Point, Render, ScrollDelta, ScrollWheelEvent, Style, Styled, + Task, WeakEntity, Window, actions, checkerboard, div, img, point, px, size, }; use language::File as _; use persistence::ImageViewerDb; use project::{ImageItem, Project, ProjectPath, image_store::ImageItemEvent}; use settings::Settings; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{Tooltip, prelude::*}; use util::paths::PathExt; use workspace::{ @@ -263,7 +261,6 @@ impl ImageView { } } - #[cfg(any(target_os = "linux", target_os = "macos"))] fn handle_pinch(&mut self, event: &PinchEvent, _window: &mut Window, cx: &mut Context) { let zoom_factor = 1.0 + event.delta; self.set_zoom(self.zoom_level * zoom_factor, Some(event.position), cx); @@ -685,7 +682,6 @@ impl Render for ImageView { .relative() .bg(cx.theme().colors().editor_background) .child({ - #[cfg(any(target_os = "linux", target_os = "macos"))] let container = div() .id("image-container") .size_full() @@ -704,24 +700,6 @@ impl Render for ImageView { .on_mouse_move(cx.listener(Self::handle_mouse_move)) .child(ImageContentElement::new(cx.entity())); - #[cfg(not(any(target_os = "linux", target_os = "macos")))] - let container = div() - .id("image-container") - .size_full() - .overflow_hidden() - .cursor(if self.is_dragging() { - gpui::CursorStyle::ClosedHand - } else { - gpui::CursorStyle::OpenHand - }) - .on_scroll_wheel(cx.listener(Self::handle_scroll_wheel)) - .on_mouse_down(MouseButton::Left, cx.listener(Self::handle_mouse_down)) - .on_mouse_down(MouseButton::Middle, cx.listener(Self::handle_mouse_down)) - .on_mouse_up(MouseButton::Left, cx.listener(Self::handle_mouse_up)) - .on_mouse_up(MouseButton::Middle, cx.listener(Self::handle_mouse_up)) - .on_mouse_move(cx.listener(Self::handle_mouse_move)) - .child(ImageContentElement::new(cx.entity())); - container }) } diff --git a/crates/inspector_ui/Cargo.toml b/crates/inspector_ui/Cargo.toml index 53d2f74b9c663496da083152ead17d479f5030eb..ec1f01195c82366a48a1ffa46397c6ce91ea6339 100644 --- a/crates/inspector_ui/Cargo.toml +++ b/crates/inspector_ui/Cargo.toml @@ -21,7 +21,7 @@ language.workspace = true project.workspace = true serde_json.workspace = true serde_json_lenient.workspace = true -theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true util_macros.workspace = true diff --git a/crates/inspector_ui/src/inspector.rs b/crates/inspector_ui/src/inspector.rs index 3c90bd7d6c6d550140df85c4c7547bd5b5700149..b687ea70a57d0f1b8ea97e4767d98eb701b77080 100644 --- a/crates/inspector_ui/src/inspector.rs +++ b/crates/inspector_ui/src/inspector.rs @@ -57,7 +57,7 @@ fn render_inspector( window: &mut Window, cx: &mut Context, ) -> AnyElement { - let ui_font = theme::setup_ui_font(window, cx); + let ui_font = theme_settings::setup_ui_font(window, cx); let colors = cx.theme().colors(); let inspector_id = inspector.active_element_id(); let toolbar_height = platform_title_bar_height(window); diff --git a/crates/keymap_editor/Cargo.toml b/crates/keymap_editor/Cargo.toml index 33ba95ddd6d8df7efe2f551451af0340d83369c7..63bfba05d4e12251a9a267984dabc7420a8c7577 100644 --- a/crates/keymap_editor/Cargo.toml +++ b/crates/keymap_editor/Cargo.toml @@ -36,6 +36,7 @@ settings.workspace = true telemetry.workspace = true tempfile.workspace = true theme.workspace = true +theme_settings.workspace = true tree-sitter-json.workspace = true tree-sitter-rust.workspace = true ui_input.workspace = true diff --git a/crates/keymap_editor/src/keymap_editor.rs b/crates/keymap_editor/src/keymap_editor.rs index 1f331811fefcf0b1fbb4e63305d4138d39931a76..6a02289353f7fc0df8fd2b3fd99313d2ce650951 100644 --- a/crates/keymap_editor/src/keymap_editor.rs +++ b/crates/keymap_editor/src/keymap_editor.rs @@ -24,6 +24,7 @@ use gpui::{ actions, anchored, deferred, div, }; use language::{Language, LanguageConfig, ToOffset as _}; + use notifications::status_toast::{StatusToast, ToastIcon}; use project::{CompletionDisplayOptions, Project}; use settings::{ @@ -502,13 +503,48 @@ fn keystrokes_match_exactly( }) } +fn disabled_binding_matches_context( + disabled_binding: &gpui::KeyBinding, + binding: &gpui::KeyBinding, +) -> bool { + match ( + disabled_binding.predicate().as_deref(), + binding.predicate().as_deref(), + ) { + (None, _) => true, + (Some(_), None) => false, + (Some(disabled_predicate), Some(predicate)) => disabled_predicate.is_superset(predicate), + } +} + +fn binding_is_unbound_by_unbind( + binding: &gpui::KeyBinding, + binding_index: usize, + all_bindings: &[&gpui::KeyBinding], +) -> bool { + all_bindings[binding_index + 1..] + .iter() + .rev() + .any(|disabled_binding| { + gpui::is_unbind(disabled_binding.action()) + && keystrokes_match_exactly(disabled_binding.keystrokes(), binding.keystrokes()) + && disabled_binding + .action() + .as_any() + .downcast_ref::() + .is_some_and(|unbind| unbind.0.as_ref() == binding.action().name()) + && disabled_binding_matches_context(disabled_binding, binding) + }) +} + impl KeymapEditor { fn new(workspace: WeakEntity, window: &mut Window, cx: &mut Context) -> Self { let _keymap_subscription = cx.observe_global_in::(window, Self::on_keymap_changed); let table_interaction_state = cx.new(|cx| { - TableInteractionState::new(cx) - .with_custom_scrollbar(ui::Scrollbars::for_settings::()) + TableInteractionState::new(cx).with_custom_scrollbar(ui::Scrollbars::for_settings::< + editor::EditorSettingsScrollbarProxy, + >()) }); let keystroke_editor = cx.new(|cx| { @@ -731,13 +767,8 @@ impl KeymapEditor { SearchMode::Normal => {} } - // Filter out NoAction suppression bindings by default. These are internal - // markers created when a user deletes a default binding (to suppress the - // default at the GPUI level), not real bindings the user should usually see. if !this.show_no_action_bindings { - matches.retain(|item| { - this.keybindings[item.candidate_id].action().name != gpui::NoAction.name() - }); + matches.retain(|item| !this.keybindings[item.candidate_id].is_no_action()); } if action_query.is_empty() { @@ -774,7 +805,7 @@ impl KeymapEditor { ) { let key_bindings_ptr = cx.key_bindings(); let lock = key_bindings_ptr.borrow(); - let key_bindings = lock.bindings(); + let key_bindings = lock.bindings().collect::>(); let mut unmapped_action_names = HashSet::from_iter(cx.all_action_names().iter().copied()); let action_documentation = cx.action_documentation(); let mut generator = KeymapFile::action_schema_generator(); @@ -787,13 +818,20 @@ impl KeymapEditor { let mut processed_bindings = Vec::new(); let mut string_match_candidates = Vec::new(); - for key_binding in key_bindings { + for (binding_index, &key_binding) in key_bindings.iter().enumerate() { + if gpui::is_unbind(key_binding.action()) { + continue; + } + let source = key_binding .meta() .map(KeybindSource::from_meta) .unwrap_or(KeybindSource::Unknown); let keystroke_text = ui::text_for_keybinding_keystrokes(key_binding.keystrokes(), cx); + let is_no_action = gpui::is_no_action(key_binding.action()); + let is_unbound_by_unbind = + binding_is_unbound_by_unbind(key_binding, binding_index, &key_bindings); let binding = KeyBinding::new(key_binding, source); let context = key_binding @@ -828,6 +866,8 @@ impl KeymapEditor { binding, context, source, + is_no_action, + is_unbound_by_unbind, action_information, )); string_match_candidates.push(string_match_candidate); @@ -1021,20 +1061,23 @@ impl KeymapEditor { .and_then(KeybindContextString::local) .is_none(); - let selected_binding_is_unbound = selected_binding.is_unbound(); + let selected_binding_is_unmapped = selected_binding.is_unbound(); + let selected_binding_is_suppressed = selected_binding.is_unbound_by_unbind(); + let selected_binding_is_non_interactable = + selected_binding_is_unmapped || selected_binding_is_suppressed; let context_menu = ContextMenu::build(window, cx, |menu, _window, _cx| { menu.context(self.focus_handle.clone()) - .when(selected_binding_is_unbound, |this| { + .when(selected_binding_is_unmapped, |this| { this.action("Create", Box::new(CreateBinding)) }) .action_disabled_when( - selected_binding_is_unbound, + selected_binding_is_non_interactable, "Edit", Box::new(EditBinding), ) .action_disabled_when( - selected_binding_is_unbound, + selected_binding_is_non_interactable, "Delete", Box::new(DeleteBinding), ) @@ -1082,9 +1125,15 @@ impl KeymapEditor { &self, index: usize, conflict: Option, + is_unbound_by_unbind: bool, cx: &mut Context, ) -> IconButton { - if self.filter_state != FilterState::Conflicts + if is_unbound_by_unbind { + base_button_style(index, IconName::Warning) + .icon_color(Color::Warning) + .disabled(true) + .tooltip(Tooltip::text("This action is unbound")) + } else if self.filter_state != FilterState::Conflicts && let Some(conflict) = conflict { if conflict.is_user_keybind_conflict() { @@ -1244,6 +1293,9 @@ impl KeymapEditor { let Some((keybind, keybind_index)) = self.selected_keybind_and_index() else { return; }; + if !create && keybind.is_unbound_by_unbind() { + return; + } let keybind = keybind.clone(); let keymap_editor = cx.entity(); @@ -1350,6 +1402,9 @@ impl KeymapEditor { let Some(to_remove) = self.selected_binding().cloned() else { return; }; + if to_remove.is_unbound_by_unbind() { + return; + } let std::result::Result::Ok(fs) = self .workspace @@ -1679,6 +1734,8 @@ struct KeybindInformation { binding: KeyBinding, context: KeybindContextString, source: KeybindSource, + is_no_action: bool, + is_unbound_by_unbind: bool, } impl KeybindInformation { @@ -1729,6 +1786,8 @@ impl ProcessedBinding { binding: KeyBinding, context: KeybindContextString, source: KeybindSource, + is_no_action: bool, + is_unbound_by_unbind: bool, action_information: ActionInformation, ) -> Self { Self::Mapped( @@ -1737,6 +1796,8 @@ impl ProcessedBinding { binding, context, source, + is_no_action, + is_unbound_by_unbind, }, action_information, ) @@ -1775,6 +1836,16 @@ impl ProcessedBinding { self.keybind_information().map(|keybind| &keybind.binding) } + fn is_no_action(&self) -> bool { + self.keybind_information() + .is_some_and(|keybind| keybind.is_no_action) + } + + fn is_unbound_by_unbind(&self) -> bool { + self.keybind_information() + .is_some_and(|keybind| keybind.is_unbound_by_unbind) + } + fn keystroke_text(&self) -> Option<&SharedString> { self.keybind_information() .map(|binding| &binding.keystroke_text) @@ -2056,11 +2127,18 @@ impl Render for KeymapEditor { let binding = &this.keybindings[candidate_id]; let action_name = binding.action().name; let conflict = this.get_conflict(index); + let is_unbound_by_unbind = binding.is_unbound_by_unbind(); let is_overridden = conflict.is_some_and(|conflict| { !conflict.is_user_keybind_conflict() }); + let is_dimmed = is_overridden || is_unbound_by_unbind; - let icon = this.create_row_button(index, conflict, cx); + let icon = this.create_row_button( + index, + conflict, + is_unbound_by_unbind, + cx, + ); let action = div() .id(("keymap action", index)) @@ -2081,7 +2159,7 @@ impl Render for KeymapEditor { .when( !context_menu_deployed && this.show_hover_menus - && !is_overridden, + && !is_dimmed, |this| { this.tooltip({ let action_name = binding.action().name; @@ -2107,7 +2185,7 @@ impl Render for KeymapEditor { .cloned() .unwrap_or_default() .into_any_element(), - |binding| ui::KeyBinding::from_keystrokes(binding.keystrokes.clone(), binding.source).into_any_element() + |binding| ui::KeyBinding::from_keystrokes(binding.keystrokes.clone(), binding.source == KeybindSource::Vim).into_any_element() ); let action_arguments = match binding.action().arguments.clone() @@ -2134,7 +2212,7 @@ impl Render for KeymapEditor { .when( is_local && !context_menu_deployed - && !is_overridden + && !is_dimmed && this.show_hover_menus, |this| { this.tooltip(Tooltip::element({ @@ -2169,6 +2247,10 @@ impl Render for KeymapEditor { .map_row(cx.processor( |this, (row_index, row): (usize, Stateful

``` + +## See also + +- [Erlang](./erlang.md) +- [Gleam](./gleam.md) diff --git a/docs/src/languages/tailwindcss.md b/docs/src/languages/tailwindcss.md index db0eb1b4d255474ed671ab16ba9f6d235372efa6..e461aa2d7fa53d2e2d19e0af985a4830e8f477d1 100644 --- a/docs/src/languages/tailwindcss.md +++ b/docs/src/languages/tailwindcss.md @@ -15,7 +15,7 @@ Languages which can be used with Tailwind CSS in Zed: - [CSS](./css.md) - [ERB](./ruby.md#using-the-tailwind-css-language-server-with-ruby) - [Gleam](./gleam.md) -- [HEEx](./elixir.md#using-the-tailwind-css-language-server-with-heex) +- [HEEx](./elixir.md#using-the-tailwind-css-language-server-with-heex-templates) - [HTML](./html.md#using-the-tailwind-css-language-server-with-html) - [TypeScript](./typescript.md#using-the-tailwind-css-language-server-with-typescript) - [JavaScript](./javascript.md#using-the-tailwind-css-language-server-with-javascript) diff --git a/docs/src/migrate/webstorm.md b/docs/src/migrate/webstorm.md index 72916b04c5579785d2f099f1fd2b09d7ffb11acf..eb41f5c245cdc33a9a78320997b546bee8e14f15 100644 --- a/docs/src/migrate/webstorm.md +++ b/docs/src/migrate/webstorm.md @@ -37,11 +37,11 @@ This opens the current directory in Zed. 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) +1. Open Settings with {#kb zed::OpenSettings} 2. Search for `Base Keymap` 3. Select `JetBrains` -This maps familiar shortcuts like `Shift Shift` for Search Everywhere, `Cmd+O` for Go to Class, and `Cmd+Shift+A` for Find Action. +This maps familiar shortcuts like {#kb:jetbrains project_symbols::Toggle} for Go to Class and {#kb:jetbrains command_palette::Toggle} for Find Action. ## Set Up Editor Preferences @@ -63,7 +63,7 @@ Zed also supports per-project settings. Create a `.zed/settings.json` file in yo ## 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. +After setup, use {#kb:jetbrains file_finder::Toggle} 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. @@ -72,60 +72,53 @@ You can also launch Zed from the terminal inside any folder with: 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") +- Use {#kb:jetbrains file_finder::Toggle} to jump between files quickly (like WebStorm's "Recent Files") +- Use {#kb:jetbrains command_palette::Toggle} to open the Command Palette (like WebStorm's "Search Everywhere") +- Use {#kb:jetbrains project_symbols::Toggle} to search for symbols (like WebStorm's "Go to Symbol") -Open buffers appear as tabs across the top. The Project Panel shows your file tree and Git status. Toggle it with `Cmd+1` (just like WebStorm's Project tool window). +Open buffers appear as tabs across the top. The Project Panel shows your file tree and Git status. Toggle it with {#kb:jetbrains project_panel::ToggleFocus} (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` | +If you chose the JetBrains keymap during onboarding, most of your shortcuts should already feel familiar. Here's a quick reference of common actions and their keybindings with the JetBrains keymap active. + +### Common Keybindings + +| Action | Zed Keybinding | +| ---------------------- | ----------------------------------------------- | +| Command Palette | {#kb:jetbrains command_palette::Toggle} | +| Go to File | {#kb:jetbrains file_finder::Toggle} | +| Go to Symbol | {#kb:jetbrains project_symbols::Toggle} | +| File Outline | {#kb:jetbrains outline::Toggle} | +| Go to Definition | {#kb:jetbrains editor::GoToDefinition} | +| Find Usages | {#kb:jetbrains editor::FindAllReferences} | +| Rename Symbol | {#kb:jetbrains editor::Rename} | +| Reformat Code | {#kb:jetbrains editor::Format} | +| Toggle Project Panel | {#kb:jetbrains project_panel::ToggleFocus} | +| Toggle Terminal | {#kb:jetbrains terminal_panel::Toggle} | +| Duplicate Line | {#kb:jetbrains editor::DuplicateSelection} | +| Delete Line | {#kb:jetbrains editor::DeleteLine} | +| Move Line Up | {#kb:jetbrains editor::MoveLineUp} | +| Move Line Down | {#kb:jetbrains editor::MoveLineDown} | +| Expand Selection | {#kb:jetbrains editor::SelectLargerSyntaxNode} | +| Shrink Selection | {#kb:jetbrains editor::SelectSmallerSyntaxNode} | +| Comment Line | {#kb:jetbrains editor::ToggleComments} | +| Go Back | {#kb:jetbrains pane::GoBack} | +| Go Forward | {#kb:jetbrains pane::GoForward} | +| Toggle Breakpoint | {#kb:jetbrains editor::ToggleBreakpoint} | +| Navigate to Next Error | {#kb:jetbrains editor::GoToDiagnostic} | ### 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 | +| Action | Keybinding | Notes | +| ----------------- | -------------------------------- | ------------------------------------------------------------- | +| Toggle Right Dock | {#kb workspace::ToggleRightDock} | Assistant panel, notifications | +| Split Pane Right | {#kb pane::SplitRight} | Use other arrow keys to create splits in different directions | ### How to Customize Keybindings -- Open the Command Palette (`Cmd+Shift+A` or `Shift Shift`) -- Run `Zed: Open Keymap Editor` +- Open the Command Palette ({#kb:jetbrains command_palette::Toggle}) +- Run `zed: open keymap` This opens a list of all available bindings. You can override individual shortcuts or remove conflicts. @@ -143,9 +136,9 @@ WebStorm's index enables features like finding all usages across your entire cod **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 +- Search symbols across the project with {#kb:jetbrains project_symbols::Toggle} (powered by the TypeScript language server) +- Find files by name with {#kb:jetbrains file_finder::Toggle} +- Use {#kb pane::DeploySearch} 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 @@ -169,10 +162,10 @@ Where you might notice differences: **How to adapt:** -- Use `Alt+Enter` for available code actions—the list will vary by language server +- Use {#kb:jetbrains editor::ToggleCodeActions} 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 +- For code inspection similar to WebStorm's "Inspect Code," check the Diagnostics panel ({#kb:jetbrains diagnostics::Deploy})—ESLint and TypeScript together catch many of the same issues ### No Project Model @@ -212,8 +205,8 @@ What this means in practice: ] ``` -- Use `Ctrl+Alt+R` to run tasks quickly -- Lean on your terminal (`Alt+F12`) for anything tasks don't cover +- Use {#kb:jetbrains task::Spawn} to run tasks quickly +- Lean on your terminal ({#kb:jetbrains terminal_panel::Toggle}) for anything tasks don't cover ### No Framework Integration @@ -223,8 +216,8 @@ Zed has none of this built-in. The TypeScript language server sees your code as **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 +- Use grep and file search liberally. {#kb pane::DeploySearch} with a regex can find component definitions, route configurations, or API endpoints. +- Rely on your language server's "find references" ({#kb:jetbrains editor::FindAllReferences}) 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 @@ -232,16 +225,16 @@ Zed has none of this built-in. The TypeScript language server sees your code as ### 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 organizes auxiliary views into numbered tool windows. 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` | +| WebStorm Tool Window | Zed Equivalent | Zed Keybinding | +| -------------------- | -------------- | ------------------------------------------ | +| Project | Project Panel | {#kb:jetbrains project_panel::ToggleFocus} | +| Git | Git Panel | {#kb:jetbrains git_panel::ToggleFocus} | +| Terminal | Terminal Panel | {#kb:jetbrains terminal_panel::Toggle} | +| Structure | Outline Panel | {#kb:jetbrains outline_panel::ToggleFocus} | +| Problems | Diagnostics | {#kb:jetbrains diagnostics::Deploy} | +| Debug | Debug Panel | {#kb:jetbrains debug_panel::ToggleFocus} | Zed has three dock positions: left, bottom, and right. Panels can be moved between docks by dragging or through settings. @@ -252,10 +245,10 @@ Note that there's no dedicated npm tool window in Zed. Use the terminal or defin 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` +- Set breakpoints with {#kb:jetbrains editor::ToggleBreakpoint} +- Start debugging with {#kb:jetbrains debugger::Start} +- Step through code with {#kb:jetbrains debugger::StepInto} (step into), {#kb:jetbrains debugger::StepOver} (step over), {#kb:jetbrains debugger::StepOut} (step out) +- Continue execution with {#kb:jetbrains debugger::Continue} Zed can debug: @@ -359,7 +352,7 @@ If you're used to AI assistants in WebStorm (like GitHub Copilot, JetBrains AI A ### Configuring GitHub Copilot -1. Open Settings with `Cmd+,` (macOS) or `Ctrl+,` (Linux/Windows) +1. Open Settings with {#kb zed::OpenSettings} 2. Navigate to **AI → Edit Predictions** 3. Click **Configure** next to "Configure Providers" 4. Under **GitHub Copilot**, click **Sign in to GitHub** diff --git a/docs/src/modelines.md b/docs/src/modelines.md new file mode 100644 index 0000000000000000000000000000000000000000..541e44b7bae4a1fb8b4400245c2e0bf54b68dcfb --- /dev/null +++ b/docs/src/modelines.md @@ -0,0 +1,67 @@ +# Modelines + +Modelines are special comments at the beginning or end of a file that configure editor settings for that specific file. Zed supports both Vim and Emacs modeline formats, allowing you to specify settings like tab size, indentation style, and file type directly within your files. + +## Configuration + +Use the [`modeline_lines`](./reference/all-settings.md#modeline-lines) setting to control how many lines Zed searches for modelines: + +```json [settings] +{ + "modeline_lines": 5 +} +``` + +Set to `0` to disable modeline parsing entirely. + +## Emacs + +Zed has some compatibility support for [Emacs file variables](https://www.gnu.org/software/emacs/manual/html_node/emacs/Specifying-File-Variables.html). + +Example: + +```python +# -*- mode: python; tab-width: 4; indent-tabs-mode: nil; -*- +``` + +### Supported Emacs Variables + +| Variable | Description | Zed Setting | +| -------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------ | +| `mode` | Major mode/language | Language detection | +| `tab-width` | Tab display width | [`tab_size`](./reference/all-settings.md#tab-size) | +| `fill-column` | Line wrap column | [`preferred_line_length`](./reference/all-settings.md#preferred-line-length) | +| `indent-tabs-mode` | `nil` for spaces, `t` for tabs | [`hard_tabs`](./reference/all-settings.md#hard-tabs) | +| `electric-indent-mode` | Auto-indentation | [`auto_indent`](./reference/all-settings.md#auto-indent) | +| `require-final-newline` | Ensure final newline | [`ensure_final_newline_on_save`](./reference/all-settings.md#ensure-final-newline-on-save) | +| `show-trailing-whitespace` | Show trailing whitespace | [`show_whitespaces`](./reference/all-settings.md#show-whitespaces) | + +## Vim + +Zed has some compatibility support for [Vim modeline](https://vimhelp.org/options.txt.html#modeline). + +Example: + +```python +# vim: set ft=python ts=4 sw=4 et: +``` + +### Supported Vim Options + +| Option | Aliases | Description | Zed Setting | +| -------------- | ------- | --------------------------------- | ------------------------------------------------------------------------------------------ | +| `filetype` | `ft` | File type/language | Language detection | +| `tabstop` | `ts` | Number of spaces a tab counts for | [`tab_size`](./reference/all-settings.md#tab-size) | +| `textwidth` | `tw` | Maximum line width | [`preferred_line_length`](./reference/all-settings.md#preferred-line-length) | +| `expandtab` | `et` | Use spaces instead of tabs | [`hard_tabs`](./reference/all-settings.md#hard-tabs) | +| `noexpandtab` | `noet` | Use tabs instead of spaces | [`hard_tabs`](./reference/all-settings.md#hard-tabs) | +| `autoindent` | `ai` | Enable auto-indentation | [`auto_indent`](./reference/all-settings.md#auto-indent) | +| `noautoindent` | `noai` | Disable auto-indentation | [`auto_indent`](./reference/all-settings.md#auto-indent) | +| `endofline` | `eol` | Ensure final newline | [`ensure_final_newline_on_save`](./reference/all-settings.md#ensure-final-newline-on-save) | +| `noendofline` | `noeol` | Disable final newline | [`ensure_final_newline_on_save`](./reference/all-settings.md#ensure-final-newline-on-save) | + +## Notes + +- The first kilobyte of a file is searched for modelines. +- Emacs modelines take precedence over Vim modelines when both are present. +- Modelines in the first few lines take precedence over those at the end of the file. diff --git a/docs/src/performance.md b/docs/src/performance.md index e974d63f8816b68d30a1c06d7cbbc083f8564327..b8f76179e16fcf1f1b886a5c3ef00bcc85aa9ed4 100644 --- a/docs/src/performance.md +++ b/docs/src/performance.md @@ -15,7 +15,7 @@ See [samply](https://github.com/mstange/samply)'s README on how to install and r The profile.json does not contain any symbols. Firefox profiler can add the local symbols to the profile for for. To do that hit the upload local profile button in the top right corner. -image +image # In depth CPU profiling (Tracing) @@ -52,10 +52,35 @@ Download the profiler: ## Usage Open the profiler (tracy-profiler), you should see zed in the list of `Discovered clients` click it. -image -To find functions that take a long time follow this image: -image +image + +Tracy is an incredibly powerful profiler which can do a lot however it's UI is not that friendly. This is not the place for an in depth guide to Tracy, I do however want to highlight one particular workflow that is helpful when figuring out why a piece of code is _sometimes_ slow. + +Here are the steps: + +1. Click the flamechart button at the top. +2. Click on a function that takes a lot of time. +3. Expand the list of function calls by clicking on main thread. +4. Filter that list to the slower calls then click on one of the slow calls in the list +5. Click zoom to zone to go to that specific function call in the timeline +6. Scroll to zoom in and see more detail about the callers +7. Click on a caller to to get statistics on _it_. + +While normally the blue bars in the Tracy timeline correspond to function calls they can time any part of a codebase. In the example below we have added an extra span "for block in edits" and added metadata to it: the block_height. You can do that like this: + +```rust +let span = ztracing::debug_span!("for block in edits", block_height = block.height()); +let _enter = span.enter(); // span guard, when this is dropped the span ends (and its duration is recorded) +``` + +Click flamechart +Click snapshot +Click main thread +Select the tail calls in the histogram to filter down the list of calls then click on one call +Click zoom to zone +Scroll to zoom in +Click on any of the zones to get statistics # Task/Async profiling diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index da93b290b4486599cf3cecc05b08f5f7a7ea1984..ce80fe78f4734135bd6bba0f3329a651059dbfdf 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -3462,12 +3462,6 @@ Non-negative `integer` values - Setting: `regex` - Default: `false` -### Search On Input - -- Description: Whether to search on input in project search. -- Setting: `search_on_input` -- Default: `true` - ### Center On Match - Description: Whether to center the cursor on each search match when navigating. @@ -4627,7 +4621,8 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a "show_user_picture": true, "show_user_menu": true, "show_sign_in": true, - "show_menus": false + "show_menus": false, + "button_layout": "platform_default" } } ``` @@ -4642,6 +4637,7 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a - `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 +- `button_layout`: The layout of window control buttons in the title bar (Linux only). Can be set to `"platform_default"` to follow the system setting, `"standard"` to use Zed's built-in layout, or a custom format like `"close:minimize,maximize"` ## Vim diff --git a/docs/src/troubleshooting.md b/docs/src/troubleshooting.md index bf30b8b837ce1b471e349b34f1b56dc38d914c09..a852ce779cdb0b719a56e3b12d68ee9b2baab6b7 100644 --- a/docs/src/troubleshooting.md +++ b/docs/src/troubleshooting.md @@ -45,10 +45,13 @@ Xcode Instruments (which comes bundled with your [Xcode](https://apps.apple.com/ 1. With Zed running, open Instruments 1. Select `Time Profiler` as the profiling template + ![Instruments template picker with Time Profiler selected](https://images.zed.dev/troubleshooting/instruments-template-picker.webp) 1. In the `Time Profiler` configuration, set the target to the running Zed process 1. Start recording -1. If the performance issue occurs when performing a specific action in Zed, perform that action now + ![Time Profiler configuration showing the target dropdown and record button](https://images.zed.dev/troubleshooting/instruments-target-and-record.webp) +1. Perform the action in Zed that causes performance issues 1. Stop recording + ![A completed Time Profiler recording in Instruments](https://images.zed.dev/troubleshooting/instruments-recording.webp) 1. Save the trace file 1. Compress the trace file into a zip archive 1. File a [GitHub issue](https://github.com/zed-industries/zed/issues/new/choose) with the trace zip attached diff --git a/nix/build.nix b/nix/build.nix index 02ed6235e54daa27a9af9b86da79618a21e3cc7e..9270abbe6f747e0ed78400d13561eadd97edd184 100644 --- a/nix/build.nix +++ b/nix/build.nix @@ -77,6 +77,7 @@ let builtins.elem firstComp topLevelIncludes; craneLib = crane.overrideToolchain rustToolchain; + gpu-lib = if withGLES then libglvnd else vulkan-loader; commonArgs = let zedCargoLock = builtins.fromTOML (builtins.readFile ../crates/zed/Cargo.toml); @@ -178,8 +179,8 @@ let libva libxkbcommon wayland + gpu-lib libglvnd - vulkan-loader xorg.libX11 xorg.libxcb libdrm @@ -236,8 +237,7 @@ let # about them that's special is that they're manually dlopened at runtime NIX_LDFLAGS = lib.optionalString stdenv'.hostPlatform.isLinux "-rpath ${ lib.makeLibraryPath [ - libglvnd - vulkan-loader + gpu-lib wayland libva ] @@ -246,7 +246,7 @@ let NIX_OUTPATH_USED_AS_RANDOM_SEED = "norebuilds"; }; - # prevent nix from removing the "unused" wayland rpaths + # prevent nix from removing the "unused" wayland/gpu-lib rpaths dontPatchELF = stdenv'.hostPlatform.isLinux; # TODO: try craneLib.cargoNextest separate output diff --git a/nix/livekit-libwebrtc/package.nix b/nix/livekit-libwebrtc/package.nix index dd7b5808ac65ab07d1293683905b694910ee503a..4c0d99926200e619b567cf7a90549f4f882eda42 100644 --- a/nix/livekit-libwebrtc/package.nix +++ b/nix/livekit-libwebrtc/package.nix @@ -81,6 +81,15 @@ stdenv.mkDerivation { pname = "livekit-libwebrtc"; version = "137-unstable-2025-11-24"; + # libwebrtc loads libEGL/libGL at runtime via dlopen() in the Wayland + # screencast path, so they are not visible as ordinary DT_NEEDED edges. + # Keep an explicit rpath so the shared object can resolve them at runtime. + NIX_LDFLAGS = lib.optionalString stdenv.hostPlatform.isLinux + "-rpath ${lib.makeLibraryPath [ libGL ]}"; + + # Prevent fixup from stripping the rpath above as "unused". + dontPatchELF = stdenv.hostPlatform.isLinux; + gclientDeps = gclient2nix.importGclientDeps ./sources.json; sourceRoot = "src"; diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 89b3c648ca2a8a9b893d1b0924697f8170047761..15b4a8f0fc9f93064f08046bcb1edff01e6c6d44 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,5 +1,5 @@ [toolchain] -channel = "1.93" +channel = "1.94.1" profile = "minimal" components = [ "rustfmt", "clippy", "rust-analyzer", "rust-src" ] targets = [ diff --git a/script/danger/dangerfile.ts b/script/danger/dangerfile.ts index c1ca883f3e910f434f686985d2c94df22986a029..99a29e51d9f287c87d6db3fec7252a28e33b95cc 100644 --- a/script/danger/dangerfile.ts +++ b/script/danger/dangerfile.ts @@ -12,7 +12,7 @@ prHygiene({ }, }); -const RELEASE_NOTES_PATTERN = /Release Notes:\r?\n\s+-/gm; +const RELEASE_NOTES_PATTERN = /Release Notes:(\r?\n)+- /gm; const body = danger.github.pr.body; const hasReleaseNotes = RELEASE_NOTES_PATTERN.test(body); diff --git a/script/licenses/zed-licenses.toml b/script/licenses/zed-licenses.toml index 572dd5c14aebcdea3544ac15b751be4c212ecf52..db14a280f2f1537c37f96f6fa180b96d54afa209 100644 --- a/script/licenses/zed-licenses.toml +++ b/script/licenses/zed-licenses.toml @@ -26,6 +26,7 @@ accepted = [ "OpenSSL", "Zlib", "BSL-1.0", + "bzip2-1.0.6", ] [procinfo.clarify] diff --git a/tooling/xtask/src/tasks/workflows.rs b/tooling/xtask/src/tasks/workflows.rs index 35f053f46666a4d5e81bffe27bc80490c20c166d..414c0b7fd8dc2a99027d8687bcf1d4dbe9c4bb85 100644 --- a/tooling/xtask/src/tasks/workflows.rs +++ b/tooling/xtask/src/tasks/workflows.rs @@ -206,7 +206,6 @@ pub fn run_workflows(args: GenerateWorkflowArgs) -> Result<()> { WorkflowFile::zed(publish_extension_cli::publish_extension_cli), WorkflowFile::zed(release::release), WorkflowFile::zed(release_nightly::release_nightly), - WorkflowFile::zed(run_agent_evals::run_agent_evals), WorkflowFile::zed(run_agent_evals::run_cron_unit_evals), WorkflowFile::zed(run_agent_evals::run_unit_evals), WorkflowFile::zed(run_bundling::run_bundling), diff --git a/tooling/xtask/src/tasks/workflows/compare_perf.rs b/tooling/xtask/src/tasks/workflows/compare_perf.rs index 74a1fbdc389e2b0dacdf579d9ee96a0366eb5c01..39f17b8d148bd6022913fdf5097368690cbd0fd0 100644 --- a/tooling/xtask/src/tasks/workflows/compare_perf.rs +++ b/tooling/xtask/src/tasks/workflows/compare_perf.rs @@ -42,7 +42,11 @@ pub fn run_perf( } fn install_hyperfine() -> Step { - named::uses("taiki-e", "install-action", "hyperfine") + named::uses( + "taiki-e", + "install-action", + "b4f2d5cb8597b15997c8ede873eb6185efc5f0ad", // hyperfine + ) } fn compare_runs(head: &WorkflowInput, base: &WorkflowInput) -> Step { diff --git a/tooling/xtask/src/tasks/workflows/extension_bump.rs b/tooling/xtask/src/tasks/workflows/extension_bump.rs index 38cd926ef4b3c4bf7e0ba4ae8ccab823be9b3187..a1c2abc169f4348fd04a529c5a5b10b412464c9b 100644 --- a/tooling/xtask/src/tasks/workflows/extension_bump.rs +++ b/tooling/xtask/src/tasks/workflows/extension_bump.rs @@ -5,8 +5,9 @@ use crate::tasks::workflows::{ extension_tests::{self}, runners, steps::{ - self, BASH_SHELL, CommonJobConditions, DEFAULT_REPOSITORY_OWNER_GUARD, FluentBuilder, - NamedJob, cache_rust_dependencies_namespace, checkout_repo, dependant_job, named, + self, BASH_SHELL, CommonJobConditions, DEFAULT_REPOSITORY_OWNER_GUARD, NamedJob, + RepositoryTarget, cache_rust_dependencies_namespace, checkout_repo, dependant_job, + generate_token, named, }, vars::{ JobOutput, StepOutput, WorkflowInput, WorkflowSecret, @@ -123,7 +124,7 @@ fn create_version_label( app_secret: &WorkflowSecret, ) -> (NamedJob, StepOutput) { let (generate_token, generated_token) = - generate_token(&app_id.to_string(), &app_secret.to_string(), None); + generate_token(&app_id.to_string(), &app_secret.to_string()).into(); let (determine_tag_step, tag) = determine_tag(current_version); let job = steps::dependant_job(dependencies) .defaults(extension_job_defaults()) @@ -144,7 +145,12 @@ fn create_version_label( } fn create_version_tag(tag: &StepOutput, generated_token: StepOutput) -> Step { - named::uses("actions", "github-script", "v7").with( + named::uses( + "actions", + "github-script", + "f28e40c7f34bde8b3046d885e986cb6290c5673b", // v7 + ) + .with( Input::default() .add( "script", @@ -221,7 +227,7 @@ fn bump_extension_version( app_secret: &WorkflowSecret, ) -> NamedJob { let (generate_token, generated_token) = - generate_token(&app_id.to_string(), &app_secret.to_string(), None); + generate_token(&app_id.to_string(), &app_secret.to_string()).into(); let (bump_version, _new_version, title, body, branch_name) = bump_version(current_version, bump_type); @@ -249,49 +255,6 @@ fn bump_extension_version( named::job(job) } -pub(crate) fn generate_token( - app_id_source: &str, - app_secret_source: &str, - repository_target: Option, -) -> (Step, StepOutput) { - let step = named::uses("actions", "create-github-app-token", "v2") - .id("generate-token") - .add_with( - Input::default() - .add("app-id", app_id_source) - .add("private-key", app_secret_source) - .when_some( - repository_target, - |input, - RepositoryTarget { - owner, - repositories, - permissions, - }| { - input - .when_some(owner, |input, owner| input.add("owner", owner)) - .when_some(repositories, |input, repositories| { - input.add("repositories", repositories) - }) - .when_some(permissions, |input, permissions| { - permissions - .into_iter() - .fold(input, |input, (permission, level)| { - input.add( - permission, - serde_json::to_value(&level).unwrap_or_default(), - ) - }) - }) - }, - ), - ); - - let generated_token = StepOutput::new(&step, "token"); - - (step, generated_token) -} - fn install_bump_2_version() -> Step { named::run( runners::Platform::Linux, @@ -364,7 +327,12 @@ fn create_pull_request( generated_token: StepOutput, branch_name: StepOutput, ) -> Step { - named::uses("peter-evans", "create-pull-request", "v7").with( + named::uses( + "peter-evans", + "create-pull-request", + "98357b18bf14b5342f975ff684046ec3b2a07725", + ) + .with( Input::default() .add("title", title.to_string()) .add("body", body.to_string()) @@ -389,11 +357,9 @@ fn trigger_release( app_secret: &WorkflowSecret, ) -> NamedJob { let extension_registry = RepositoryTarget::new("zed-industries", &["extensions"]); - let (generate_token, generated_token) = generate_token( - &app_id.to_string(), - &app_secret.to_string(), - Some(extension_registry), - ); + let (generate_token, generated_token) = + generate_token(&app_id.to_string(), &app_secret.to_string()) + .for_repository(extension_registry); let (get_extension_id, extension_id) = get_extension_id(); let (release_action, pull_request_number) = release_action(extension_id, tag, &generated_token); @@ -452,7 +418,11 @@ fn enable_automerge_if_staff( pull_request_number: StepOutput, generated_token: StepOutput, ) -> Step { - named::uses("actions", "github-script", "v7") + named::uses( + "actions", + "github-script", + "f28e40c7f34bde8b3046d885e986cb6290c5673b", // v7 + ) .add_with(("github-token", generated_token.to_string())) .add_with(( "script", @@ -526,34 +496,3 @@ fn extension_workflow_secrets() -> (WorkflowSecret, WorkflowSecret) { (app_id, app_secret) } - -pub(crate) struct RepositoryTarget { - owner: Option, - repositories: Option, - permissions: Option>, -} - -impl RepositoryTarget { - pub fn new(owner: T, repositories: &[&str]) -> Self { - Self { - owner: Some(owner.to_string()), - repositories: Some(repositories.join("\n")), - permissions: None, - } - } - - pub fn current() -> Self { - Self { - owner: None, - repositories: None, - permissions: None, - } - } - - pub fn permissions(self, permissions: impl Into>) -> Self { - Self { - permissions: Some(permissions.into()), - ..self - } - } -} diff --git a/tooling/xtask/src/tasks/workflows/extension_tests.rs b/tooling/xtask/src/tasks/workflows/extension_tests.rs index caf57ce130f7d7e9f0018ef20d4cf4892823f4ab..d724afc1353b0aa9205706c5f23eb0d0ee8e96c9 100644 --- a/tooling/xtask/src/tasks/workflows/extension_tests.rs +++ b/tooling/xtask/src/tasks/workflows/extension_tests.rs @@ -12,7 +12,7 @@ use crate::tasks::workflows::{ vars::{PathCondition, StepOutput, WorkflowInput, one_workflow_per_non_main_branch_and_token}, }; -pub(crate) const ZED_EXTENSION_CLI_SHA: &str = "03d8e9aee95ea6117d75a48bcac2e19241f6e667"; +pub(crate) const ZED_EXTENSION_CLI_SHA: &str = "1fa7f1a3ec28ea1eae6db2e937d7a538fb10c0c7"; // This should follow the set target in crates/extension/src/extension_builder.rs const EXTENSION_RUST_TARGET: &str = "wasm32-wasip2"; diff --git a/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs b/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs index a62bb107da5228cd3ba620e47ab77dc673974696..3a5d14603f97b43aacb581aaf3b970bac31b701f 100644 --- a/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs +++ b/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs @@ -9,9 +9,10 @@ use crate::tasks::workflows::steps::CheckoutStep; use crate::tasks::workflows::steps::cache_rust_dependencies_namespace; use crate::tasks::workflows::vars::JobOutput; use crate::tasks::workflows::{ - extension_bump::{RepositoryTarget, generate_token}, runners, - steps::{self, DEFAULT_REPOSITORY_OWNER_GUARD, NamedJob, named}, + steps::{ + self, DEFAULT_REPOSITORY_OWNER_GUARD, NamedJob, RepositoryTarget, generate_token, named, + }, vars::{self, StepOutput, WorkflowInput}, }; @@ -49,7 +50,7 @@ pub(crate) fn extension_workflow_rollout() -> Workflow { fn fetch_extension_repos(filter_repos_input: &WorkflowInput) -> (NamedJob, JobOutput, JobOutput) { fn get_repositories(filter_repos_input: &WorkflowInput) -> (Step, StepOutput) { - let step = named::uses("actions", "github-script", "v7") + let step = named::uses("actions", "github-script", "f28e40c7f34bde8b3046d885e986cb6290c5673b") .id("list-repos") .add_with(( "script", @@ -268,25 +269,29 @@ fn rollout_workflows_to_extension( "#, }; - named::uses("peter-evans", "create-pull-request", "v7") - .add_with(("path", "extension")) - .add_with(("title", title.clone())) - .add_with(("body", body)) - .add_with(("commit-message", title)) - .add_with(("branch", "update-workflows")) - .add_with(( - "committer", - "zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>", - )) - .add_with(( - "author", - "zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>", - )) - .add_with(("base", "main")) - .add_with(("delete-branch", true)) - .add_with(("token", token.to_string())) - .add_with(("sign-commits", true)) - .id("create-pr") + named::uses( + "peter-evans", + "create-pull-request", + "98357b18bf14b5342f975ff684046ec3b2a07725", + ) + .add_with(("path", "extension")) + .add_with(("title", title.clone())) + .add_with(("body", body)) + .add_with(("commit-message", title)) + .add_with(("branch", "update-workflows")) + .add_with(( + "committer", + "zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>", + )) + .add_with(( + "author", + "zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>", + )) + .add_with(("base", "main")) + .add_with(("delete-branch", true)) + .add_with(("token", token.to_string())) + .add_with(("sign-commits", true)) + .id("create-pr") } fn enable_auto_merge(token: &StepOutput) -> Step { @@ -303,17 +308,15 @@ fn rollout_workflows_to_extension( )) } - let (authenticate, token) = generate_token( - vars::ZED_ZIPPY_APP_ID, - vars::ZED_ZIPPY_APP_PRIVATE_KEY, - Some( + let (authenticate, token) = + generate_token(vars::ZED_ZIPPY_APP_ID, vars::ZED_ZIPPY_APP_PRIVATE_KEY).for_repository( RepositoryTarget::new("zed-extensions", &["${{ matrix.repo }}"]).permissions([ ("permission-pull-requests".to_owned(), Level::Write), ("permission-contents".to_owned(), Level::Write), ("permission-workflows".to_owned(), Level::Write), ]), - ), - ); + ); + let (calculate_short_sha, short_sha) = get_short_sha(); let job = Job::default() @@ -368,14 +371,11 @@ fn create_rollout_tag(rollout_job: &NamedJob, filter_repos_input: &WorkflowInput "#}) } - let (authenticate, token) = generate_token( - vars::ZED_ZIPPY_APP_ID, - vars::ZED_ZIPPY_APP_PRIVATE_KEY, - Some( + let (authenticate, token) = + generate_token(vars::ZED_ZIPPY_APP_ID, vars::ZED_ZIPPY_APP_PRIVATE_KEY).for_repository( RepositoryTarget::current() .permissions([("permission-contents".to_owned(), Level::Write)]), - ), - ); + ); let job = Job::default() .needs([rollout_job.name.clone()]) diff --git a/tooling/xtask/src/tasks/workflows/publish_extension_cli.rs b/tooling/xtask/src/tasks/workflows/publish_extension_cli.rs index 2269201a2de383bc5ae7147d9e1d08105c540d15..dad4bce45399bd8d0b4a6ff842f87830bd77484f 100644 --- a/tooling/xtask/src/tasks/workflows/publish_extension_cli.rs +++ b/tooling/xtask/src/tasks/workflows/publish_extension_cli.rs @@ -2,9 +2,8 @@ use gh_workflow::{ctx::Context, *}; use indoc::indoc; use crate::tasks::workflows::{ - extension_bump::{RepositoryTarget, generate_token}, runners, - steps::{self, CommonJobConditions, NamedJob, named}, + steps::{self, CommonJobConditions, NamedJob, RepositoryTarget, generate_token, named}, vars::{self, StepOutput}, }; @@ -42,7 +41,7 @@ fn publish_job() -> NamedJob { named::job( Job::default() .with_repository_owner_guard() - .runs_on(runners::LINUX_SMALL) + .runs_on(runners::LINUX_DEFAULT) .add_step(steps::checkout_repo()) .add_step(steps::cache_rust_dependencies_namespace()) .add_step(steps::setup_linux()) @@ -52,11 +51,8 @@ fn publish_job() -> NamedJob { } fn update_sha_in_zed(publish_job: &NamedJob) -> NamedJob { - let (generate_token, generated_token) = generate_token( - vars::ZED_ZIPPY_APP_ID, - vars::ZED_ZIPPY_APP_PRIVATE_KEY, - Some(RepositoryTarget::current()), - ); + let (generate_token, generated_token) = + generate_token(vars::ZED_ZIPPY_APP_ID, vars::ZED_ZIPPY_APP_PRIVATE_KEY).into(); fn replace_sha() -> Step { named::bash(indoc! {r#" @@ -92,7 +88,7 @@ fn create_pull_request_zed(generated_token: &StepOutput, short_sha: &StepOutput) short_sha ); - named::uses("peter-evans", "create-pull-request", "v7").with( + named::uses("peter-evans", "create-pull-request", "98357b18bf14b5342f975ff684046ec3b2a07725").with( Input::default() .add("title", title.clone()) .add( @@ -121,11 +117,9 @@ fn create_pull_request_zed(generated_token: &StepOutput, short_sha: &StepOutput) fn update_sha_in_extensions(publish_job: &NamedJob) -> NamedJob { let extensions_repo = RepositoryTarget::new("zed-industries", &["extensions"]); - let (generate_token, generated_token) = generate_token( - vars::ZED_ZIPPY_APP_ID, - vars::ZED_ZIPPY_APP_PRIVATE_KEY, - Some(extensions_repo), - ); + let (generate_token, generated_token) = + generate_token(vars::ZED_ZIPPY_APP_ID, vars::ZED_ZIPPY_APP_PRIVATE_KEY) + .for_repository(extensions_repo); fn checkout_extensions_repo(token: &StepOutput) -> Step { named::uses( @@ -165,7 +159,7 @@ fn create_pull_request_extensions( ) -> Step { let title = format!("Bump extension CLI version to `{}`", short_sha); - named::uses("peter-evans", "create-pull-request", "v7").with( + named::uses("peter-evans", "create-pull-request", "98357b18bf14b5342f975ff684046ec3b2a07725").with( Input::default() .add("title", title.clone()) .add( diff --git a/tooling/xtask/src/tasks/workflows/run_agent_evals.rs b/tooling/xtask/src/tasks/workflows/run_agent_evals.rs index 521f419d9b317c42a1106ebe8500ccf0a3f494ec..8146552e6567fc336be91e3ad6c0687c441b6604 100644 --- a/tooling/xtask/src/tasks/workflows/run_agent_evals.rs +++ b/tooling/xtask/src/tasks/workflows/run_agent_evals.rs @@ -3,32 +3,10 @@ use serde_json::json; use crate::tasks::workflows::{ runners::{self, Platform}, - steps::{self, FluentBuilder as _, NamedJob, named, setup_cargo_config}, + steps::{self, FluentBuilder as _, NamedJob, named}, vars::{self, WorkflowInput}, }; -pub(crate) fn run_agent_evals() -> Workflow { - let agent_evals = agent_evals(); - let model_name = WorkflowInput::string("model_name", None); - - named::workflow() - .on(Event::default().workflow_dispatch( - WorkflowDispatch::default().add_input(model_name.name, model_name.input()), - )) - .concurrency(vars::one_workflow_per_non_main_branch()) - .add_env(("CARGO_TERM_COLOR", "always")) - .add_env(("CARGO_INCREMENTAL", 0)) - .add_env(("RUST_BACKTRACE", 1)) - .add_env(("ANTHROPIC_API_KEY", vars::ANTHROPIC_API_KEY)) - .add_env(("OPENAI_API_KEY", vars::OPENAI_API_KEY)) - .add_env(("GOOGLE_AI_API_KEY", vars::GOOGLE_AI_API_KEY)) - .add_env(("GOOGLE_CLOUD_PROJECT", vars::GOOGLE_CLOUD_PROJECT)) - .add_env(("ZED_CLIENT_CHECKSUM_SEED", vars::ZED_CLIENT_CHECKSUM_SEED)) - .add_env(("ZED_EVAL_TELEMETRY", 1)) - .add_env(("MODEL_NAME", model_name.to_string())) - .add_job(agent_evals.name, agent_evals.job) -} - pub(crate) fn run_unit_evals() -> Workflow { let model_name = WorkflowInput::string("model_name", None); let commit_sha = WorkflowInput::string("commit_sha", None); @@ -59,29 +37,6 @@ fn add_api_keys(step: Step) -> Step { .add_env(("GOOGLE_CLOUD_PROJECT", vars::GOOGLE_CLOUD_PROJECT)) } -fn agent_evals() -> NamedJob { - fn run_eval() -> Step { - named::bash( - "cargo run --package=eval -- --repetitions=8 --concurrency=1 --model \"${MODEL_NAME}\"", - ) - } - - named::job( - Job::default() - .runs_on(runners::LINUX_DEFAULT) - .timeout_minutes(60_u32 * 10) - .add_step(steps::checkout_repo()) - .add_step(steps::cache_rust_dependencies_namespace()) - .map(steps::install_linux_dependencies) - .add_step(setup_cargo_config(Platform::Linux)) - .add_step(steps::setup_sccache(Platform::Linux)) - .add_step(steps::script("cargo build --package=eval")) - .add_step(add_api_keys(run_eval())) - .add_step(steps::show_sccache_stats(Platform::Linux)) - .add_step(steps::cleanup_cargo_config(Platform::Linux)), - ) -} - pub(crate) fn run_cron_unit_evals() -> Workflow { let unit_evals = cron_unit_evals(); diff --git a/tooling/xtask/src/tasks/workflows/steps.rs b/tooling/xtask/src/tasks/workflows/steps.rs index 2593d5dd0e8a2edc33f558de07af05a30f46ddbe..ebdd9b30538eb389a267a1c2fdb1822eec1d3a54 100644 --- a/tooling/xtask/src/tasks/workflows/steps.rs +++ b/tooling/xtask/src/tasks/workflows/steps.rs @@ -1,7 +1,11 @@ use gh_workflow::*; use serde_json::Value; -use crate::tasks::workflows::{runners::Platform, vars, vars::StepOutput}; +use crate::tasks::workflows::{ + runners::Platform, + steps::named::function_name, + vars::{self, StepOutput}, +}; pub(crate) fn use_clang(job: Job) -> Job { job.add_env(Env::new("CC", "clang")) @@ -114,7 +118,7 @@ impl From for Step { .uses( "actions", "checkout", - "11bd71901bbe5b1630ceea73d27597364c9af683", // v4 + "93cb6efe18208431cddfb8368fd83d5badbf9bfd", // v5.0.1 ) // prevent checkout action from running `git clean -ffdx` which // would delete the target directory @@ -173,7 +177,11 @@ pub fn cargo_fmt() -> Step { } pub fn cargo_install_nextest() -> Step { - named::uses("taiki-e", "install-action", "nextest") + named::uses( + "taiki-e", + "install-action", + "921e2c9f7148d7ba14cd819f417db338f63e733c", // nextest + ) } pub fn setup_cargo_config(platform: Platform) -> Step { @@ -226,9 +234,13 @@ pub fn install_rustup_target(target: &str) -> Step { } pub fn cache_rust_dependencies_namespace() -> Step { - named::uses("namespacelabs", "nscloud-cache-action", "v1") - .add_with(("cache", "rust")) - .add_with(("path", "~/.rustup")) + named::uses( + "namespacelabs", + "nscloud-cache-action", + "a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9", // v1 + ) + .add_with(("cache", "rust")) + .add_with(("path", "~/.rustup")) } pub fn setup_sccache(platform: Platform) -> Step { @@ -255,14 +267,24 @@ pub fn show_sccache_stats(platform: Platform) -> Step { } pub fn cache_nix_dependencies_namespace() -> Step { - named::uses("namespacelabs", "nscloud-cache-action", "v1").add_with(("cache", "nix")) + named::uses( + "namespacelabs", + "nscloud-cache-action", + "a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9", // v1 + ) + .add_with(("cache", "nix")) } pub fn cache_nix_store_macos() -> Step { // On macOS, `/nix` is on a read-only root filesystem so nscloud's `cache: nix` // cannot mount or symlink there. Instead we cache a user-writable directory and // use nix-store --import/--export in separate steps to transfer store paths. - named::uses("namespacelabs", "nscloud-cache-action", "v1").add_with(("path", "~/nix-cache")) + named::uses( + "namespacelabs", + "nscloud-cache-action", + "a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9", // v1 + ) + .add_with(("path", "~/nix-cache")) } pub fn setup_linux() -> Step { @@ -491,15 +513,119 @@ pub fn git_checkout(ref_name: &dyn std::fmt::Display) -> Step { .add_env(("REF_NAME", ref_name.to_string())) } +pub(crate) struct GenerateAppToken<'a> { + job_name: String, + app_id: &'a str, + app_secret: &'a str, + repository_target: Option, +} + +impl<'a> GenerateAppToken<'a> { + pub fn for_repository(self, repository_target: RepositoryTarget) -> (Step, StepOutput) { + Self { + repository_target: Some(repository_target), + ..self + } + .into() + } +} + +impl<'a> From> for (Step, StepOutput) { + fn from(token: GenerateAppToken<'a>) -> Self { + let step = Step::new(token.job_name) + .uses( + "actions", + "create-github-app-token", + "f8d387b68d61c58ab83c6c016672934102569859", + ) + .id("generate-token") + .add_with( + Input::default() + .add("app-id", token.app_id) + .add("private-key", token.app_secret) + .when_some( + token.repository_target, + |input, + RepositoryTarget { + owner, + repositories, + permissions, + }| { + input + .when_some(owner, |input, owner| input.add("owner", owner)) + .when_some(repositories, |input, repositories| { + input.add("repositories", repositories) + }) + .when_some(permissions, |input, permissions| { + permissions.into_iter().fold( + input, + |input, (permission, level)| { + input.add( + permission, + serde_json::to_value(&level).unwrap_or_default(), + ) + }, + ) + }) + }, + ), + ); + + let generated_token = StepOutput::new(&step, "token"); + (step, generated_token) + } +} + +pub(crate) struct RepositoryTarget { + owner: Option, + repositories: Option, + permissions: Option>, +} + +impl RepositoryTarget { + pub fn new(owner: T, repositories: &[&str]) -> Self { + Self { + owner: Some(owner.to_string()), + repositories: Some(repositories.join("\n")), + permissions: None, + } + } + + pub fn current() -> Self { + Self { + owner: None, + repositories: None, + permissions: None, + } + } + + pub fn permissions(self, permissions: impl Into>) -> Self { + Self { + permissions: Some(permissions.into()), + ..self + } + } +} + +pub(crate) fn generate_token<'a>( + app_id_source: &'a str, + app_secret_source: &'a str, +) -> GenerateAppToken<'a> { + generate_token_with_job_name(app_id_source, app_secret_source) +} + 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) + generate_token_with_job_name(vars::ZED_ZIPPY_APP_ID, vars::ZED_ZIPPY_APP_PRIVATE_KEY).into() +} + +fn generate_token_with_job_name<'a>( + app_id_source: &'a str, + app_secret_source: &'a str, +) -> GenerateAppToken<'a> { + GenerateAppToken { + job_name: function_name(1), + app_id: app_id_source, + app_secret: app_secret_source, + repository_target: None, + } } diff --git a/typos.toml b/typos.toml index 8c57caaf0417efdb01013e76f179515d9629a47c..959b5fc6f73477369572cdca3ff95d12b43f5ee1 100644 --- a/typos.toml +++ b/typos.toml @@ -49,8 +49,6 @@ extend-exclude = [ "docs/theme/c15t@*.js", # Spellcheck triggers on `|Fixe[sd]|` regex part. "script/danger/dangerfile.ts", - # Eval examples for prompts and criteria - "crates/eval/src/examples/", # File type extensions are not typos "crates/zed/resources/windows/zed.iss", # typos-cli doesn't understand our `vˇariable` markup @@ -93,6 +91,8 @@ extend-ignore-re = [ "ags", # AMD GPU Services "AGS", + # "noet" is a vim variable (ideally to ignore locally) + "noet", # Yarn Plug'n'Play "PnP" ]
), _window, cx| { let conflict = this.get_conflict(row_index); + let candidate_id = this.matches.get(row_index).map(|candidate| candidate.candidate_id); + let is_unbound_by_unbind = candidate_id + .and_then(|candidate_id| this.keybindings.get(candidate_id)) + .is_some_and(ProcessedBinding::is_unbound_by_unbind); let is_selected = this.selected_index == Some(row_index); let row_id = row_group_id(row_index); @@ -2177,38 +2259,43 @@ impl Render for KeymapEditor { .id(("keymap-row-wrapper", row_index)) .child( row.id(row_id.clone()) - .on_any_mouse_down(cx.listener( - move |this, - mouse_down_event: &gpui::MouseDownEvent, - window, - cx| { - if mouse_down_event.button == MouseButton::Right { - this.select_index( - row_index, None, window, cx, - ); - this.create_context_menu( - mouse_down_event.position, - window, - cx, - ); - } - }, - )) - .on_click(cx.listener( - move |this, event: &ClickEvent, window, cx| { - this.select_index(row_index, None, window, cx); - if event.click_count() == 2 { - this.open_edit_keybinding_modal( - false, window, cx, - ); - } - }, - )) + .when(!is_unbound_by_unbind, |row| { + row.on_any_mouse_down(cx.listener( + move |this, + mouse_down_event: &gpui::MouseDownEvent, + window, + cx| { + if mouse_down_event.button == MouseButton::Right { + this.select_index( + row_index, None, window, cx, + ); + this.create_context_menu( + mouse_down_event.position, + window, + cx, + ); + } + }, + )) + }) + .when(!is_unbound_by_unbind, |row| { + row.on_click(cx.listener( + move |this, event: &ClickEvent, window, cx| { + this.select_index(row_index, None, window, cx); + if event.click_count() == 2 { + this.open_edit_keybinding_modal( + false, window, cx, + ); + } + }, + )) + }) .group(row_id) .when( - conflict.is_some_and(|conflict| { - !conflict.is_user_keybind_conflict() - }), + is_unbound_by_unbind + || conflict.is_some_and(|conflict| { + !conflict.is_user_keybind_conflict() + }), |row| { const OVERRIDDEN_OPACITY: f32 = 0.5; row.opacity(OVERRIDDEN_OPACITY) @@ -2216,7 +2303,8 @@ impl Render for KeymapEditor { ) .when_some( conflict.filter(|conflict| { - !this.context_menu_deployed() && + !is_unbound_by_unbind + && !this.context_menu_deployed() && !conflict.is_user_keybind_conflict() }), |row, conflict| { @@ -2233,8 +2321,12 @@ impl Render for KeymapEditor { }.map(|source| format!("This keybinding is overridden by the '{}' binding from {}.", binding.action().humanized_name, source)) }).unwrap_or_else(|| "This binding is overridden.".to_string()); - row.tooltip(Tooltip::text(context))}, - ), + row.tooltip(Tooltip::text(context)) + }, + ) + .when(is_unbound_by_unbind, |row| { + row.tooltip(Tooltip::text("This action is unbound")) + }), ) .border_2() .when( @@ -2315,9 +2407,10 @@ impl RenderOnce for SyntaxHighlightedText { } let mut run_style = text_style.clone(); - if let Some(highlight_style) = highlight_id.style(syntax_theme) { + if let Some(highlight_style) = syntax_theme.get(highlight_id).cloned() { run_style = run_style.highlight(highlight_style); } + // add the highlighted range runs.push(run_style.to_run(highlight_range.len())); offset = highlight_range.end; @@ -3339,7 +3432,7 @@ impl ActionArgumentsEditor { impl Render for ActionArgumentsEditor { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let settings = theme::ThemeSettings::get_global(cx); + let settings = theme_settings::ThemeSettings::get_global(cx); let colors = cx.theme().colors(); let border_color = if self.is_loading { @@ -4056,4 +4149,25 @@ mod tests { assert!(cmp("!(!(!a))", "!a")); assert!(cmp("!(!(!(!a)))", "a")); } + + #[test] + fn binding_is_unbound_by_unbind_respects_precedence() { + let binding = gpui::KeyBinding::new("tab", zed_actions::OpenKeymap, None); + let unbind = + gpui::KeyBinding::new("tab", gpui::Unbind(binding.action().name().into()), None); + + let unbind_then_binding = vec![&unbind, &binding]; + assert!(!binding_is_unbound_by_unbind( + &binding, + 1, + &unbind_then_binding, + )); + + let binding_then_unbind = vec![&binding, &unbind]; + assert!(binding_is_unbound_by_unbind( + &binding, + 0, + &binding_then_unbind, + )); + } } diff --git a/crates/keymap_editor/src/ui_components/keystroke_input.rs b/crates/keymap_editor/src/ui_components/keystroke_input.rs index e1f20de587c274a164a96e3b8d7189a3710ff301..75cc2869c855283302e9e2ce57b9a511f8ba4d37 100644 --- a/crates/keymap_editor/src/ui_components/keystroke_input.rs +++ b/crates/keymap_editor/src/ui_components/keystroke_input.rs @@ -1115,7 +1115,7 @@ mod tests { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); }); let fs = FakeFs::new(cx.executor()); diff --git a/crates/language/Cargo.toml b/crates/language/Cargo.toml index 37c19172f7c48743e1436ba41e30d0c7ebf99d1d..1392ed63f64b7d3e3f6ebb9f629168f6096c5b61 100644 --- a/crates/language/Cargo.toml +++ b/crates/language/Cargo.toml @@ -40,6 +40,7 @@ globset.workspace = true gpui.workspace = true http_client.workspace = true imara-diff.workspace = true +language_core.workspace = true itertools.workspace = true log.workspace = true lsp.workspace = true @@ -48,7 +49,6 @@ postage.workspace = true rand = { workspace = true, optional = true } regex.workspace = true rpc.workspace = true -schemars.workspace = true semver.workspace = true serde.workspace = true serde_json.workspace = true @@ -101,6 +101,12 @@ toml.workspace = true unindent.workspace = true util = { workspace = true, features = ["test-support"] } zlog.workspace = true +criterion.workspace = true +theme_settings.workspace = true + +[[bench]] +name = "highlight_map" +harness = false [package.metadata.cargo-machete] ignored = ["tracing"] diff --git a/crates/language/benches/highlight_map.rs b/crates/language/benches/highlight_map.rs new file mode 100644 index 0000000000000000000000000000000000000000..678bd08a8db40b588c6f2716a14b04048fb46d23 --- /dev/null +++ b/crates/language/benches/highlight_map.rs @@ -0,0 +1,144 @@ +use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main}; +use gpui::rgba; +use language::build_highlight_map; +use theme::SyntaxTheme; + +fn syntax_theme(highlight_names: &[&str]) -> SyntaxTheme { + SyntaxTheme::new(highlight_names.iter().enumerate().map(|(i, name)| { + let r = ((i * 37) % 256) as u8; + let g = ((i * 53) % 256) as u8; + let b = ((i * 71) % 256) as u8; + let color = rgba(u32::from_be_bytes([r, g, b, 0xff])); + (name.to_string(), color.into()) + })) +} + +static SMALL_THEME_KEYS: &[&str] = &[ + "comment", "function", "keyword", "string", "type", "variable", +]; + +static LARGE_THEME_KEYS: &[&str] = &[ + "attribute", + "boolean", + "comment", + "comment.doc", + "constant", + "constant.builtin", + "constructor", + "embedded", + "emphasis", + "emphasis.strong", + "function", + "function.builtin", + "function.method", + "function.method.builtin", + "function.special.definition", + "keyword", + "keyword.control", + "keyword.control.conditional", + "keyword.control.import", + "keyword.control.repeat", + "keyword.control.return", + "keyword.modifier", + "keyword.operator", + "label", + "link_text", + "link_uri", + "number", + "operator", + "property", + "punctuation", + "punctuation.bracket", + "punctuation.delimiter", + "punctuation.list_marker", + "punctuation.special", + "string", + "string.escape", + "string.regex", + "string.special", + "string.special.symbol", + "tag", + "text.literal", + "title", + "type", + "type.builtin", + "type.super", + "variable", + "variable.builtin", + "variable.member", + "variable.parameter", + "variable.special", +]; + +static SMALL_CAPTURE_NAMES: &[&str] = &[ + "function", + "keyword", + "string.escape", + "type.builtin", + "variable.builtin", +]; + +static LARGE_CAPTURE_NAMES: &[&str] = &[ + "attribute", + "boolean", + "comment", + "comment.doc", + "constant", + "constant.builtin", + "constructor", + "function", + "function.builtin", + "function.method", + "keyword", + "keyword.control", + "keyword.control.conditional", + "keyword.control.import", + "keyword.modifier", + "keyword.operator", + "label", + "number", + "operator", + "property", + "punctuation.bracket", + "punctuation.delimiter", + "punctuation.special", + "string", + "string.escape", + "string.regex", + "string.special", + "tag", + "type", + "type.builtin", + "variable", + "variable.builtin", + "variable.member", + "variable.parameter", +]; + +fn bench_build_highlight_map(c: &mut Criterion) { + let mut group = c.benchmark_group("build_highlight_map"); + + for (capture_label, capture_names) in [ + ("small_captures", SMALL_CAPTURE_NAMES as &[&str]), + ("large_captures", LARGE_CAPTURE_NAMES as &[&str]), + ] { + for (theme_label, theme_keys) in [ + ("small_theme", SMALL_THEME_KEYS as &[&str]), + ("large_theme", LARGE_THEME_KEYS as &[&str]), + ] { + let theme = syntax_theme(theme_keys); + group.bench_with_input( + BenchmarkId::new(capture_label, theme_label), + &(capture_names, &theme), + |b, (capture_names, theme)| { + b.iter(|| build_highlight_map(black_box(capture_names), black_box(theme))); + }, + ); + } + } + + group.finish(); +} + +criterion_group!(benches, bench_build_highlight_map); +criterion_main!(benches); diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 8a3886a7832fabbd67340f7f6d19b36557aa24a8..b2ab420312249f809599d06315e706627b76570b 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -1,10 +1,10 @@ pub mod row_chunk; use crate::{ - DebuggerTextObject, LanguageScope, Outline, OutlineConfig, PLAIN_TEXT, RunnableCapture, - RunnableTag, TextObject, TreeSitterOptions, + DebuggerTextObject, LanguageScope, ModelineSettings, Outline, OutlineConfig, PLAIN_TEXT, + RunnableCapture, RunnableTag, TextObject, TreeSitterOptions, diagnostic_set::{DiagnosticEntry, DiagnosticEntryRef, DiagnosticGroup}, - language_settings::{AutoIndentMode, LanguageSettings, language_settings}, + language_settings::{AutoIndentMode, LanguageSettings}, outline::OutlineItem, row_chunk::RowChunks, syntax_map::{ @@ -16,11 +16,10 @@ use crate::{ unified_diff_with_offsets, }; pub use crate::{ - Grammar, Language, LanguageRegistry, - diagnostic_set::DiagnosticSet, - highlight_map::{HighlightId, HighlightMap}, + Grammar, HighlightId, HighlightMap, Language, LanguageRegistry, diagnostic_set::DiagnosticSet, proto, }; + use anyhow::{Context as _, Result}; use clock::Lamport; pub use clock::ReplicaId; @@ -33,10 +32,8 @@ use gpui::{ Task, TextStyle, }; -use lsp::{LanguageServerId, NumberOrString}; +use lsp::LanguageServerId; use parking_lot::Mutex; -use serde::{Deserialize, Serialize}; -use serde_json::Value; use settings::WorktreeId; use smallvec::SmallVec; use smol::future::yield_now; @@ -135,6 +132,7 @@ pub struct Buffer { /// The contents of a cell are (self.version, has_changes) at the time of a last call. has_unsaved_edits: Cell<(clock::Global, bool)>, change_bits: Vec>>, + modeline: Option>, _subscriptions: Vec, tree_sitter_data: Arc, encoding: &'static Encoding, @@ -195,6 +193,7 @@ pub struct BufferSnapshot { file: Option>, non_text_state_update_count: usize, pub capability: Capability, + modeline: Option>, } /// The kind and amount of indentation in a particular line. For now, @@ -250,57 +249,6 @@ struct SelectionSet { lamport_timestamp: clock::Lamport, } -/// A diagnostic associated with a certain range of a buffer. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct Diagnostic { - /// The name of the service that produced this diagnostic. - pub source: Option, - /// The ID provided by the dynamic registration that produced this diagnostic. - pub registration_id: Option, - /// A machine-readable code that identifies this diagnostic. - pub code: Option, - pub code_description: Option, - /// Whether this diagnostic is a hint, warning, or error. - pub severity: DiagnosticSeverity, - /// The human-readable message associated with this diagnostic. - pub message: String, - /// The human-readable message (in markdown format) - pub markdown: Option, - /// An id that identifies the group to which this diagnostic belongs. - /// - /// When a language server produces a diagnostic with - /// one or more associated diagnostics, those diagnostics are all - /// assigned a single group ID. - pub group_id: usize, - /// Whether this diagnostic is the primary diagnostic for its group. - /// - /// In a given group, the primary diagnostic is the top-level diagnostic - /// returned by the language server. The non-primary diagnostics are the - /// associated diagnostics. - pub is_primary: bool, - /// Whether this diagnostic is considered to originate from an analysis of - /// files on disk, as opposed to any unsaved buffer contents. This is a - /// property of a given diagnostic source, and is configured for a given - /// language server via the [`LspAdapter::disk_based_diagnostic_sources`](crate::LspAdapter::disk_based_diagnostic_sources) method - /// for the language server. - pub is_disk_based: bool, - /// Whether this diagnostic marks unnecessary code. - pub is_unnecessary: bool, - /// Quick separation of diagnostics groups based by their source. - pub source_kind: DiagnosticSourceKind, - /// Data from language server that produced this diagnostic. Passed back to the LS when we request code actions for this diagnostic. - pub data: Option, - /// Whether to underline the corresponding text range in the editor. - pub underline: bool, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum DiagnosticSourceKind { - Pulled, - Pushed, - Other, -} - /// An operation used to synchronize this buffer with its other replicas. #[derive(Clone, Debug, PartialEq)] pub enum Operation { @@ -747,7 +695,7 @@ impl HighlightedTextBuilder { if let Some(highlight_style) = chunk .syntax_highlight_id - .and_then(|id| id.style(syntax_theme)) + .and_then(|id| syntax_theme.get(id).cloned()) { let highlight_style = override_style.map_or(highlight_style, |override_style| { highlight_style.highlight(override_style) @@ -1163,6 +1111,7 @@ impl Buffer { deferred_ops: OperationQueue::new(), has_conflict: false, change_bits: Default::default(), + modeline: None, _subscriptions: Vec::new(), encoding: encoding_rs::UTF_8, has_bom: false, @@ -1175,6 +1124,7 @@ impl Buffer { text: Rope, language: Option>, language_registry: Option>, + modeline: Option>, cx: &mut App, ) -> impl Future + use<> { let entity_id = cx.reserve_entity::().entity_id(); @@ -1199,6 +1149,7 @@ impl Buffer { language, non_text_state_update_count: 0, capability: Capability::ReadOnly, + modeline, } } } @@ -1225,6 +1176,7 @@ impl Buffer { language: None, non_text_state_update_count: 0, capability: Capability::ReadOnly, + modeline: None, } } @@ -1255,6 +1207,7 @@ impl Buffer { language, non_text_state_update_count: 0, capability: Capability::ReadOnly, + modeline: None, } } @@ -1285,6 +1238,7 @@ impl Buffer { language: self.language.clone(), non_text_state_update_count: self.non_text_state_update_count, capability: self.capability, + modeline: self.modeline.clone(), } } @@ -1537,6 +1491,21 @@ impl Buffer { ); } + /// Assign the buffer [`ModelineSettings`]. + pub fn set_modeline(&mut self, modeline: Option) -> bool { + if modeline.as_ref() != self.modeline.as_deref() { + self.modeline = modeline.map(Arc::new); + true + } else { + false + } + } + + /// Returns the [`ModelineSettings`]. + pub fn modeline(&self) -> Option<&Arc> { + self.modeline.as_ref() + } + /// Assign the buffer a new [`Capability`]. pub fn set_capability(&mut self, capability: Capability, cx: &mut Context) { if self.capability != capability { @@ -2755,8 +2724,12 @@ impl Buffer { } else { // The auto-indent setting is not present in editorconfigs, hence // we can avoid passing the file here. - let auto_indent_mode = - language_settings(language.map(|l| l.name()), None, cx).auto_indent; + let auto_indent_mode = LanguageSettings::resolve( + None, + language.map(|l| l.name()).as_ref(), + cx, + ) + .auto_indent; let apply_syntax_indent = auto_indent_mode == AutoIndentMode::SyntaxAware; previous_setting = Some((language_id, apply_syntax_indent)); apply_syntax_indent @@ -3301,6 +3274,10 @@ impl Buffer { pub fn preserve_preview(&self) -> bool { !self.has_edits_since(&self.preview_version) } + + pub fn set_group_interval(&mut self, group_interval: Duration) { + self.text.set_group_interval(group_interval); + } } #[doc(hidden)] @@ -3316,10 +3293,6 @@ impl Buffer { self.edit(edits, autoindent_mode, cx); } - pub fn set_group_interval(&mut self, group_interval: Duration) { - self.text.set_group_interval(group_interval); - } - pub fn randomly_edit(&mut self, rng: &mut T, old_range_count: usize, cx: &mut Context) where T: rand::Rng, @@ -3397,11 +3370,7 @@ impl BufferSnapshot { /// Returns [`IndentSize`] for a given position that respects user settings /// and language preferences. pub fn language_indent_size_at(&self, position: T, cx: &App) -> IndentSize { - let settings = language_settings( - self.language_at(position).map(|l| l.name()), - self.file(), - cx, - ); + let settings = self.settings_at(position, cx); if settings.hard_tabs { IndentSize::tab() } else { @@ -3867,6 +3836,11 @@ impl BufferSnapshot { }) } + /// Returns the [`ModelineSettings`]. + pub fn modeline(&self) -> Option<&Arc> { + self.modeline.as_ref() + } + /// Returns the main [`Language`]. pub fn language(&self) -> Option<&Arc> { self.language.as_ref() @@ -3885,11 +3859,7 @@ impl BufferSnapshot { position: D, cx: &'a App, ) -> Cow<'a, LanguageSettings> { - language_settings( - self.language_at(position).map(|l| l.name()), - self.file.as_ref(), - cx, - ) + LanguageSettings::for_buffer_snapshot(self, Some(position.to_offset(self)), cx) } pub fn char_classifier_at(&self, point: T) -> CharClassifier { @@ -4527,7 +4497,8 @@ impl BufferSnapshot { let style = chunk .syntax_highlight_id .zip(theme) - .and_then(|(highlight, theme)| highlight.style(theme)); + .and_then(|(highlight, theme)| theme.get(highlight).cloned()); + if let Some(style) = style { let start = text.len(); let end = start + chunk.text.len(); @@ -5511,6 +5482,7 @@ impl Clone for BufferSnapshot { tree_sitter_data: self.tree_sitter_data.clone(), non_text_state_update_count: self.non_text_state_update_count, capability: self.capability, + modeline: self.modeline.clone(), } } } @@ -5811,27 +5783,6 @@ impl operation_queue::Operation for Operation { } } -impl Default for Diagnostic { - fn default() -> Self { - Self { - source: Default::default(), - source_kind: DiagnosticSourceKind::Other, - code: None, - code_description: None, - severity: DiagnosticSeverity::ERROR, - message: Default::default(), - markdown: None, - group_id: 0, - is_primary: false, - is_disk_based: false, - is_unnecessary: false, - underline: true, - data: None, - registration_id: None, - } - } -} - impl IndentSize { /// Returns an [`IndentSize`] representing the given spaces. pub fn spaces(len: u32) -> Self { diff --git a/crates/language/src/buffer_tests.rs b/crates/language/src/buffer_tests.rs index a47578faa2037e5f17a0e2be4ce5329e61d0fa84..9308ee6f0a0ee207b30be9e6fafa73ba9452d94c 100644 --- a/crates/language/src/buffer_tests.rs +++ b/crates/language/src/buffer_tests.rs @@ -246,6 +246,7 @@ async fn test_first_line_pattern(cx: &mut TestAppContext) { matcher: LanguageMatcher { path_suffixes: vec!["js".into()], first_line_pattern: Some(Regex::new(r"\bnode\b").unwrap()), + ..LanguageMatcher::default() }, ..Default::default() }); @@ -3246,7 +3247,7 @@ fn test_undo_after_merge_into_base(cx: &mut TestAppContext) { async fn test_preview_edits(cx: &mut TestAppContext) { cx.update(|cx| { init_settings(cx, |_| {}); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); }); let insertion_style = HighlightStyle { diff --git a/crates/language/src/diagnostic.rs b/crates/language/src/diagnostic.rs new file mode 100644 index 0000000000000000000000000000000000000000..951feec0da18582b56b361797efc0b346e7b2a04 --- /dev/null +++ b/crates/language/src/diagnostic.rs @@ -0,0 +1 @@ +pub use language_core::diagnostic::{Diagnostic, DiagnosticSourceKind}; diff --git a/crates/language/src/highlight_map.rs b/crates/language/src/highlight_map.rs deleted file mode 100644 index ed9eb5d11d7bc4b156dc9bd660fb10a485129c3d..0000000000000000000000000000000000000000 --- a/crates/language/src/highlight_map.rs +++ /dev/null @@ -1,114 +0,0 @@ -use gpui::HighlightStyle; -use std::sync::Arc; -use theme::SyntaxTheme; - -#[derive(Clone, Debug)] -pub struct HighlightMap(Arc<[HighlightId]>); - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct HighlightId(pub u32); - -const DEFAULT_SYNTAX_HIGHLIGHT_ID: HighlightId = HighlightId(u32::MAX); - -impl HighlightMap { - pub(crate) fn new(capture_names: &[&str], theme: &SyntaxTheme) -> Self { - // For each capture name in the highlight query, find the longest - // key in the theme's syntax styles that matches all of the - // dot-separated components of the capture name. - HighlightMap( - capture_names - .iter() - .map(|capture_name| { - theme - .highlights - .iter() - .enumerate() - .filter_map(|(i, (key, _))| { - let mut len = 0; - let capture_parts = capture_name.split('.'); - for key_part in key.split('.') { - if capture_parts.clone().any(|part| part == key_part) { - len += 1; - } else { - return None; - } - } - Some((i, len)) - }) - .max_by_key(|(_, len)| *len) - .map_or(DEFAULT_SYNTAX_HIGHLIGHT_ID, |(i, _)| HighlightId(i as u32)) - }) - .collect(), - ) - } - - pub fn get(&self, capture_id: u32) -> HighlightId { - self.0 - .get(capture_id as usize) - .copied() - .unwrap_or(DEFAULT_SYNTAX_HIGHLIGHT_ID) - } -} - -impl HighlightId { - pub const TABSTOP_INSERT_ID: HighlightId = HighlightId(u32::MAX - 1); - pub const TABSTOP_REPLACE_ID: HighlightId = HighlightId(u32::MAX - 2); - - pub(crate) fn is_default(&self) -> bool { - *self == DEFAULT_SYNTAX_HIGHLIGHT_ID - } - - pub fn style(&self, theme: &SyntaxTheme) -> Option { - theme.highlights.get(self.0 as usize).map(|entry| entry.1) - } - - pub fn name<'a>(&self, theme: &'a SyntaxTheme) -> Option<&'a str> { - theme.highlights.get(self.0 as usize).map(|e| e.0.as_str()) - } -} - -impl Default for HighlightMap { - fn default() -> Self { - Self(Arc::new([])) - } -} - -impl Default for HighlightId { - fn default() -> Self { - DEFAULT_SYNTAX_HIGHLIGHT_ID - } -} - -#[cfg(test)] -mod tests { - use super::*; - use gpui::rgba; - - #[test] - fn test_highlight_map() { - let theme = SyntaxTheme { - highlights: [ - ("function", rgba(0x100000ff)), - ("function.method", rgba(0x200000ff)), - ("function.async", rgba(0x300000ff)), - ("variable.builtin.self.rust", rgba(0x400000ff)), - ("variable.builtin", rgba(0x500000ff)), - ("variable", rgba(0x600000ff)), - ] - .iter() - .map(|(name, color)| (name.to_string(), (*color).into())) - .collect(), - }; - - let capture_names = &[ - "function.special", - "function.async.rust", - "variable.builtin.self", - ]; - - let map = HighlightMap::new(capture_names, &theme); - assert_eq!(map.get(0).name(&theme), Some("function")); - assert_eq!(map.get(1).name(&theme), Some("function.async")); - assert_eq!(map.get(2).name(&theme), Some("variable.builtin")); - } -} diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index 4e994a7e60f58b6e4ccd50c2cb0584f91bd351f2..035cb3a2009241cc4ff97a7adf4c82de73166a76 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -7,11 +7,13 @@ //! //! Notably we do *not* assign a single language to a single file; in real world a single file can consist of multiple programming languages - HTML is a good example of that - and `language` crate tends to reflect that status quo in its API. mod buffer; +mod diagnostic; mod diagnostic_set; -mod highlight_map; mod language_registry; + pub mod language_settings; mod manifest; +pub mod modeline; mod outline; pub mod proto; mod syntax_map; @@ -22,17 +24,29 @@ mod toolchain; #[cfg(test)] pub mod buffer_tests; -use crate::language_settings::SoftWrap; pub use crate::language_settings::{AutoIndentMode, EditPredictionsMode, IndentGuideSettings}; use anyhow::{Context as _, Result}; use async_trait::async_trait; -use collections::{HashMap, HashSet, IndexSet}; +use collections::{HashMap, HashSet}; use futures::Future; use futures::future::LocalBoxFuture; use futures::lock::OwnedMutexGuard; -use gpui::{App, AsyncApp, Entity, SharedString}; -pub use highlight_map::HighlightMap; +use gpui::{App, AsyncApp, Entity}; use http_client::HttpClient; + +pub use language_core::highlight_map::{HighlightId, HighlightMap}; + +pub use language_core::{ + BlockCommentConfig, BracketPair, BracketPairConfig, BracketPairContent, BracketsConfig, + BracketsPatternConfig, CodeLabel, CodeLabelBuilder, DebugVariablesConfig, DebuggerTextObject, + DecreaseIndentConfig, Grammar, GrammarId, HighlightsConfig, IndentConfig, InjectionConfig, + InjectionPatternConfig, JsxTagAutoCloseConfig, LanguageConfig, LanguageConfigOverride, + LanguageId, LanguageMatcher, OrderedListConfig, OutlineConfig, Override, OverrideConfig, + OverrideEntry, PromptResponseContext, RedactionConfig, RunnableCapture, RunnableConfig, + SoftWrap, Symbol, TaskListConfig, TextObject, TextObjectConfig, ToLspPosition, + WrapCharactersConfig, auto_indent_using_last_non_empty_line_default, deserialize_regex, + deserialize_regex_vec, regex_json_schema, regex_vec_json_schema, serialize_regex, +}; pub use language_registry::{ LanguageName, LanguageServerStatusUpdate, LoadedLanguage, ServerHealth, }; @@ -40,15 +54,13 @@ use lsp::{ CodeActionKind, InitializeParams, LanguageServerBinary, LanguageServerBinaryOptions, Uri, }; pub use manifest::{ManifestDelegate, ManifestName, ManifestProvider, ManifestQuery}; +pub use modeline::{ModelineSettings, parse_modeline}; 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; use smol::future::FutureExt as _; -use std::num::NonZeroU32; use std::{ ffi::OsStr, fmt::Debug, @@ -57,10 +69,7 @@ use std::{ ops::{DerefMut, Range}, path::{Path, PathBuf}, str, - sync::{ - Arc, LazyLock, - atomic::{AtomicUsize, Ordering::SeqCst}, - }, + sync::{Arc, LazyLock}, }; use syntax_map::{QueryCursorHandle, SyntaxSnapshot}; use task::RunnableTag; @@ -75,12 +84,12 @@ pub use toolchain::{ LanguageToolchainStore, LocalLanguageToolchainStore, Toolchain, ToolchainList, ToolchainLister, ToolchainMetadata, ToolchainScope, }; -use tree_sitter::{self, Query, QueryCursor, WasmStore, wasmtime}; +use tree_sitter::{self, QueryCursor, WasmStore, wasmtime}; use util::rel_path::RelPath; -use util::serde::default_true; pub use buffer::Operation; pub use buffer::*; +pub use diagnostic::{Diagnostic, DiagnosticSourceKind}; pub use diagnostic_set::{DiagnosticEntry, DiagnosticEntryRef, DiagnosticGroup}; pub use language_registry::{ AvailableLanguage, BinaryStatus, LanguageNotFound, LanguageQueries, LanguageRegistry, @@ -94,6 +103,16 @@ pub use syntax_map::{ pub use text::{AnchorRangeExt, LineEnding}; pub use tree_sitter::{Node, Parser, Tree, TreeCursor}; +pub(crate) fn to_settings_soft_wrap(value: language_core::SoftWrap) -> settings::SoftWrap { + match value { + language_core::SoftWrap::None => settings::SoftWrap::None, + language_core::SoftWrap::PreferLine => settings::SoftWrap::PreferLine, + language_core::SoftWrap::EditorWidth => settings::SoftWrap::EditorWidth, + language_core::SoftWrap::PreferredLineLength => settings::SoftWrap::PreferredLineLength, + language_core::SoftWrap::Bounded => settings::SoftWrap::Bounded, + } +} + static QUERY_CURSORS: Mutex> = Mutex::new(vec![]); static PARSERS: Mutex> = Mutex::new(vec![]); @@ -123,8 +142,6 @@ where func(cursor.deref_mut()) } -static NEXT_LANGUAGE_ID: AtomicUsize = AtomicUsize::new(0); -static NEXT_GRAMMAR_ID: AtomicUsize = AtomicUsize::new(0); static WASM_ENGINE: LazyLock = LazyLock::new(|| { wasmtime::Engine::new(&wasmtime::Config::new()).expect("Failed to create Wasmtime engine") }); @@ -138,6 +155,7 @@ pub static PLAIN_TEXT: LazyLock> = LazyLock::new(|| { matcher: LanguageMatcher { path_suffixes: vec!["txt".to_owned()], first_line_pattern: None, + modeline_aliases: vec!["text".to_owned(), "txt".to_owned()], }, brackets: BracketPairConfig { pairs: vec![ @@ -185,26 +203,12 @@ pub static PLAIN_TEXT: LazyLock> = LazyLock::new(|| { )) }); -/// Types that represent a position in a buffer, and can be converted into -/// an LSP position, to send to a language server. -pub trait ToLspPosition { - /// Converts the value into an LSP position. - fn to_lsp_position(self) -> lsp::Position; -} - #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct Location { pub buffer: Entity, pub range: Range, } -#[derive(Debug, Clone)] -pub struct Symbol { - pub name: String, - pub kind: lsp::SymbolKind, - pub container_name: Option, -} - type ServerBinaryCache = futures::lock::Mutex>; type DownloadableLanguageServerBinary = LocalBoxFuture<'static, Result>; pub type LanguageServerBinaryLocations = LocalBoxFuture< @@ -289,14 +293,12 @@ impl CachedLspAdapter { &self, params: &mut lsp::PublishDiagnosticsParams, server_id: LanguageServerId, - existing_diagnostics: Option<&'_ Buffer>, ) { - self.adapter - .process_diagnostics(params, server_id, existing_diagnostics) + self.adapter.process_diagnostics(params, server_id) } - pub fn retain_old_diagnostic(&self, previous_diagnostic: &Diagnostic, cx: &App) -> bool { - self.adapter.retain_old_diagnostic(previous_diagnostic, cx) + pub fn retain_old_diagnostic(&self, previous_diagnostic: &Diagnostic) -> bool { + self.adapter.retain_old_diagnostic(previous_diagnostic) } pub fn underline_diagnostic(&self, diagnostic: &lsp::Diagnostic) -> bool { @@ -394,31 +396,14 @@ pub trait LspAdapterDelegate: Send + Sync { 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; - fn process_diagnostics( - &self, - _: &mut lsp::PublishDiagnosticsParams, - _: LanguageServerId, - _: Option<&'_ Buffer>, - ) { - } + fn process_diagnostics(&self, _: &mut lsp::PublishDiagnosticsParams, _: LanguageServerId) {} /// When processing new `lsp::PublishDiagnosticsParams` diagnostics, whether to retain previous one(s) or not. - fn retain_old_diagnostic(&self, _previous_diagnostic: &Diagnostic, _cx: &App) -> bool { + fn retain_old_diagnostic(&self, _previous_diagnostic: &Diagnostic) -> bool { false } @@ -809,295 +794,6 @@ where } } -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct CodeLabel { - /// The text to display. - pub text: String, - /// Syntax highlighting runs. - pub runs: Vec<(Range, HighlightId)>, - /// The portion of the text that should be used in fuzzy filtering. - pub filter_range: Range, -} - -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct CodeLabelBuilder { - /// The text to display. - text: String, - /// Syntax highlighting runs. - runs: Vec<(Range, HighlightId)>, - /// The portion of the text that should be used in fuzzy filtering. - filter_range: Range, -} - -#[derive(Clone, Deserialize, JsonSchema, Debug)] -pub struct LanguageConfig { - /// Human-readable name of the language. - pub name: LanguageName, - /// The name of this language for a Markdown code fence block - pub code_fence_block_name: Option>, - /// Alternative language names that Jupyter kernels may report for this language. - /// Used when a kernel's `language` field differs from Zed's language name. - /// For example, the Nu extension would set this to `["nushell"]`. - #[serde(default)] - pub kernel_language_names: Vec>, - // The name of the grammar in a WASM bundle (experimental). - pub grammar: Option>, - /// The criteria for matching this language to a given file. - #[serde(flatten)] - pub matcher: LanguageMatcher, - /// List of bracket types in a language. - #[serde(default)] - pub brackets: BracketPairConfig, - /// If set to true, auto indentation uses last non empty line to determine - /// the indentation level for a new line. - #[serde(default = "auto_indent_using_last_non_empty_line_default")] - pub auto_indent_using_last_non_empty_line: bool, - // Whether indentation of pasted content should be adjusted based on the context. - #[serde(default)] - pub auto_indent_on_paste: Option, - /// A regex that is used to determine whether the indentation level should be - /// increased in the following line. - #[serde(default, deserialize_with = "deserialize_regex")] - #[schemars(schema_with = "regex_json_schema")] - pub increase_indent_pattern: Option, - /// A regex that is used to determine whether the indentation level should be - /// decreased in the following line. - #[serde(default, deserialize_with = "deserialize_regex")] - #[schemars(schema_with = "regex_json_schema")] - pub decrease_indent_pattern: Option, - /// A list of rules for decreasing indentation. Each rule pairs a regex with a set of valid - /// "block-starting" tokens. When a line matches a pattern, its indentation is aligned with - /// the most recent line that began with a corresponding token. This enables context-aware - /// outdenting, like aligning an `else` with its `if`. - #[serde(default)] - pub decrease_indent_patterns: Vec, - /// A list of characters that trigger the automatic insertion of a closing - /// bracket when they immediately precede the point where an opening - /// bracket is inserted. - #[serde(default)] - pub autoclose_before: String, - /// A placeholder used internally by Semantic Index. - #[serde(default)] - pub collapsed_placeholder: String, - /// A line comment string that is inserted in e.g. `toggle comments` action. - /// A language can have multiple flavours of line comments. All of the provided line comments are - /// used for comment continuations on the next line, but only the first one is used for Editor::ToggleComments. - #[serde(default)] - pub line_comments: Vec>, - /// Delimiters and configuration for recognizing and formatting block comments. - #[serde(default)] - pub block_comment: Option, - /// Delimiters and configuration for recognizing and formatting documentation comments. - #[serde(default, alias = "documentation")] - pub documentation_comment: Option, - /// List markers that are inserted unchanged on newline (e.g., `- `, `* `, `+ `). - #[serde(default)] - pub unordered_list: Vec>, - /// Configuration for ordered lists with auto-incrementing numbers on newline (e.g., `1. ` becomes `2. `). - #[serde(default)] - pub ordered_list: Vec, - /// Configuration for task lists where multiple markers map to a single continuation prefix (e.g., `- [x] ` continues as `- [ ] `). - #[serde(default)] - pub task_list: Option, - /// A list of additional regex patterns that should be treated as prefixes - /// for creating boundaries during rewrapping, ensuring content from one - /// prefixed section doesn't merge with another (e.g., markdown list items). - /// By default, Zed treats as paragraph and comment prefixes as boundaries. - #[serde(default, deserialize_with = "deserialize_regex_vec")] - #[schemars(schema_with = "regex_vec_json_schema")] - pub rewrap_prefixes: Vec, - /// A list of language servers that are allowed to run on subranges of a given language. - #[serde(default)] - pub scope_opt_in_language_servers: Vec, - #[serde(default)] - pub overrides: HashMap, - /// A list of characters that Zed should treat as word characters for the - /// purpose of features that operate on word boundaries, like 'move to next word end' - /// or a whole-word search in buffer search. - #[serde(default)] - pub word_characters: HashSet, - /// Whether to indent lines using tab characters, as opposed to multiple - /// spaces. - #[serde(default)] - pub hard_tabs: Option, - /// How many columns a tab should occupy. - #[serde(default)] - #[schemars(range(min = 1, max = 128))] - pub tab_size: Option, - /// How to soft-wrap long lines of text. - #[serde(default)] - pub soft_wrap: Option, - /// When set, selections can be wrapped using prefix/suffix pairs on both sides. - #[serde(default)] - pub wrap_characters: Option, - /// The name of a Prettier parser that will be used for this language when no file path is available. - /// If there's a parser name in the language settings, that will be used instead. - #[serde(default)] - pub prettier_parser_name: Option, - /// If true, this language is only for syntax highlighting via an injection into other - /// languages, but should not appear to the user as a distinct language. - #[serde(default)] - pub hidden: bool, - /// If configured, this language contains JSX style tags, and should support auto-closing of those tags. - #[serde(default)] - pub jsx_tag_auto_close: Option, - /// A list of characters that Zed should treat as word characters for completion queries. - #[serde(default)] - pub completion_query_characters: HashSet, - /// A list of characters that Zed should treat as word characters for linked edit operations. - #[serde(default)] - pub linked_edit_characters: HashSet, - /// A list of preferred debuggers for this language. - #[serde(default)] - pub debuggers: IndexSet, - /// A list of import namespace segments that aren't expected to appear in file paths. For - /// example, "super" and "crate" in Rust. - #[serde(default)] - pub ignored_import_segments: HashSet>, - /// Regular expression that matches substrings to omit from import paths, to make the paths more - /// similar to how they are specified when imported. For example, "/mod\.rs$" or "/__init__\.py$". - #[serde(default, deserialize_with = "deserialize_regex")] - #[schemars(schema_with = "regex_json_schema")] - pub import_path_strip_regex: Option, -} - -impl LanguageConfig { - pub const FILE_NAME: &str = "config.toml"; - - pub fn load(config_path: impl AsRef) -> Result { - let config = std::fs::read_to_string(config_path.as_ref())?; - toml::from_str(&config).map_err(Into::into) - } -} - -#[derive(Clone, Debug, Deserialize, Default, JsonSchema)] -pub struct DecreaseIndentConfig { - #[serde(default, deserialize_with = "deserialize_regex")] - #[schemars(schema_with = "regex_json_schema")] - pub pattern: Option, - #[serde(default)] - pub valid_after: Vec, -} - -/// Configuration for continuing ordered lists with auto-incrementing numbers. -#[derive(Clone, Debug, Deserialize, JsonSchema)] -pub struct OrderedListConfig { - /// A regex pattern with a capture group for the number portion (e.g., `(\\d+)\\. `). - pub pattern: String, - /// A format string where `{1}` is replaced with the incremented number (e.g., `{1}. `). - pub format: String, -} - -/// Configuration for continuing task lists on newline. -#[derive(Clone, Debug, Deserialize, JsonSchema)] -pub struct TaskListConfig { - /// The list markers to match (e.g., `- [ ] `, `- [x] `). - pub prefixes: Vec>, - /// The marker to insert when continuing the list on a new line (e.g., `- [ ] `). - pub continuation: Arc, -} - -#[derive(Clone, Debug, Serialize, Deserialize, Default, JsonSchema)] -pub struct LanguageMatcher { - /// Given a list of `LanguageConfig`'s, the language of a file can be determined based on the path extension matching any of the `path_suffixes`. - #[serde(default)] - pub path_suffixes: Vec, - /// A regex pattern that determines whether the language should be assigned to a file or not. - #[serde( - default, - serialize_with = "serialize_regex", - deserialize_with = "deserialize_regex" - )] - #[schemars(schema_with = "regex_json_schema")] - pub first_line_pattern: Option, -} - -/// The configuration for JSX tag auto-closing. -#[derive(Clone, Deserialize, JsonSchema, Debug)] -pub struct JsxTagAutoCloseConfig { - /// The name of the node for a opening tag - pub open_tag_node_name: String, - /// The name of the node for an closing tag - pub close_tag_node_name: String, - /// The name of the node for a complete element with children for open and close tags - pub jsx_element_node_name: String, - /// The name of the node found within both opening and closing - /// tags that describes the tag name - pub tag_name_node_name: String, - /// Alternate Node names for tag names. - /// Specifically needed as TSX represents the name in `` - /// as `member_expression` rather than `identifier` as usual - #[serde(default)] - pub tag_name_node_name_alternates: Vec, - /// Some grammars are smart enough to detect a closing tag - /// that is not valid i.e. doesn't match it's corresponding - /// opening tag or does not have a corresponding opening tag - /// This should be set to the name of the node for invalid - /// closing tags if the grammar contains such a node, otherwise - /// detecting already closed tags will not work properly - #[serde(default)] - pub erroneous_close_tag_node_name: Option, - /// See above for erroneous_close_tag_node_name for details - /// This should be set if the node used for the tag name - /// within erroneous closing tags is different from the - /// normal tag name node name - #[serde(default)] - pub erroneous_close_tag_name_node_name: Option, -} - -/// The configuration for block comments for this language. -#[derive(Clone, Debug, JsonSchema, PartialEq)] -pub struct BlockCommentConfig { - /// A start tag of block comment. - pub start: Arc, - /// A end tag of block comment. - pub end: Arc, - /// A character to add as a prefix when a new line is added to a block comment. - pub prefix: Arc, - /// A indent to add for prefix and end line upon new line. - #[schemars(range(min = 1, max = 128))] - pub tab_size: u32, -} - -impl<'de> Deserialize<'de> for BlockCommentConfig { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - #[derive(Deserialize)] - #[serde(untagged)] - enum BlockCommentConfigHelper { - New { - start: Arc, - end: Arc, - prefix: Arc, - tab_size: u32, - }, - Old([Arc; 2]), - } - - match BlockCommentConfigHelper::deserialize(deserializer)? { - BlockCommentConfigHelper::New { - start, - end, - prefix, - tab_size, - } => Ok(BlockCommentConfig { - start, - end, - prefix, - tab_size, - }), - BlockCommentConfigHelper::Old([start, end]) => Ok(BlockCommentConfig { - start, - end, - prefix: "".into(), - tab_size: 0, - }), - } - } -} - /// Represents a language for the given range. Some languages (e.g. HTML) /// interleave several languages together, thus a single buffer might actually contain /// several nested scopes. @@ -1107,148 +803,6 @@ pub struct LanguageScope { override_id: Option, } -#[derive(Clone, Deserialize, Default, Debug, JsonSchema)] -pub struct LanguageConfigOverride { - #[serde(default)] - pub line_comments: Override>>, - #[serde(default)] - pub block_comment: Override, - #[serde(skip)] - pub disabled_bracket_ixs: Vec, - #[serde(default)] - pub word_characters: Override>, - #[serde(default)] - pub completion_query_characters: Override>, - #[serde(default)] - pub linked_edit_characters: Override>, - #[serde(default)] - pub opt_into_language_servers: Vec, - #[serde(default)] - pub prefer_label_for_snippet: Option, -} - -#[derive(Clone, Deserialize, Debug, Serialize, JsonSchema)] -#[serde(untagged)] -pub enum Override { - Remove { remove: bool }, - Set(T), -} - -impl Default for Override { - fn default() -> Self { - Override::Remove { remove: false } - } -} - -impl Override { - fn as_option<'a>(this: Option<&'a Self>, original: Option<&'a T>) -> Option<&'a T> { - match this { - Some(Self::Set(value)) => Some(value), - Some(Self::Remove { remove: true }) => None, - Some(Self::Remove { remove: false }) | None => original, - } - } -} - -impl Default for LanguageConfig { - fn default() -> Self { - Self { - name: LanguageName::new_static(""), - code_fence_block_name: None, - kernel_language_names: Default::default(), - grammar: None, - matcher: LanguageMatcher::default(), - brackets: Default::default(), - auto_indent_using_last_non_empty_line: auto_indent_using_last_non_empty_line_default(), - auto_indent_on_paste: None, - increase_indent_pattern: Default::default(), - decrease_indent_pattern: Default::default(), - decrease_indent_patterns: Default::default(), - autoclose_before: Default::default(), - line_comments: Default::default(), - block_comment: Default::default(), - documentation_comment: Default::default(), - unordered_list: Default::default(), - ordered_list: Default::default(), - task_list: Default::default(), - rewrap_prefixes: Default::default(), - scope_opt_in_language_servers: Default::default(), - overrides: Default::default(), - word_characters: Default::default(), - collapsed_placeholder: Default::default(), - hard_tabs: None, - tab_size: None, - soft_wrap: None, - wrap_characters: None, - prettier_parser_name: None, - hidden: false, - jsx_tag_auto_close: None, - completion_query_characters: Default::default(), - linked_edit_characters: Default::default(), - debuggers: Default::default(), - ignored_import_segments: Default::default(), - import_path_strip_regex: None, - } - } -} - -#[derive(Clone, Debug, Deserialize, JsonSchema)] -pub struct WrapCharactersConfig { - /// Opening token split into a prefix and suffix. The first caret goes - /// after the prefix (i.e., between prefix and suffix). - pub start_prefix: String, - pub start_suffix: String, - /// Closing token split into a prefix and suffix. The second caret goes - /// after the prefix (i.e., between prefix and suffix). - pub end_prefix: String, - pub end_suffix: String, -} - -fn auto_indent_using_last_non_empty_line_default() -> bool { - true -} - -fn deserialize_regex<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { - let source = Option::::deserialize(d)?; - if let Some(source) = source { - Ok(Some(regex::Regex::new(&source).map_err(de::Error::custom)?)) - } else { - Ok(None) - } -} - -fn regex_json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { - json_schema!({ - "type": "string" - }) -} - -fn serialize_regex(regex: &Option, serializer: S) -> Result -where - S: Serializer, -{ - match regex { - Some(regex) => serializer.serialize_str(regex.as_str()), - None => serializer.serialize_none(), - } -} - -fn deserialize_regex_vec<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { - let sources = Vec::::deserialize(d)?; - sources - .into_iter() - .map(|source| regex::Regex::new(&source)) - .collect::>() - .map_err(de::Error::custom) -} - -fn regex_vec_json_schema(_: &mut SchemaGenerator) -> schemars::Schema { - json_schema!({ - "type": "array", - "items": { "type": "string" } - }) -} - #[doc(hidden)] #[cfg(any(test, feature = "test-support"))] pub struct FakeLspAdapter { @@ -1271,79 +825,6 @@ pub struct FakeLspAdapter { >, } -/// Configuration of handling bracket pairs for a given language. -/// -/// This struct includes settings for defining which pairs of characters are considered brackets and -/// also specifies any language-specific scopes where these pairs should be ignored for bracket matching purposes. -#[derive(Clone, Debug, Default, JsonSchema)] -#[schemars(with = "Vec::")] -pub struct BracketPairConfig { - /// A list of character pairs that should be treated as brackets in the context of a given language. - pub pairs: Vec, - /// A list of tree-sitter scopes for which a given bracket should not be active. - /// N-th entry in `[Self::disabled_scopes_by_bracket_ix]` contains a list of disabled scopes for an n-th entry in `[Self::pairs]` - pub disabled_scopes_by_bracket_ix: Vec>, -} - -impl BracketPairConfig { - pub fn is_closing_brace(&self, c: char) -> bool { - self.pairs.iter().any(|pair| pair.end.starts_with(c)) - } -} - -#[derive(Deserialize, JsonSchema)] -pub struct BracketPairContent { - #[serde(flatten)] - pub bracket_pair: BracketPair, - #[serde(default)] - pub not_in: Vec, -} - -impl<'de> Deserialize<'de> for BracketPairConfig { - fn deserialize(deserializer: D) -> std::result::Result - where - D: Deserializer<'de>, - { - let result = Vec::::deserialize(deserializer)?; - let (brackets, disabled_scopes_by_bracket_ix) = result - .into_iter() - .map(|entry| (entry.bracket_pair, entry.not_in)) - .unzip(); - - Ok(BracketPairConfig { - pairs: brackets, - disabled_scopes_by_bracket_ix, - }) - } -} - -/// Describes a single bracket pair and how an editor should react to e.g. inserting -/// an opening bracket or to a newline character insertion in between `start` and `end` characters. -#[derive(Clone, Debug, Default, Deserialize, PartialEq, JsonSchema)] -pub struct BracketPair { - /// Starting substring for a bracket. - pub start: String, - /// Ending substring for a bracket. - pub end: String, - /// True if `end` should be automatically inserted right after `start` characters. - pub close: bool, - /// True if selected text should be surrounded by `start` and `end` characters. - #[serde(default = "default_true")] - pub surround: bool, - /// True if an extra newline should be inserted while the cursor is in the middle - /// of that bracket pair. - pub newline: bool, -} - -#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] -pub struct LanguageId(usize); - -impl LanguageId { - pub(crate) fn new() -> Self { - Self(NEXT_LANGUAGE_ID.fetch_add(1, SeqCst)) - } -} - pub struct Language { pub(crate) id: LanguageId, pub(crate) config: LanguageConfig, @@ -1353,184 +834,6 @@ pub struct Language { pub(crate) manifest_name: Option, } -#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] -pub struct GrammarId(pub usize); - -impl GrammarId { - pub(crate) fn new() -> Self { - Self(NEXT_GRAMMAR_ID.fetch_add(1, SeqCst)) - } -} - -pub struct Grammar { - id: GrammarId, - pub ts_language: tree_sitter::Language, - pub(crate) error_query: Option, - pub highlights_config: Option, - pub(crate) brackets_config: Option, - pub(crate) redactions_config: Option, - pub(crate) runnable_config: Option, - pub(crate) indents_config: Option, - pub outline_config: Option, - pub text_object_config: Option, - pub(crate) injection_config: Option, - pub(crate) override_config: Option, - pub(crate) debug_variables_config: Option, - pub(crate) imports_config: Option, - pub(crate) highlight_map: Mutex, -} - -pub struct HighlightsConfig { - pub query: Query, - pub identifier_capture_indices: Vec, -} - -struct IndentConfig { - query: Query, - indent_capture_ix: u32, - start_capture_ix: Option, - end_capture_ix: Option, - outdent_capture_ix: Option, - suffixed_start_captures: HashMap, -} - -pub struct OutlineConfig { - pub query: Query, - pub item_capture_ix: u32, - pub name_capture_ix: u32, - pub context_capture_ix: Option, - pub extra_context_capture_ix: Option, - pub open_capture_ix: Option, - pub close_capture_ix: Option, - pub annotation_capture_ix: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum DebuggerTextObject { - Variable, - Scope, -} - -impl DebuggerTextObject { - pub fn from_capture_name(name: &str) -> Option { - match name { - "debug-variable" => Some(DebuggerTextObject::Variable), - "debug-scope" => Some(DebuggerTextObject::Scope), - _ => None, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum TextObject { - InsideFunction, - AroundFunction, - InsideClass, - AroundClass, - InsideComment, - AroundComment, -} - -impl TextObject { - pub fn from_capture_name(name: &str) -> Option { - match name { - "function.inside" => Some(TextObject::InsideFunction), - "function.around" => Some(TextObject::AroundFunction), - "class.inside" => Some(TextObject::InsideClass), - "class.around" => Some(TextObject::AroundClass), - "comment.inside" => Some(TextObject::InsideComment), - "comment.around" => Some(TextObject::AroundComment), - _ => None, - } - } - - pub fn around(&self) -> Option { - match self { - TextObject::InsideFunction => Some(TextObject::AroundFunction), - TextObject::InsideClass => Some(TextObject::AroundClass), - TextObject::InsideComment => Some(TextObject::AroundComment), - _ => None, - } - } -} - -pub struct TextObjectConfig { - pub query: Query, - pub text_objects_by_capture_ix: Vec<(u32, TextObject)>, -} - -struct InjectionConfig { - query: Query, - content_capture_ix: u32, - language_capture_ix: Option, - patterns: Vec, -} - -struct RedactionConfig { - pub query: Query, - pub redaction_capture_ix: u32, -} - -#[derive(Clone, Debug, PartialEq)] -enum RunnableCapture { - Named(SharedString), - Run, -} - -struct RunnableConfig { - pub query: Query, - /// A mapping from capture indice to capture kind - pub extra_captures: Vec, -} - -struct OverrideConfig { - query: Query, - values: HashMap, -} - -#[derive(Debug)] -struct OverrideEntry { - name: String, - range_is_inclusive: bool, - value: LanguageConfigOverride, -} - -#[derive(Default, Clone)] -struct InjectionPatternConfig { - language: Option>, - combined: bool, -} - -#[derive(Debug)] -struct BracketsConfig { - query: Query, - open_capture_ix: u32, - close_capture_ix: u32, - patterns: Vec, -} - -#[derive(Clone, Debug, Default)] -struct BracketsPatternConfig { - newline_only: bool, - rainbow_exclude: bool, -} - -pub struct DebugVariablesConfig { - pub query: Query, - pub objects_by_capture_ix: Vec<(u32, DebuggerTextObject)>, -} - -pub struct ImportsConfig { - pub query: Query, - pub import_ix: u32, - pub name_ix: Option, - pub namespace_ix: Option, - pub source_ix: Option, - pub list_ix: Option, - pub wildcard_ix: Option, - pub alias_ix: Option, -} - impl Language { pub fn new(config: LanguageConfig, ts_language: Option) -> Self { Self::new_with_id(LanguageId::new(), config, ts_language) @@ -1548,25 +851,7 @@ impl Language { Self { id, config, - grammar: ts_language.map(|ts_language| { - Arc::new(Grammar { - id: GrammarId::new(), - highlights_config: None, - brackets_config: None, - outline_config: None, - text_object_config: None, - indents_config: None, - injection_config: None, - override_config: None, - redactions_config: None, - runnable_config: None, - error_query: Query::new(&ts_language, "(ERROR) @error").ok(), - debug_variables_config: None, - imports_config: None, - ts_language, - highlight_map: Default::default(), - }) - }), + grammar: ts_language.map(|ts_language| Arc::new(Grammar::new(ts_language))), context_provider: None, toolchain: None, manifest_name: None, @@ -1589,493 +874,95 @@ impl Language { } pub fn with_queries(mut self, queries: LanguageQueries) -> Result { - if let Some(query) = queries.highlights { - self = self - .with_highlights_query(query.as_ref()) - .context("Error loading highlights query")?; - } - if let Some(query) = queries.brackets { - self = self - .with_brackets_query(query.as_ref()) - .context("Error loading brackets query")?; - } - if let Some(query) = queries.indents { - self = self - .with_indents_query(query.as_ref()) - .context("Error loading indents query")?; - } - if let Some(query) = queries.outline { - self = self - .with_outline_query(query.as_ref()) - .context("Error loading outline query")?; - } - if let Some(query) = queries.injections { - self = self - .with_injection_query(query.as_ref()) - .context("Error loading injection query")?; - } - if let Some(query) = queries.overrides { - self = self - .with_override_query(query.as_ref()) - .context("Error loading override query")?; - } - if let Some(query) = queries.redactions { - self = self - .with_redaction_query(query.as_ref()) - .context("Error loading redaction query")?; - } - if let Some(query) = queries.runnables { - self = self - .with_runnable_query(query.as_ref()) - .context("Error loading runnables query")?; - } - if let Some(query) = queries.text_objects { - self = self - .with_text_object_query(query.as_ref()) - .context("Error loading textobject query")?; - } - if let Some(query) = queries.debugger { - self = self - .with_debug_variables_query(query.as_ref()) - .context("Error loading debug variables query")?; - } - if let Some(query) = queries.imports { - self = self - .with_imports_query(query.as_ref()) - .context("Error loading imports query")?; + if let Some(grammar) = self.grammar.take() { + let grammar = + Arc::try_unwrap(grammar).map_err(|_| anyhow::anyhow!("cannot mutate grammar"))?; + let grammar = grammar.with_queries(queries, &mut self.config)?; + self.grammar = Some(Arc::new(grammar)); } Ok(self) } - pub fn with_highlights_query(mut self, source: &str) -> Result { - let grammar = self.grammar_mut()?; - let query = Query::new(&grammar.ts_language, source)?; - - let mut identifier_capture_indices = Vec::new(); - for name in [ - "variable", - "constant", - "constructor", - "function", - "function.method", - "function.method.call", - "function.special", - "property", - "type", - "type.interface", - ] { - identifier_capture_indices.extend(query.capture_index_for_name(name)); - } - - grammar.highlights_config = Some(HighlightsConfig { - query, - identifier_capture_indices, - }); - - Ok(self) + pub fn with_highlights_query(self, source: &str) -> Result { + self.with_grammar_query(|grammar| grammar.with_highlights_query(source)) } - pub fn with_runnable_query(mut self, source: &str) -> Result { - let grammar = self.grammar_mut()?; - - let query = Query::new(&grammar.ts_language, source)?; - let extra_captures: Vec<_> = query - .capture_names() - .iter() - .map(|&name| match name { - "run" => RunnableCapture::Run, - name => RunnableCapture::Named(name.to_string().into()), - }) - .collect(); - - grammar.runnable_config = Some(RunnableConfig { - extra_captures, - query, - }); - - Ok(self) + pub fn with_runnable_query(self, source: &str) -> Result { + self.with_grammar_query(|grammar| grammar.with_runnable_query(source)) } - pub fn with_outline_query(mut self, source: &str) -> Result { - let query = Query::new(&self.expect_grammar()?.ts_language, source)?; - let mut item_capture_ix = 0; - let mut name_capture_ix = 0; - let mut context_capture_ix = None; - let mut extra_context_capture_ix = None; - let mut open_capture_ix = None; - let mut close_capture_ix = None; - let mut annotation_capture_ix = None; - if populate_capture_indices( - &query, - &self.config.name, - "outline", - &[], - &mut [ - Capture::Required("item", &mut item_capture_ix), - Capture::Required("name", &mut name_capture_ix), - Capture::Optional("context", &mut context_capture_ix), - Capture::Optional("context.extra", &mut extra_context_capture_ix), - Capture::Optional("open", &mut open_capture_ix), - Capture::Optional("close", &mut close_capture_ix), - Capture::Optional("annotation", &mut annotation_capture_ix), - ], - ) { - self.grammar_mut()?.outline_config = Some(OutlineConfig { - query, - item_capture_ix, - name_capture_ix, - context_capture_ix, - extra_context_capture_ix, - open_capture_ix, - close_capture_ix, - annotation_capture_ix, - }); - } - Ok(self) + pub fn with_outline_query(self, source: &str) -> Result { + self.with_grammar_query_and_name(|grammar, name| grammar.with_outline_query(source, name)) } - pub fn with_text_object_query(mut self, source: &str) -> Result { - let query = Query::new(&self.expect_grammar()?.ts_language, source)?; - - let mut text_objects_by_capture_ix = Vec::new(); - for (ix, name) in query.capture_names().iter().enumerate() { - if let Some(text_object) = TextObject::from_capture_name(name) { - text_objects_by_capture_ix.push((ix as u32, text_object)); - } else { - log::warn!( - "unrecognized capture name '{}' in {} textobjects TreeSitter query", - name, - self.config.name, - ); - } - } - - self.grammar_mut()?.text_object_config = Some(TextObjectConfig { - query, - text_objects_by_capture_ix, - }); - Ok(self) + pub fn with_text_object_query(self, source: &str) -> Result { + self.with_grammar_query_and_name(|grammar, name| { + grammar.with_text_object_query(source, name) + }) } - pub fn with_debug_variables_query(mut self, source: &str) -> Result { - let query = Query::new(&self.expect_grammar()?.ts_language, source)?; - - let mut objects_by_capture_ix = Vec::new(); - for (ix, name) in query.capture_names().iter().enumerate() { - if let Some(text_object) = DebuggerTextObject::from_capture_name(name) { - objects_by_capture_ix.push((ix as u32, text_object)); - } else { - log::warn!( - "unrecognized capture name '{}' in {} debugger TreeSitter query", - name, - self.config.name, - ); - } - } - - self.grammar_mut()?.debug_variables_config = Some(DebugVariablesConfig { - query, - objects_by_capture_ix, - }); - Ok(self) + pub fn with_debug_variables_query(self, source: &str) -> Result { + self.with_grammar_query_and_name(|grammar, name| { + grammar.with_debug_variables_query(source, name) + }) } - pub fn with_imports_query(mut self, source: &str) -> Result { - let query = Query::new(&self.expect_grammar()?.ts_language, source)?; - - let mut import_ix = 0; - let mut name_ix = None; - let mut namespace_ix = None; - let mut source_ix = None; - let mut list_ix = None; - let mut wildcard_ix = None; - let mut alias_ix = None; - if populate_capture_indices( - &query, - &self.config.name, - "imports", - &[], - &mut [ - Capture::Required("import", &mut import_ix), - Capture::Optional("name", &mut name_ix), - Capture::Optional("namespace", &mut namespace_ix), - Capture::Optional("source", &mut source_ix), - Capture::Optional("list", &mut list_ix), - Capture::Optional("wildcard", &mut wildcard_ix), - Capture::Optional("alias", &mut alias_ix), - ], - ) { - self.grammar_mut()?.imports_config = Some(ImportsConfig { - query, - import_ix, - name_ix, - namespace_ix, - source_ix, - list_ix, - wildcard_ix, - alias_ix, - }); - } - return Ok(self); - } - - pub fn with_brackets_query(mut self, source: &str) -> Result { - let query = Query::new(&self.expect_grammar()?.ts_language, source)?; - let mut open_capture_ix = 0; - let mut close_capture_ix = 0; - if populate_capture_indices( - &query, - &self.config.name, - "brackets", - &[], - &mut [ - Capture::Required("open", &mut open_capture_ix), - Capture::Required("close", &mut close_capture_ix), - ], - ) { - let patterns = (0..query.pattern_count()) - .map(|ix| { - let mut config = BracketsPatternConfig::default(); - for setting in query.property_settings(ix) { - let setting_key = setting.key.as_ref(); - if setting_key == "newline.only" { - config.newline_only = true - } - if setting_key == "rainbow.exclude" { - config.rainbow_exclude = true - } - } - config - }) - .collect(); - self.grammar_mut()?.brackets_config = Some(BracketsConfig { - query, - open_capture_ix, - close_capture_ix, - patterns, - }); - } - Ok(self) + pub fn with_brackets_query(self, source: &str) -> Result { + self.with_grammar_query_and_name(|grammar, name| grammar.with_brackets_query(source, name)) } - pub fn with_indents_query(mut self, source: &str) -> Result { - let query = Query::new(&self.expect_grammar()?.ts_language, source)?; - let mut indent_capture_ix = 0; - let mut start_capture_ix = None; - let mut end_capture_ix = None; - let mut outdent_capture_ix = None; - if populate_capture_indices( - &query, - &self.config.name, - "indents", - &["start."], - &mut [ - Capture::Required("indent", &mut indent_capture_ix), - Capture::Optional("start", &mut start_capture_ix), - Capture::Optional("end", &mut end_capture_ix), - Capture::Optional("outdent", &mut outdent_capture_ix), - ], - ) { - let mut suffixed_start_captures = HashMap::default(); - for (ix, name) in query.capture_names().iter().enumerate() { - if let Some(suffix) = name.strip_prefix("start.") { - suffixed_start_captures.insert(ix as u32, suffix.to_owned().into()); - } - } + pub fn with_indents_query(self, source: &str) -> Result { + self.with_grammar_query_and_name(|grammar, name| grammar.with_indents_query(source, name)) + } - self.grammar_mut()?.indents_config = Some(IndentConfig { - query, - indent_capture_ix, - start_capture_ix, - end_capture_ix, - outdent_capture_ix, - suffixed_start_captures, - }); - } - Ok(self) + pub fn with_injection_query(self, source: &str) -> Result { + self.with_grammar_query_and_name(|grammar, name| grammar.with_injection_query(source, name)) } - pub fn with_injection_query(mut self, source: &str) -> Result { - let query = Query::new(&self.expect_grammar()?.ts_language, source)?; - let mut language_capture_ix = None; - let mut injection_language_capture_ix = None; - let mut content_capture_ix = None; - let mut injection_content_capture_ix = None; - if populate_capture_indices( - &query, - &self.config.name, - "injections", - &[], - &mut [ - Capture::Optional("language", &mut language_capture_ix), - Capture::Optional("injection.language", &mut injection_language_capture_ix), - Capture::Optional("content", &mut content_capture_ix), - Capture::Optional("injection.content", &mut injection_content_capture_ix), - ], - ) { - language_capture_ix = match (language_capture_ix, injection_language_capture_ix) { - (None, Some(ix)) => Some(ix), - (Some(_), Some(_)) => { - anyhow::bail!("both language and injection.language captures are present"); - } - _ => language_capture_ix, - }; - content_capture_ix = match (content_capture_ix, injection_content_capture_ix) { - (None, Some(ix)) => Some(ix), - (Some(_), Some(_)) => { - anyhow::bail!("both content and injection.content captures are present") - } - _ => content_capture_ix, - }; - let patterns = (0..query.pattern_count()) - .map(|ix| { - let mut config = InjectionPatternConfig::default(); - for setting in query.property_settings(ix) { - match setting.key.as_ref() { - "language" | "injection.language" => { - config.language.clone_from(&setting.value); - } - "combined" | "injection.combined" => { - config.combined = true; - } - _ => {} - } - } - config - }) - .collect(); - if let Some(content_capture_ix) = content_capture_ix { - self.grammar_mut()?.injection_config = Some(InjectionConfig { - query, - language_capture_ix, - content_capture_ix, - patterns, - }); - } else { - log::error!( - "missing required capture in injections {} TreeSitter query: \ - content or injection.content", - &self.config.name, - ); - } + pub fn with_override_query(mut self, source: &str) -> Result { + if let Some(grammar_arc) = self.grammar.take() { + let grammar = Arc::try_unwrap(grammar_arc) + .map_err(|_| anyhow::anyhow!("cannot mutate grammar"))?; + let grammar = grammar.with_override_query( + source, + &self.config.name, + &self.config.overrides, + &mut self.config.brackets, + &self.config.scope_opt_in_language_servers, + )?; + self.grammar = Some(Arc::new(grammar)); } Ok(self) } - pub fn with_override_query(mut self, source: &str) -> anyhow::Result { - let query = Query::new(&self.expect_grammar()?.ts_language, source)?; - - let mut override_configs_by_id = HashMap::default(); - for (ix, mut name) in query.capture_names().iter().copied().enumerate() { - let mut range_is_inclusive = false; - if name.starts_with('_') { - continue; - } - if let Some(prefix) = name.strip_suffix(".inclusive") { - name = prefix; - range_is_inclusive = true; - } - - let value = self.config.overrides.get(name).cloned().unwrap_or_default(); - for server_name in &value.opt_into_language_servers { - if !self - .config - .scope_opt_in_language_servers - .contains(server_name) - { - util::debug_panic!( - "Server {server_name:?} has been opted-in by scope {name:?} but has not been marked as an opt-in server" - ); - } - } - - override_configs_by_id.insert( - ix as u32, - OverrideEntry { - name: name.to_string(), - range_is_inclusive, - value, - }, - ); - } - - let referenced_override_names = self.config.overrides.keys().chain( - self.config - .brackets - .disabled_scopes_by_bracket_ix - .iter() - .flatten(), - ); - - for referenced_name in referenced_override_names { - if !override_configs_by_id - .values() - .any(|entry| entry.name == *referenced_name) - { - anyhow::bail!( - "language {:?} has overrides in config not in query: {referenced_name:?}", - self.config.name - ); - } - } + pub fn with_redaction_query(self, source: &str) -> Result { + self.with_grammar_query_and_name(|grammar, name| grammar.with_redaction_query(source, name)) + } - for entry in override_configs_by_id.values_mut() { - entry.value.disabled_bracket_ixs = self - .config - .brackets - .disabled_scopes_by_bracket_ix - .iter() - .enumerate() - .filter_map(|(ix, disabled_scope_names)| { - if disabled_scope_names.contains(&entry.name) { - Some(ix as u16) - } else { - None - } - }) - .collect(); + fn with_grammar_query( + mut self, + build: impl FnOnce(Grammar) -> Result, + ) -> Result { + if let Some(grammar_arc) = self.grammar.take() { + let grammar = Arc::try_unwrap(grammar_arc) + .map_err(|_| anyhow::anyhow!("cannot mutate grammar"))?; + self.grammar = Some(Arc::new(build(grammar)?)); } - - self.config.brackets.disabled_scopes_by_bracket_ix.clear(); - - let grammar = self.grammar_mut()?; - grammar.override_config = Some(OverrideConfig { - query, - values: override_configs_by_id, - }); Ok(self) } - pub fn with_redaction_query(mut self, source: &str) -> anyhow::Result { - let query = Query::new(&self.expect_grammar()?.ts_language, source)?; - let mut redaction_capture_ix = 0; - if populate_capture_indices( - &query, - &self.config.name, - "redactions", - &[], - &mut [Capture::Required("redact", &mut redaction_capture_ix)], - ) { - self.grammar_mut()?.redactions_config = Some(RedactionConfig { - query, - redaction_capture_ix, - }); + fn with_grammar_query_and_name( + mut self, + build: impl FnOnce(Grammar, &LanguageName) -> Result, + ) -> Result { + if let Some(grammar_arc) = self.grammar.take() { + let grammar = Arc::try_unwrap(grammar_arc) + .map_err(|_| anyhow::anyhow!("cannot mutate grammar"))?; + self.grammar = Some(Arc::new(build(grammar, &self.config.name)?)); } Ok(self) } - fn expect_grammar(&self) -> Result<&Grammar> { - self.grammar - .as_ref() - .map(|grammar| grammar.as_ref()) - .context("no grammar for language") - } - - fn grammar_mut(&mut self) -> Result<&mut Grammar> { - Arc::get_mut(self.grammar.as_mut().context("no grammar for language")?) - .context("cannot mutate grammar") - } - pub fn name(&self) -> LanguageName { self.config.name.clone() } @@ -2122,7 +1009,7 @@ impl Language { ) -> Vec<(Range, HighlightId)> { let mut result = Vec::new(); if let Some(grammar) = &self.grammar { - let tree = grammar.parse_text(text, None); + let tree = parse_text(grammar, text, None); let captures = SyntaxSnapshot::single_tree_captures(range.clone(), text, &tree, self, |grammar| { grammar @@ -2160,7 +1047,7 @@ impl Language { && let Some(highlights_config) = &grammar.highlights_config { *grammar.highlight_map.lock() = - HighlightMap::new(highlights_config.query.capture_names(), theme); + build_highlight_map(highlights_config.query.capture_names(), theme); } } @@ -2188,6 +1075,15 @@ impl Language { } } +#[inline] +pub fn build_highlight_map(capture_names: &[&str], theme: &SyntaxTheme) -> HighlightMap { + HighlightMap::from_ids(capture_names.iter().map(|capture_name| { + theme + .highlight_id(capture_name) + .map_or(HighlightId::default(), HighlightId) + })) +} + impl LanguageScope { pub fn path_suffixes(&self) -> &[String] { self.language.path_suffixes() @@ -2369,85 +1265,37 @@ impl Debug for Language { } } -impl Grammar { - pub fn id(&self) -> GrammarId { - self.id - } - - fn parse_text(&self, text: &Rope, old_tree: Option) -> Tree { - with_parser(|parser| { - parser - .set_language(&self.ts_language) - .expect("incompatible grammar"); - let mut chunks = text.chunks_in_range(0..text.len()); - parser - .parse_with_options( - &mut move |offset, _| { - chunks.seek(offset); - chunks.next().unwrap_or("").as_bytes() - }, - old_tree.as_ref(), - None, - ) - .unwrap() - }) - } - - pub fn highlight_map(&self) -> HighlightMap { - self.highlight_map.lock().clone() - } - - pub fn highlight_id_for_name(&self, name: &str) -> Option { - let capture_id = self - .highlights_config - .as_ref()? - .query - .capture_index_for_name(name)?; - Some(self.highlight_map.lock().get(capture_id)) - } - - pub fn debug_variables_config(&self) -> Option<&DebugVariablesConfig> { - self.debug_variables_config.as_ref() - } - - pub fn imports_config(&self) -> Option<&ImportsConfig> { - self.imports_config.as_ref() - } +pub(crate) fn parse_text(grammar: &Grammar, text: &Rope, old_tree: Option) -> Tree { + with_parser(|parser| { + parser + .set_language(&grammar.ts_language) + .expect("incompatible grammar"); + let mut chunks = text.chunks_in_range(0..text.len()); + parser + .parse_with_options( + &mut move |offset, _| { + chunks.seek(offset); + chunks.next().unwrap_or("").as_bytes() + }, + old_tree.as_ref(), + None, + ) + .unwrap() + }) } -impl CodeLabelBuilder { - pub fn respan_filter_range(&mut self, filter_text: Option<&str>) { - self.filter_range = filter_text - .and_then(|filter| self.text.find(filter).map(|ix| ix..ix + filter.len())) - .unwrap_or(0..self.text.len()); - } - - pub fn push_str(&mut self, text: &str, highlight: Option) { - let start_ix = self.text.len(); - self.text.push_str(text); - if let Some(highlight) = highlight { - let end_ix = self.text.len(); - self.runs.push((start_ix..end_ix, highlight)); - } - } - - pub fn build(mut self) -> CodeLabel { - if self.filter_range.end == 0 { - self.respan_filter_range(None); - } - CodeLabel { - text: self.text, - runs: self.runs, - filter_range: self.filter_range, - } - } +pub trait CodeLabelExt { + fn fallback_for_completion( + item: &lsp::CompletionItem, + language: Option<&Language>, + ) -> CodeLabel; } -impl CodeLabel { - pub fn fallback_for_completion( +impl CodeLabelExt for CodeLabel { + fn fallback_for_completion( item: &lsp::CompletionItem, language: Option<&Language>, - ) -> Self { + ) -> CodeLabel { let highlight_id = item.kind.and_then(|kind| { let grammar = language?.grammar()?; use lsp::CompletionItemKind as Kind; @@ -2498,98 +1346,12 @@ impl CodeLabel { .as_deref() .and_then(|filter| text.find(filter).map(|ix| ix..ix + filter.len())) .unwrap_or(0..label_length); - Self { + CodeLabel { text, runs, filter_range, } } - - pub fn plain(text: String, filter_text: Option<&str>) -> Self { - Self::filtered(text.clone(), text.len(), filter_text, Vec::new()) - } - - pub fn filtered( - text: String, - label_len: usize, - filter_text: Option<&str>, - runs: Vec<(Range, HighlightId)>, - ) -> Self { - assert!(label_len <= text.len()); - let filter_range = filter_text - .and_then(|filter| text.find(filter).map(|ix| ix..ix + filter.len())) - .unwrap_or(0..label_len); - Self::new(text, filter_range, runs) - } - - pub fn new( - text: String, - filter_range: Range, - runs: Vec<(Range, HighlightId)>, - ) -> Self { - assert!( - text.get(filter_range.clone()).is_some(), - "invalid filter range" - ); - runs.iter().for_each(|(range, _)| { - assert!( - text.get(range.clone()).is_some(), - "invalid run range with inputs. Requested range {range:?} in text '{text}'", - ); - }); - Self { - runs, - filter_range, - text, - } - } - - pub fn text(&self) -> &str { - self.text.as_str() - } - - pub fn filter_text(&self) -> &str { - &self.text[self.filter_range.clone()] - } -} - -impl From for CodeLabel { - fn from(value: String) -> Self { - Self::plain(value, None) - } -} - -impl From<&str> for CodeLabel { - fn from(value: &str) -> Self { - Self::plain(value.to_string(), None) - } -} - -impl Ord for LanguageMatcher { - fn cmp(&self, other: &Self) -> std::cmp::Ordering { - self.path_suffixes.cmp(&other.path_suffixes).then_with(|| { - self.first_line_pattern - .as_ref() - .map(Regex::as_str) - .cmp(&other.first_line_pattern.as_ref().map(Regex::as_str)) - }) - } -} - -impl PartialOrd for LanguageMatcher { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -impl Eq for LanguageMatcher {} - -impl PartialEq for LanguageMatcher { - fn eq(&self, other: &Self) -> bool { - self.path_suffixes == other.path_suffixes - && self.first_line_pattern.as_ref().map(Regex::as_str) - == other.first_line_pattern.as_ref().map(Regex::as_str) - } } #[cfg(any(test, feature = "test-support"))] @@ -2690,68 +1452,6 @@ impl LspAdapter for FakeLspAdapter { } } -enum Capture<'a> { - Required(&'static str, &'a mut u32), - Optional(&'static str, &'a mut Option), -} - -fn populate_capture_indices( - query: &Query, - language_name: &LanguageName, - query_type: &str, - expected_prefixes: &[&str], - captures: &mut [Capture<'_>], -) -> bool { - let mut found_required_indices = Vec::new(); - 'outer: for (ix, name) in query.capture_names().iter().enumerate() { - for (required_ix, capture) in captures.iter_mut().enumerate() { - match capture { - Capture::Required(capture_name, index) if capture_name == name => { - **index = ix as u32; - found_required_indices.push(required_ix); - continue 'outer; - } - Capture::Optional(capture_name, index) if capture_name == name => { - **index = Some(ix as u32); - continue 'outer; - } - _ => {} - } - } - if !name.starts_with("_") - && !expected_prefixes - .iter() - .any(|&prefix| name.starts_with(prefix)) - { - log::warn!( - "unrecognized capture name '{}' in {} {} TreeSitter query \ - (suppress this warning by prefixing with '_')", - name, - language_name, - query_type - ); - } - } - let mut missing_required_captures = Vec::new(); - for (capture_ix, capture) in captures.iter().enumerate() { - if let Capture::Required(capture_name, _) = capture - && !found_required_indices.contains(&capture_ix) - { - missing_required_captures.push(*capture_name); - } - } - let success = missing_required_captures.is_empty(); - if !success { - log::error!( - "missing required capture(s) in {} {} TreeSitter query: {}", - language_name, - query_type, - missing_required_captures.join(", ") - ); - } - success -} - pub fn point_to_lsp(point: PointUtf16) -> lsp::Position { lsp::Position::new(point.row, point.column) } @@ -2801,41 +1501,78 @@ pub fn rust_lang() -> Arc { ..Default::default() }, line_comments: vec!["// ".into(), "/// ".into(), "//! ".into()], + brackets: BracketPairConfig { + pairs: vec![ + BracketPair { + start: "{".into(), + end: "}".into(), + close: true, + surround: false, + newline: true, + }, + BracketPair { + start: "[".into(), + end: "]".into(), + close: true, + surround: false, + newline: true, + }, + BracketPair { + start: "(".into(), + end: ")".into(), + close: true, + surround: false, + newline: true, + }, + BracketPair { + start: "<".into(), + end: ">".into(), + close: false, + surround: false, + newline: true, + }, + BracketPair { + start: "\"".into(), + end: "\"".into(), + close: true, + surround: false, + newline: false, + }, + ], + ..Default::default() + }, ..Default::default() }, Some(tree_sitter_rust::LANGUAGE.into()), ) .with_queries(LanguageQueries { outline: Some(Cow::from(include_str!( - "../../languages/src/rust/outline.scm" + "../../grammars/src/rust/outline.scm" ))), indents: Some(Cow::from(include_str!( - "../../languages/src/rust/indents.scm" + "../../grammars/src/rust/indents.scm" ))), brackets: Some(Cow::from(include_str!( - "../../languages/src/rust/brackets.scm" + "../../grammars/src/rust/brackets.scm" ))), text_objects: Some(Cow::from(include_str!( - "../../languages/src/rust/textobjects.scm" + "../../grammars/src/rust/textobjects.scm" ))), highlights: Some(Cow::from(include_str!( - "../../languages/src/rust/highlights.scm" + "../../grammars/src/rust/highlights.scm" ))), injections: Some(Cow::from(include_str!( - "../../languages/src/rust/injections.scm" + "../../grammars/src/rust/injections.scm" ))), overrides: Some(Cow::from(include_str!( - "../../languages/src/rust/overrides.scm" + "../../grammars/src/rust/overrides.scm" ))), redactions: None, runnables: Some(Cow::from(include_str!( - "../../languages/src/rust/runnables.scm" + "../../grammars/src/rust/runnables.scm" ))), debugger: Some(Cow::from(include_str!( - "../../languages/src/rust/debugger.scm" - ))), - imports: Some(Cow::from(include_str!( - "../../languages/src/rust/imports.scm" + "../../grammars/src/rust/debugger.scm" ))), }) .expect("Could not parse queries"); @@ -2860,19 +1597,19 @@ pub fn markdown_lang() -> Arc { ) .with_queries(LanguageQueries { brackets: Some(Cow::from(include_str!( - "../../languages/src/markdown/brackets.scm" + "../../grammars/src/markdown/brackets.scm" ))), injections: Some(Cow::from(include_str!( - "../../languages/src/markdown/injections.scm" + "../../grammars/src/markdown/injections.scm" ))), highlights: Some(Cow::from(include_str!( - "../../languages/src/markdown/highlights.scm" + "../../grammars/src/markdown/highlights.scm" ))), indents: Some(Cow::from(include_str!( - "../../languages/src/markdown/indents.scm" + "../../grammars/src/markdown/indents.scm" ))), outline: Some(Cow::from(include_str!( - "../../languages/src/markdown/outline.scm" + "../../grammars/src/markdown/outline.scm" ))), ..LanguageQueries::default() }) @@ -2883,10 +1620,38 @@ pub fn markdown_lang() -> Arc { #[cfg(test)] mod tests { use super::*; - use gpui::TestAppContext; + use gpui::{TestAppContext, rgba}; use pretty_assertions::assert_matches; + #[test] + fn test_highlight_map() { + let theme = SyntaxTheme::new( + [ + ("function", rgba(0x100000ff)), + ("function.method", rgba(0x200000ff)), + ("function.async", rgba(0x300000ff)), + ("variable.builtin.self.rust", rgba(0x400000ff)), + ("variable.builtin", rgba(0x500000ff)), + ("variable", rgba(0x600000ff)), + ] + .iter() + .map(|(name, color)| (name.to_string(), (*color).into())), + ); + + let capture_names = &[ + "function.special", + "function.async.rust", + "variable.builtin.self", + ]; + + let map = build_highlight_map(capture_names, &theme); + assert_eq!(theme.get_capture_name(map.get(0)), Some("function")); + assert_eq!(theme.get_capture_name(map.get(1)), Some("function.async")); + assert_eq!(theme.get_capture_name(map.get(2)), Some("variable.builtin")); + } + #[gpui::test(iterations = 10)] + async fn test_language_loading(cx: &mut TestAppContext) { let languages = LanguageRegistry::test(cx.executor()); let languages = Arc::new(languages); diff --git a/crates/language/src/language_registry.rs b/crates/language/src/language_registry.rs index d73a44fda3347ebcec9c6798325838acec543566..2ac6ef456d2ee17c8710ec1c37f22ff34a648e4d 100644 --- a/crates/language/src/language_registry.rs +++ b/crates/language/src/language_registry.rs @@ -5,6 +5,10 @@ use crate::{ }; use anyhow::{Context as _, Result, anyhow}; use collections::{FxHashMap, HashMap, HashSet, hash_map}; +pub use language_core::{ + BinaryStatus, LanguageName, LanguageQueries, LanguageServerStatusUpdate, + QUERY_FILENAME_PREFIXES, ServerHealth, +}; use settings::{AllLanguageSettingsContent, LanguageSettingsContent}; use futures::{ @@ -12,15 +16,13 @@ use futures::{ channel::{mpsc, oneshot}, }; use globset::GlobSet; -use gpui::{App, BackgroundExecutor, SharedString}; +use gpui::{App, BackgroundExecutor}; use lsp::LanguageServerId; use parking_lot::{Mutex, RwLock}; use postage::watch; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; + use smallvec::SmallVec; use std::{ - borrow::{Borrow, Cow}, cell::LazyCell, ffi::OsStr, ops::Not, @@ -33,91 +35,6 @@ use theme::Theme; use unicase::UniCase; use util::{ResultExt, maybe, post_inc}; -#[derive( - Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, -)] -pub struct LanguageName(pub SharedString); - -impl LanguageName { - pub fn new(s: &str) -> Self { - Self(SharedString::new(s)) - } - - pub fn new_static(s: &'static str) -> Self { - Self(SharedString::new_static(s)) - } - - pub fn from_proto(s: String) -> Self { - Self(SharedString::from(s)) - } - - pub fn to_proto(&self) -> String { - self.0.to_string() - } - - pub fn lsp_id(&self) -> String { - match self.0.as_ref() { - "Plain Text" => "plaintext".to_string(), - language_name => language_name.to_lowercase(), - } - } -} - -impl From for SharedString { - fn from(value: LanguageName) -> Self { - value.0 - } -} - -impl From for LanguageName { - fn from(value: SharedString) -> Self { - LanguageName(value) - } -} - -impl AsRef for LanguageName { - fn as_ref(&self) -> &str { - self.0.as_ref() - } -} - -impl Borrow for LanguageName { - fn borrow(&self) -> &str { - self.0.as_ref() - } -} - -impl PartialEq for LanguageName { - fn eq(&self, other: &str) -> bool { - self.0.as_ref() == other - } -} - -impl PartialEq<&str> for LanguageName { - fn eq(&self, other: &&str) -> bool { - self.0.as_ref() == *other - } -} - -impl std::fmt::Display for LanguageName { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} - -impl From<&'static str> for LanguageName { - fn from(str: &'static str) -> Self { - Self(SharedString::new_static(str)) - } -} - -impl From for String { - fn from(value: LanguageName) -> Self { - let value: &str = &value.0; - Self::from(value) - } -} - pub struct LanguageRegistry { state: RwLock, language_server_download_dir: Option>, @@ -153,31 +70,6 @@ pub struct FakeLanguageServerEntry { pub _server: Option, } -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum LanguageServerStatusUpdate { - Binary(BinaryStatus), - Health(ServerHealth, Option), -} - -#[derive(Debug, PartialEq, Eq, Deserialize, Serialize, Clone, Copy)] -#[serde(rename_all = "camelCase")] -pub enum ServerHealth { - Ok, - Warning, - Error, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum BinaryStatus { - None, - CheckingForUpdate, - Downloading, - Starting, - Stopping, - Stopped, - Failed { error: String }, -} - #[derive(Clone)] pub struct AvailableLanguage { id: LanguageId, @@ -232,39 +124,6 @@ impl std::fmt::Display for LanguageNotFound { } } -pub const QUERY_FILENAME_PREFIXES: &[( - &str, - fn(&mut LanguageQueries) -> &mut Option>, -)] = &[ - ("highlights", |q| &mut q.highlights), - ("brackets", |q| &mut q.brackets), - ("outline", |q| &mut q.outline), - ("indents", |q| &mut q.indents), - ("injections", |q| &mut q.injections), - ("overrides", |q| &mut q.overrides), - ("redactions", |q| &mut q.redactions), - ("runnables", |q| &mut q.runnables), - ("debugger", |q| &mut q.debugger), - ("textobjects", |q| &mut q.text_objects), - ("imports", |q| &mut q.imports), -]; - -/// Tree-sitter language queries for a given language. -#[derive(Debug, Default)] -pub struct LanguageQueries { - pub highlights: Option>, - pub brackets: Option>, - pub indents: Option>, - pub outline: Option>, - pub injections: Option>, - pub overrides: Option>, - pub redactions: Option>, - pub runnables: Option>, - pub text_objects: Option>, - pub debugger: Option>, - pub imports: Option>, -} - #[derive(Clone, Default)] struct ServerStatusSender { txs: Arc>>>, @@ -745,6 +604,44 @@ impl LanguageRegistry { .cloned() } + /// Look up a language by its modeline name (vim filetype or emacs mode). + /// + /// This performs a case-insensitive match against: + /// 1. Explicit modeline aliases defined in the language config + /// 2. The language's grammar name + /// 3. The language name itself + pub fn available_language_for_modeline_name( + self: &Arc, + modeline_name: &str, + ) -> Option { + let modeline_name_lower = modeline_name.to_lowercase(); + let state = self.state.read(); + + state + .available_languages + .iter() + .find(|lang| { + lang.matcher + .modeline_aliases + .iter() + .any(|alias| alias.to_lowercase() == modeline_name_lower) + }) + .or_else(|| { + state.available_languages.iter().find(|lang| { + lang.grammar + .as_ref() + .is_some_and(|g| g.to_lowercase() == modeline_name_lower) + }) + }) + .or_else(|| { + state + .available_languages + .iter() + .find(|lang| lang.name.0.to_lowercase() == modeline_name_lower) + }) + .cloned() + } + pub fn language_for_file( self: &Arc, file: &Arc, @@ -1223,7 +1120,7 @@ impl LanguageRegistryState { LanguageSettingsContent { tab_size: language.config.tab_size, hard_tabs: language.config.hard_tabs, - soft_wrap: language.config.soft_wrap, + soft_wrap: language.config.soft_wrap.map(crate::to_settings_soft_wrap), auto_indent_on_paste: language.config.auto_indent_on_paste, ..Default::default() }, diff --git a/crates/language/src/language_settings.rs b/crates/language/src/language_settings.rs index f2c55fd1e8a3b8bf5b6c2dd8ea24d1343385fa78..1e7910fa54f91938ea0d8e34a7818384c3b81a0e 100644 --- a/crates/language/src/language_settings.rs +++ b/crates/language/src/language_settings.rs @@ -1,6 +1,8 @@ //! Provides `language`-related settings. -use crate::{File, Language, LanguageName, LanguageServerName}; +use crate::{ + Buffer, BufferSnapshot, File, Language, LanguageName, LanguageServerName, ModelineSettings, +}; use collections::{FxHashMap, HashMap, HashSet}; use ec4rs::{ Properties as EditorconfigProperties, @@ -17,22 +19,10 @@ pub use settings::{ LanguageSettingsContent, LspInsertMode, RewrapBehavior, ShowWhitespaceSetting, SoftWrap, WordsCompletionMode, }; -use settings::{RegisterSetting, Settings, SettingsLocation, SettingsStore}; +use settings::{RegisterSetting, Settings, SettingsLocation, SettingsStore, merge_from::MergeFrom}; use shellexpand; use std::{borrow::Cow, num::NonZeroU32, path::Path, sync::Arc}; - -/// Returns the settings for the specified language from the provided file. -pub fn language_settings<'a>( - language: Option, - file: Option<&'a Arc>, - cx: &'a App, -) -> Cow<'a, LanguageSettings> { - let location = file.map(|f| SettingsLocation { - worktree_id: f.worktree_id(cx), - path: f.path().as_ref(), - }); - AllLanguageSettings::get(location, cx).language(location, language.as_ref(), cx) -} +use text::ToOffset; /// Returns the settings for all languages from the provided file. pub fn all_language_settings<'a>( @@ -284,6 +274,74 @@ impl LanguageSettings { /// A token representing the rest of the available language servers. const REST_OF_LANGUAGE_SERVERS: &'static str = "..."; + pub fn for_buffer<'a>(buffer: &'a Buffer, cx: &'a App) -> Cow<'a, LanguageSettings> { + Self::resolve(Some(buffer), None, cx) + } + + pub fn for_buffer_at<'a, D: ToOffset>( + buffer: &'a Buffer, + position: D, + cx: &'a App, + ) -> Cow<'a, LanguageSettings> { + let language = buffer.language_at(position); + Self::resolve(Some(buffer), language.map(|l| l.name()).as_ref(), cx) + } + + pub fn for_buffer_snapshot<'a>( + buffer: &'a BufferSnapshot, + offset: Option, + cx: &'a App, + ) -> Cow<'a, LanguageSettings> { + let location = buffer.file().map(|f| SettingsLocation { + worktree_id: f.worktree_id(cx), + path: f.path().as_ref(), + }); + + let language = if let Some(offset) = offset { + buffer.language_at(offset) + } else { + buffer.language() + }; + + let mut settings = AllLanguageSettings::get(location, cx).language( + location, + language.map(|l| l.name()).as_ref(), + cx, + ); + + if let Some(modeline) = buffer.modeline() { + merge_with_modeline(settings.to_mut(), modeline); + } + + settings + } + + pub fn resolve<'a>( + buffer: Option<&'a Buffer>, + override_language: Option<&LanguageName>, + cx: &'a App, + ) -> Cow<'a, LanguageSettings> { + let Some(buffer) = buffer else { + return AllLanguageSettings::get(None, cx).language(None, override_language, cx); + }; + let location = buffer.file().map(|f| SettingsLocation { + worktree_id: f.worktree_id(cx), + path: f.path().as_ref(), + }); + let all = AllLanguageSettings::get(location, cx); + let mut settings = if override_language.is_none() { + all.language(location, buffer.language().map(|l| l.name()).as_ref(), cx) + } else { + all.language(location, override_language, cx) + }; + + if let Some(modeline) = buffer.modeline() { + merge_with_modeline(settings.to_mut(), modeline); + } + + settings + } + /// Returns the customized list of language servers from the list of /// available language servers. pub fn customized_language_servers( @@ -411,8 +469,6 @@ pub struct EditPredictionSettings { pub copilot: CopilotSettings, /// Settings specific to Codestral. pub codestral: CodestralSettings, - /// Settings specific to Sweep. - pub sweep: SweepSettings, /// Settings specific to Ollama. pub ollama: Option, pub open_ai_compatible_api: Option, @@ -464,15 +520,6 @@ pub struct CodestralSettings { pub api_url: Option, } -#[derive(Clone, Debug, Default)] -pub struct SweepSettings { - /// When enabled, Sweep will not store edit prediction inputs or outputs. - /// When disabled, Sweep may collect data including buffer contents, - /// diagnostics, file paths, repository names, and generated predictions - /// to improve the service. - pub privacy_mode: bool, -} - #[derive(Clone, Debug, Default)] pub struct OpenAiCompatibleEditPredictionSettings { /// Model to use for completions. @@ -530,6 +577,42 @@ impl AllLanguageSettings { } } +fn merge_with_modeline(settings: &mut LanguageSettings, modeline: &ModelineSettings) { + let show_whitespaces = modeline.show_trailing_whitespace.and_then(|v| { + if v { + Some(ShowWhitespaceSetting::Trailing) + } else { + None + } + }); + + settings + .tab_size + .merge_from_option(modeline.tab_size.as_ref()); + settings + .hard_tabs + .merge_from_option(modeline.hard_tabs.as_ref()); + settings + .preferred_line_length + .merge_from_option(modeline.preferred_line_length.map(u32::from).as_ref()); + let auto_indent_mode = modeline.auto_indent.map(|enabled| { + if enabled { + AutoIndentMode::SyntaxAware + } else { + AutoIndentMode::None + } + }); + settings + .auto_indent + .merge_from_option(auto_indent_mode.as_ref()); + settings + .show_whitespaces + .merge_from_option(show_whitespaces.as_ref()); + settings + .ensure_final_newline_on_save + .merge_from_option(modeline.ensure_final_newline.as_ref()); +} + fn merge_with_editorconfig(settings: &mut LanguageSettings, cfg: &EditorconfigProperties) { let preferred_line_length = cfg.get::().ok().and_then(|v| match v { MaxLineLen::Value(u) => Some(u as u32), @@ -557,22 +640,18 @@ fn merge_with_editorconfig(settings: &mut LanguageSettings, cfg: &EditorconfigPr TrimTrailingWs::Value(b) => b, }) .ok(); - fn merge(target: &mut T, value: Option) { - if let Some(value) = value { - *target = value; - } - } - merge(&mut settings.preferred_line_length, preferred_line_length); - merge(&mut settings.tab_size, tab_size); - merge(&mut settings.hard_tabs, hard_tabs); - merge( - &mut settings.remove_trailing_whitespace_on_save, - remove_trailing_whitespace_on_save, - ); - merge( - &mut settings.ensure_final_newline_on_save, - ensure_final_newline_on_save, - ); + + settings + .preferred_line_length + .merge_from_option(preferred_line_length.as_ref()); + settings.tab_size.merge_from_option(tab_size.as_ref()); + settings.hard_tabs.merge_from_option(hard_tabs.as_ref()); + settings + .remove_trailing_whitespace_on_save + .merge_from_option(remove_trailing_whitespace_on_save.as_ref()); + settings + .ensure_final_newline_on_save + .merge_from_option(ensure_final_newline_on_save.as_ref()); } impl settings::Settings for AllLanguageSettings { @@ -715,10 +794,6 @@ impl settings::Settings for AllLanguageSettings { api_url: codestral.api_url, }; - let sweep = edit_predictions.sweep.unwrap(); - let sweep_settings = SweepSettings { - privacy_mode: sweep.privacy_mode.unwrap(), - }; let ollama = edit_predictions.ollama.unwrap(); let ollama_settings = ollama .model @@ -782,7 +857,6 @@ impl settings::Settings for AllLanguageSettings { mode: edit_predictions_mode, copilot: copilot_settings, codestral: codestral_settings, - sweep: sweep_settings, ollama: ollama_settings, open_ai_compatible_api: openai_compatible_settings, enabled_in_text_threads, diff --git a/crates/language/src/manifest.rs b/crates/language/src/manifest.rs index 82ed164a032cb18d2d011f59938a0cd1410ba60f..a155ac28332e8b1d4f5a2c238e3622169787789c 100644 --- a/crates/language/src/manifest.rs +++ b/crates/language/src/manifest.rs @@ -1,43 +1,12 @@ -use std::{borrow::Borrow, sync::Arc}; +use std::sync::Arc; -use gpui::SharedString; use settings::WorktreeId; use util::rel_path::RelPath; -#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct ManifestName(SharedString); +// Re-export ManifestName from language_core. +pub use language_core::ManifestName; -impl Borrow for ManifestName { - fn borrow(&self) -> &SharedString { - &self.0 - } -} - -impl Borrow for ManifestName { - fn borrow(&self) -> &str { - &self.0 - } -} - -impl From for ManifestName { - fn from(value: SharedString) -> Self { - Self(value) - } -} - -impl From for SharedString { - fn from(value: ManifestName) -> Self { - value.0 - } -} - -impl AsRef for ManifestName { - fn as_ref(&self) -> &SharedString { - &self.0 - } -} - -/// Represents a manifest query; given a path to a file, [ManifestSearcher] is tasked with finding a path to the directory containing the manifest for that file. +/// Represents a manifest query; given a path to a file, the manifest provider is tasked with finding a path to the directory containing the manifest for that file. /// /// Since parts of the path might have already been explored, there's an additional `depth` parameter that indicates to what ancestry level a given path should be explored. /// For example, given a path like `foo/bar/baz`, a depth of 2 would explore `foo/bar/baz` and `foo/bar`, but not `foo`. diff --git a/crates/language/src/modeline.rs b/crates/language/src/modeline.rs new file mode 100644 index 0000000000000000000000000000000000000000..8b7e6044492fc64c5291a9d034466e1ec42a5ead --- /dev/null +++ b/crates/language/src/modeline.rs @@ -0,0 +1,763 @@ +use regex::Regex; +use std::{num::NonZeroU32, sync::LazyLock}; + +/// The settings extracted from an emacs/vim modelines. +/// +/// The parsing tries to best match the modeline directives and +/// variables to Zed, matching LanguageSettings fields. +/// The mode mapping is done later thanks to the LanguageRegistry. +/// +/// It is not exhaustive, but covers the most common settings. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct ModelineSettings { + /// The emacs mode or vim filetype. + pub mode: Option, + /// How many columns a tab should occupy. + pub tab_size: Option, + /// Whether to indent lines using tab characters, as opposed to multiple + /// spaces. + pub hard_tabs: Option, + /// The number of bytes that comprise the indentation. + pub indent_size: Option, + /// Whether to auto-indent lines. + pub auto_indent: Option, + /// The column at which to soft-wrap lines. + pub preferred_line_length: Option, + /// Whether to ensure a final newline at the end of the file. + pub ensure_final_newline: Option, + /// Whether to show trailing whitespace on the editor. + pub show_trailing_whitespace: Option, + + /// Emacs modeline variables that were parsed but not mapped to Zed settings. + /// Stored as (variable-name, value) pairs. + pub emacs_extra_variables: Vec<(String, String)>, + /// Vim modeline options that were parsed but not mapped to Zed settings. + /// Stored as (option-name, value) pairs. + pub vim_extra_variables: Vec<(String, Option)>, +} + +impl ModelineSettings { + fn has_settings(&self) -> bool { + self != &Self::default() + } +} + +/// Parse modelines from file content. +/// +/// Supports: +/// - Emacs modelines: -*- mode: rust; tab-width: 4; indent-tabs-mode: nil; -*- and "Local Variables" +/// - Vim modelines: vim: set ft=rust ts=4 sw=4 et: +pub fn parse_modeline(first_lines: &[&str], last_lines: &[&str]) -> Option { + let mut settings = ModelineSettings::default(); + + parse_modelines(first_lines, &mut settings); + + // Parse Emacs Local Variables in last lines + parse_emacs_local_variables(last_lines, &mut settings); + + // Also check for vim modelines in last lines if we don't have settings yet + if !settings.has_settings() { + parse_vim_modelines(last_lines, &mut settings); + } + + Some(settings).filter(|s| s.has_settings()) +} + +fn parse_modelines(modelines: &[&str], settings: &mut ModelineSettings) { + for line in modelines { + parse_emacs_modeline(line, settings); + // if emacs is set, do not check for vim modelines + if settings.has_settings() { + return; + } + } + + parse_vim_modelines(modelines, settings); +} + +static EMACS_MODELINE_RE: LazyLock = + LazyLock::new(|| Regex::new(r"-\*-\s*(.+?)\s*-\*-").expect("valid regex")); + +/// Parse Emacs-style modelines +/// Format: -*- mode: rust; tab-width: 4; indent-tabs-mode: nil; -*- +/// See Emacs (set-auto-mode) +fn parse_emacs_modeline(line: &str, settings: &mut ModelineSettings) { + let Some(captures) = EMACS_MODELINE_RE.captures(line) else { + return; + }; + let Some(modeline_content) = captures.get(1).map(|m| m.as_str()) else { + return; + }; + for part in modeline_content.split(';') { + parse_emacs_key_value(part, settings, true); + } +} + +/// Parse Emacs-style Local Variables block +/// +/// Emacs supports a "Local Variables" block at the end of files: +/// ```text +/// /* Local Variables: */ +/// /* mode: c */ +/// /* tab-width: 4 */ +/// /* End: */ +/// ``` +/// +/// Emacs related code is hack-local-variables--find-variables in +/// https://cgit.git.savannah.gnu.org/cgit/emacs.git/tree/lisp/files.el#n4346 +fn parse_emacs_local_variables(lines: &[&str], settings: &mut ModelineSettings) { + const LOCAL_VARIABLES: &str = "Local Variables:"; + + let Some((start_idx, prefix, suffix)) = lines.iter().enumerate().find_map(|(i, line)| { + let prefix_len = line.find(LOCAL_VARIABLES)?; + let suffix_start = prefix_len + LOCAL_VARIABLES.len(); + Some((i, line.get(..prefix_len)?, line.get(suffix_start..)?)) + }) else { + return; + }; + + let mut continuation = String::new(); + + for line in &lines[start_idx + 1..] { + let Some(content) = line + .strip_prefix(prefix) + .and_then(|l| l.strip_suffix(suffix)) + .map(str::trim) + else { + return; + }; + + if let Some(continued) = content.strip_suffix('\\') { + continuation.push_str(continued); + continue; + } + + let to_parse = if continuation.is_empty() { + content + } else { + continuation.push_str(content); + &continuation + }; + + if to_parse == "End:" { + return; + } + + parse_emacs_key_value(to_parse, settings, false); + continuation.clear(); + } +} + +fn parse_emacs_key_value(part: &str, settings: &mut ModelineSettings, bare: bool) { + let part = part.trim(); + if part.is_empty() { + return; + } + + if let Some((key, value)) = part.split_once(':') { + let key = key.trim(); + let value = value.trim(); + + match key.to_lowercase().as_str() { + "mode" => { + settings.mode = Some(value.to_string()); + } + "c-basic-offset" | "python-indent-offset" => { + if let Ok(size) = value.parse::() { + settings.indent_size = Some(size); + } + } + "fill-column" => { + if let Ok(size) = value.parse::() { + settings.preferred_line_length = Some(size); + } + } + "tab-width" => { + if let Ok(size) = value.parse::() { + settings.tab_size = Some(size); + } + } + "indent-tabs-mode" => { + settings.hard_tabs = Some(value != "nil"); + } + "electric-indent-mode" => { + settings.auto_indent = Some(value != "nil"); + } + "require-final-newline" => { + settings.ensure_final_newline = Some(value != "nil"); + } + "show-trailing-whitespace" => { + settings.show_trailing_whitespace = Some(value != "nil"); + } + key => settings + .emacs_extra_variables + .push((key.to_string(), value.to_string())), + } + } else if bare { + // Handle bare mode specification (e.g., -*- rust -*-) + settings.mode = Some(part.to_string()); + } +} + +fn parse_vim_modelines(modelines: &[&str], settings: &mut ModelineSettings) { + for line in modelines { + parse_vim_modeline(line, settings); + } +} + +static VIM_MODELINE_PATTERNS: LazyLock> = LazyLock::new(|| { + [ + // Second form: [text{white}]{vi:vim:Vim:}[white]se[t] {options}:[text] + // Allow escaped colons in options: match non-colon chars or backslash followed by any char + r"(?:^|\s)(vi|vim|Vim):(?:\s*)se(?:t)?\s+((?:[^\\:]|\\.)*):", + // First form: [text{white}]{vi:vim:}[white]{options} + r"(?:^|\s+)(vi|vim):(?:\s*(.+))", + ] + .iter() + .map(|pattern| Regex::new(pattern).expect("valid regex")) + .collect() +}); + +/// Parse Vim-style modelines +/// Supports both forms: +/// 1. First form: vi:noai:sw=3 ts=6 +/// 2. Second form: vim: set ft=rust ts=4 sw=4 et: +fn parse_vim_modeline(line: &str, settings: &mut ModelineSettings) { + for re in VIM_MODELINE_PATTERNS.iter() { + if let Some(captures) = re.captures(line) { + if let Some(options) = captures.get(2) { + parse_vim_settings(options.as_str().trim(), settings); + break; + } + } + } +} + +fn parse_vim_settings(content: &str, settings: &mut ModelineSettings) { + fn split_colon_unescape(input: &str) -> Vec { + let mut split = Vec::new(); + let mut str = String::new(); + let mut chars = input.chars().peekable(); + while let Some(c) = chars.next() { + if c == '\\' { + match chars.next() { + Some(escaped_char) => str.push(escaped_char), + None => str.push('\\'), + } + } else if c == ':' { + split.push(std::mem::take(&mut str)); + } else { + str.push(c); + } + } + split.push(str); + split + } + + let parts = split_colon_unescape(content); + for colon_part in parts { + let colon_part = colon_part.trim(); + if colon_part.is_empty() { + continue; + } + + // Each colon part might contain space-separated options + for part in colon_part.split_whitespace() { + if let Some((key, value)) = part.split_once('=') { + match key { + "ft" | "filetype" => { + settings.mode = Some(value.to_string()); + } + "ts" | "tabstop" => { + if let Ok(size) = value.parse::() { + settings.tab_size = Some(size); + } + } + "sw" | "shiftwidth" => { + if let Ok(size) = value.parse::() { + settings.indent_size = Some(size); + } + } + "tw" | "textwidth" => { + if let Ok(size) = value.parse::() { + settings.preferred_line_length = Some(size); + } + } + _ => { + settings + .vim_extra_variables + .push((key.to_string(), Some(value.to_string()))); + } + } + } else { + match part { + "ai" | "autoindent" => { + settings.auto_indent = Some(true); + } + "noai" | "noautoindent" => { + settings.auto_indent = Some(false); + } + "et" | "expandtab" => { + settings.hard_tabs = Some(false); + } + "noet" | "noexpandtab" => { + settings.hard_tabs = Some(true); + } + "eol" | "endofline" => { + settings.ensure_final_newline = Some(true); + } + "noeol" | "noendofline" => { + settings.ensure_final_newline = Some(false); + } + "set" => { + // Ignore the "set" keyword itself + } + _ => { + settings.vim_extra_variables.push((part.to_string(), None)); + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use indoc::indoc; + use pretty_assertions::assert_eq; + + #[test] + fn test_no_modeline() { + let content = "This is just regular content\nwith no modeline"; + assert!(parse_modeline(&[content], &[content]).is_none()); + } + + #[test] + fn test_emacs_bare_mode() { + let content = "/* -*- rust -*- */"; + let settings = parse_modeline(&[content], &[]).unwrap(); + assert_eq!( + settings, + ModelineSettings { + mode: Some("rust".to_string()), + ..Default::default() + } + ); + } + + #[test] + fn test_emacs_modeline_parsing() { + let content = "/* -*- mode: rust; tab-width: 4; indent-tabs-mode: nil; -*- */"; + let settings = parse_modeline(&[content], &[]).unwrap(); + assert_eq!( + settings, + ModelineSettings { + mode: Some("rust".to_string()), + tab_size: Some(NonZeroU32::new(4).unwrap()), + hard_tabs: Some(false), + ..Default::default() + } + ); + } + + #[test] + fn test_emacs_last_line_parsing() { + let content = indoc! {r#" + # Local Variables: + # compile-command: "cc foo.c -Dfoo=bar -Dhack=whatever \ + # -Dmumble=blaah" + # End: + "#} + .lines() + .collect::>(); + let settings = parse_modeline(&[], &content).unwrap(); + assert_eq!( + settings, + ModelineSettings { + emacs_extra_variables: vec![( + "compile-command".to_string(), + "\"cc foo.c -Dfoo=bar -Dhack=whatever -Dmumble=blaah\"".to_string() + ),], + ..Default::default() + } + ); + + let content = indoc! {" + foo + /* Local Variables: */ + /* eval: (font-lock-mode -1) */ + /* mode: old-c */ + /* mode: c */ + /* End: */ + /* mode: ignored */ + "} + .lines() + .collect::>(); + let settings = parse_modeline(&[], &content).unwrap(); + assert_eq!( + settings, + ModelineSettings { + mode: Some("c".to_string()), + emacs_extra_variables: vec![( + "eval".to_string(), + "(font-lock-mode -1)".to_string() + ),], + ..Default::default() + } + ); + } + + #[test] + fn test_vim_modeline_parsing() { + // Test second form (set format) + let content = "// vim: set ft=rust ts=4 sw=4 et:"; + let settings = parse_modeline(&[content], &[]).unwrap(); + assert_eq!( + settings, + ModelineSettings { + mode: Some("rust".to_string()), + tab_size: Some(NonZeroU32::new(4).unwrap()), + hard_tabs: Some(false), + indent_size: Some(NonZeroU32::new(4).unwrap()), + ..Default::default() + } + ); + + // Test first form (colon-separated) + let content = "vi:noai:sw=3:ts=6"; + let settings = parse_modeline(&[content], &[]).unwrap(); + assert_eq!( + settings, + ModelineSettings { + tab_size: Some(NonZeroU32::new(6).unwrap()), + auto_indent: Some(false), + indent_size: Some(NonZeroU32::new(3).unwrap()), + ..Default::default() + } + ); + } + + #[test] + fn test_vim_modeline_first_form() { + // Examples from vim specification: vi:noai:sw=3 ts=6 + let content = " vi:noai:sw=3 ts=6 "; + let settings = parse_modeline(&[content], &[]).unwrap(); + assert_eq!( + settings, + ModelineSettings { + tab_size: Some(NonZeroU32::new(6).unwrap()), + auto_indent: Some(false), + indent_size: Some(NonZeroU32::new(3).unwrap()), + ..Default::default() + } + ); + + // Test with filetype + let content = "vim:ft=python:ts=8:noet"; + let settings = parse_modeline(&[content], &[]).unwrap(); + assert_eq!( + settings, + ModelineSettings { + mode: Some("python".to_string()), + tab_size: Some(NonZeroU32::new(8).unwrap()), + hard_tabs: Some(true), + ..Default::default() + } + ); + } + + #[test] + fn test_vim_modeline_second_form() { + // Examples from vim specification: /* vim: set ai tw=75: */ + let content = "/* vim: set ai tw=75: */"; + let settings = parse_modeline(&[content], &[]).unwrap(); + assert_eq!( + settings, + ModelineSettings { + auto_indent: Some(true), + preferred_line_length: Some(NonZeroU32::new(75).unwrap()), + ..Default::default() + } + ); + + // Test with 'Vim:' (capital V) + let content = "/* Vim: set ai tw=75: */"; + let settings = parse_modeline(&[content], &[]).unwrap(); + assert_eq!( + settings, + ModelineSettings { + auto_indent: Some(true), + preferred_line_length: Some(NonZeroU32::new(75).unwrap()), + ..Default::default() + } + ); + + // Test 'se' shorthand + let content = "// vi: se ft=c ts=4:"; + let settings = parse_modeline(&[content], &[]).unwrap(); + assert_eq!( + settings, + ModelineSettings { + mode: Some("c".to_string()), + tab_size: Some(NonZeroU32::new(4).unwrap()), + ..Default::default() + } + ); + + // Test complex modeline with encoding + let content = "# vim: set ft=python ts=4 sw=4 et encoding=utf-8:"; + let settings = parse_modeline(&[content], &[]).unwrap(); + assert_eq!( + settings, + ModelineSettings { + mode: Some("python".to_string()), + tab_size: Some(NonZeroU32::new(4).unwrap()), + hard_tabs: Some(false), + indent_size: Some(NonZeroU32::new(4).unwrap()), + vim_extra_variables: vec![("encoding".to_string(), Some("utf-8".to_string()))], + ..Default::default() + } + ); + } + + #[test] + fn test_vim_modeline_edge_cases() { + // Test modeline at start of line (compatibility with version 3.0) + let content = "vi:ts=2:et"; + let settings = parse_modeline(&[content], &[]).unwrap(); + assert_eq!( + settings, + ModelineSettings { + tab_size: Some(NonZeroU32::new(2).unwrap()), + hard_tabs: Some(false), + ..Default::default() + } + ); + + // Test vim at start of line + let content = "vim:ft=rust:noet"; + let settings = parse_modeline(&[content], &[]).unwrap(); + assert_eq!( + settings, + ModelineSettings { + mode: Some("rust".to_string()), + hard_tabs: Some(true), + ..Default::default() + } + ); + + // Test mixed boolean flags + let content = "vim: set wrap noet ts=8:"; + let settings = parse_modeline(&[content], &[]).unwrap(); + assert_eq!( + settings, + ModelineSettings { + tab_size: Some(NonZeroU32::new(8).unwrap()), + hard_tabs: Some(true), + vim_extra_variables: vec![("wrap".to_string(), None)], + ..Default::default() + } + ); + } + + #[test] + fn test_vim_modeline_invalid_cases() { + // Test malformed options are ignored gracefully + let content = "vim: set ts=invalid ft=rust:"; + let settings = parse_modeline(&[content], &[]).unwrap(); + assert_eq!( + settings, + ModelineSettings { + mode: Some("rust".to_string()), + ..Default::default() + } + ); + + // Test empty modeline content - this should still work as there might be options + let content = "vim: set :"; + // This should return None because there are no actual options + let result = parse_modeline(&[content], &[]); + assert!(result.is_none(), "Expected None but got: {:?}", result); + + // Test modeline without proper format + let content = "not a modeline"; + assert!(parse_modeline(&[content], &[]).is_none()); + + // Test word that looks like modeline but isn't + let content = "example: this could be confused with ex:"; + assert!(parse_modeline(&[content], &[]).is_none()); + } + + #[test] + fn test_vim_language_mapping() { + // Test vim-specific language mappings + let content = "vim: set ft=sh:"; + let settings = parse_modeline(&[content], &[]).unwrap(); + assert_eq!(settings.mode, Some("sh".to_string())); + + let content = "vim: set ft=golang:"; + let settings = parse_modeline(&[content], &[]).unwrap(); + assert_eq!(settings.mode, Some("golang".to_string())); + + let content = "vim: set filetype=js:"; + let settings = parse_modeline(&[content], &[]).unwrap(); + assert_eq!(settings.mode, Some("js".to_string())); + } + + #[test] + fn test_vim_extra_variables() { + // Test that unknown vim options are stored as extra variables + let content = "vim: set foldmethod=marker conceallevel=2 custom=value:"; + let settings = parse_modeline(&[content], &[]).unwrap(); + + assert!( + settings + .vim_extra_variables + .contains(&("foldmethod".to_string(), Some("marker".to_string()))) + ); + assert!( + settings + .vim_extra_variables + .contains(&("conceallevel".to_string(), Some("2".to_string()))) + ); + assert!( + settings + .vim_extra_variables + .contains(&("custom".to_string(), Some("value".to_string()))) + ); + } + + #[test] + fn test_modeline_position() { + // Test modeline in first lines + let first_lines = ["#!/bin/bash", "# vim: set ft=bash ts=4:"]; + let settings = parse_modeline(&first_lines, &[]).unwrap(); + assert_eq!(settings.mode, Some("bash".to_string())); + + // Test modeline in last lines + let last_lines = ["", "/* vim: set ft=c: */"]; + let settings = parse_modeline(&[], &last_lines).unwrap(); + assert_eq!(settings.mode, Some("c".to_string())); + + // Test no modeline found + let content = ["regular content", "no modeline here"]; + assert!(parse_modeline(&content, &content).is_none()); + } + + #[test] + fn test_vim_modeline_version_checks() { + // Note: Current implementation doesn't support version checks yet + // These are tests for future implementation based on vim spec + + // Test version-specific modelines (currently ignored in our implementation) + let content = "/* vim700: set foldmethod=marker */"; + // Should be ignored for now since we don't support version checks + assert!(parse_modeline(&[content], &[]).is_none()); + + let content = "/* vim>702: set cole=2: */"; + // Should be ignored for now since we don't support version checks + assert!(parse_modeline(&[content], &[]).is_none()); + } + + #[test] + fn test_vim_modeline_colon_escaping() { + // Test colon escaping as mentioned in vim spec + + // According to vim spec: "if you want to include a ':' in a set command precede it with a '\'" + let content = r#"/* vim: set fdm=expr fde=getline(v\:lnum)=~'{'?'>1'\:'1': */"#; + + let result = parse_modeline(&[content], &[]).unwrap(); + + // The modeline should parse fdm=expr and fde=getline(v:lnum)=~'{'?'>1':'1' + // as extra variables since they're not recognized settings + assert_eq!(result.vim_extra_variables.len(), 2); + assert_eq!( + result.vim_extra_variables[0], + ("fdm".to_string(), Some("expr".to_string())) + ); + assert_eq!( + result.vim_extra_variables[1], + ( + "fde".to_string(), + Some("getline(v:lnum)=~'{'?'>1':'1'".to_string()) + ) + ); + } + + #[test] + fn test_vim_modeline_whitespace_requirements() { + // Test whitespace requirements from vim spec + + // Valid: whitespace before vi/vim + let content = " vim: set ft=rust:"; + assert!(parse_modeline(&[content], &[]).is_some()); + + // Valid: tab before vi/vim + let content = "\tvim: set ft=rust:"; + assert!(parse_modeline(&[content], &[]).is_some()); + + // Valid: vi/vim at start of line (compatibility) + let content = "vim: set ft=rust:"; + assert!(parse_modeline(&[content], &[]).is_some()); + } + + #[test] + fn test_vim_modeline_comprehensive_examples() { + // Real-world examples from vim documentation and common usage + + // Python example + let content = "# vim: set expandtab tabstop=4 shiftwidth=4 softtabstop=4:"; + let settings = parse_modeline(&[content], &[]).unwrap(); + assert_eq!(settings.hard_tabs, Some(false)); + assert_eq!(settings.tab_size, Some(NonZeroU32::new(4).unwrap())); + + // C example with multiple options + let content = "/* vim: set ts=8 sw=8 noet ai cindent: */"; + let settings = parse_modeline(&[content], &[]).unwrap(); + assert_eq!(settings.tab_size, Some(NonZeroU32::new(8).unwrap())); + assert_eq!(settings.hard_tabs, Some(true)); + assert!( + settings + .vim_extra_variables + .contains(&("cindent".to_string(), None)) + ); + + // Shell script example + let content = "# vi: set ft=sh ts=2 sw=2 et:"; + let settings = parse_modeline(&[content], &[]).unwrap(); + assert_eq!(settings.mode, Some("sh".to_string())); + assert_eq!(settings.tab_size, Some(NonZeroU32::new(2).unwrap())); + assert_eq!(settings.hard_tabs, Some(false)); + + // First form colon-separated + let content = "vim:ft=xml:ts=2:sw=2:et"; + let settings = parse_modeline(&[content], &[]).unwrap(); + assert_eq!(settings.mode, Some("xml".to_string())); + assert_eq!(settings.tab_size, Some(NonZeroU32::new(2).unwrap())); + assert_eq!(settings.hard_tabs, Some(false)); + } + + #[test] + fn test_combined_emacs_vim_detection() { + // Test that both emacs and vim modelines can be detected in the same file + + let first_lines = [ + "#!/usr/bin/env python3", + "# -*- require-final-newline: t; -*-", + "# vim: set ft=python ts=4 sw=4 et:", + ]; + + // Should find the emacs modeline first (with coding) + let settings = parse_modeline(&first_lines, &[]).unwrap(); + assert_eq!(settings.ensure_final_newline, Some(true)); + assert_eq!(settings.tab_size, None); + + // Test vim-only content + let vim_only = ["# vim: set ft=python ts=4 sw=4 et:"]; + let settings = parse_modeline(&vim_only, &[]).unwrap(); + assert_eq!(settings.mode, Some("python".to_string())); + assert_eq!(settings.tab_size, Some(NonZeroU32::new(4).unwrap())); + assert_eq!(settings.hard_tabs, Some(false)); + } +} diff --git a/crates/language/src/syntax_map.rs b/crates/language/src/syntax_map.rs index c5931c474d2962fc7ceb66954f2f00d3bf14b4f8..f2f79b9a793f303fef66fb4266d67f1fbd2ed52d 100644 --- a/crates/language/src/syntax_map.rs +++ b/crates/language/src/syntax_map.rs @@ -1121,7 +1121,7 @@ impl<'a> SyntaxMapCaptures<'a> { let grammar_index = result .grammars .iter() - .position(|g| g.id == grammar.id()) + .position(|g| g.id() == grammar.id()) .unwrap_or_else(|| { result.grammars.push(grammar); result.grammars.len() - 1 @@ -1265,7 +1265,7 @@ impl<'a> SyntaxMapMatches<'a> { let grammar_index = result .grammars .iter() - .position(|g| g.id == grammar.id()) + .position(|g| g.id() == grammar.id()) .unwrap_or_else(|| { result.grammars.push(grammar); result.grammars.len() - 1 diff --git a/crates/language/src/syntax_map/syntax_map_tests.rs b/crates/language/src/syntax_map/syntax_map_tests.rs index b7fec897b98aed7902cd25de65e008ba58ee55f9..247076b6f25e3cf62913c93d65ae352109effafa 100644 --- a/crates/language/src/syntax_map/syntax_map_tests.rs +++ b/crates/language/src/syntax_map/syntax_map_tests.rs @@ -1492,7 +1492,7 @@ fn python_lang() -> Language { ) .with_queries(LanguageQueries { injections: Some(Cow::from(include_str!( - "../../../languages/src/python/injections.scm" + "../../../grammars/src/python/injections.scm" ))), ..Default::default() }) diff --git a/crates/language/src/task_context.rs b/crates/language/src/task_context.rs index b8cc6d13fff14576ca938e36d8982973f6307912..dc59d21bd73a2d4a8e1d4a4c765195afffd2ce67 100644 --- a/crates/language/src/task_context.rs +++ b/crates/language/src/task_context.rs @@ -1,11 +1,11 @@ use std::{ops::Range, path::PathBuf, sync::Arc}; -use crate::{File, LanguageToolchainStore, Location, Runnable}; +use crate::{Buffer, LanguageToolchainStore, Location, Runnable}; use anyhow::Result; use collections::HashMap; use fs::Fs; -use gpui::{App, Task}; +use gpui::{App, Entity, Task}; use lsp::LanguageServerName; use task::{TaskTemplates, TaskVariables}; use text::BufferId; @@ -37,7 +37,7 @@ pub trait ContextProvider: Send + Sync { } /// Provides all tasks, associated with the current language. - fn associated_tasks(&self, _: Option>, _: &App) -> Task> { + fn associated_tasks(&self, _: Option>, _: &App) -> Task> { Task::ready(None) } diff --git a/crates/language/src/toolchain.rs b/crates/language/src/toolchain.rs index 0d80f84e7ec1dc330db823a0938421a1f5ad85c9..d33700b1724f964597c66d9df0bc792210c96e42 100644 --- a/crates/language/src/toolchain.rs +++ b/crates/language/src/toolchain.rs @@ -4,95 +4,21 @@ //! which is a set of tools used to interact with the projects written in said language. //! For example, a Python project can have an associated virtual environment; a Rust project can have a toolchain override. -use std::{ - path::{Path, PathBuf}, - sync::Arc, -}; +use std::{path::PathBuf, sync::Arc}; use async_trait::async_trait; use collections::HashMap; -use fs::Fs; + use futures::future::BoxFuture; -use gpui::{App, AsyncApp, SharedString}; +use gpui::{App, AsyncApp}; use settings::WorktreeId; use task::ShellKind; use util::rel_path::RelPath; -use crate::{LanguageName, ManifestName}; - -/// Represents a single toolchain. -#[derive(Clone, Eq, Debug)] -pub struct Toolchain { - /// User-facing label - pub name: SharedString, - /// Absolute path - pub path: SharedString, - pub language_name: LanguageName, - /// Full toolchain data (including language-specific details) - pub as_json: serde_json::Value, -} - -/// Declares a scope of a toolchain added by user. -/// -/// When the user adds a toolchain, we give them an option to see that toolchain in: -/// - All of their projects -/// - A project they're currently in. -/// - Only in the subproject they're currently in. -#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] -pub enum ToolchainScope { - Subproject(Arc, Arc), - Project, - /// Available in all projects on this box. It wouldn't make sense to show suggestions across machines. - Global, -} - -impl ToolchainScope { - pub fn label(&self) -> &'static str { - match self { - ToolchainScope::Subproject(_, _) => "Subproject", - ToolchainScope::Project => "Project", - ToolchainScope::Global => "Global", - } - } - - pub fn description(&self) -> &'static str { - match self { - ToolchainScope::Subproject(_, _) => { - "Available only in the subproject you're currently in." - } - ToolchainScope::Project => "Available in all locations in your current project.", - ToolchainScope::Global => "Available in all of your projects on this machine.", - } - } -} - -impl std::hash::Hash for Toolchain { - fn hash(&self, state: &mut H) { - let Self { - name, - path, - language_name, - as_json: _, - } = self; - name.hash(state); - path.hash(state); - language_name.hash(state); - } -} +use crate::LanguageName; -impl PartialEq for Toolchain { - fn eq(&self, other: &Self) -> bool { - let Self { - name, - path, - language_name, - as_json: _, - } = self; - // Do not use as_json for comparisons; it shouldn't impact equality, as it's not user-surfaced. - // Thus, there could be multiple entries that look the same in the UI. - (name, path, language_name).eq(&(&other.name, &other.path, &other.language_name)) - } -} +// Re-export core data types from language_core. +pub use language_core::{Toolchain, ToolchainList, ToolchainMetadata, ToolchainScope}; #[async_trait] pub trait ToolchainLister: Send + Sync + 'static { @@ -102,7 +28,6 @@ pub trait ToolchainLister: Send + Sync + 'static { worktree_root: PathBuf, subroot_relative_path: Arc, project_env: Option>, - fs: &dyn Fs, ) -> ToolchainList; /// Given a user-created toolchain, resolve lister-specific details. @@ -111,7 +36,6 @@ pub trait ToolchainLister: Send + Sync + 'static { &self, path: PathBuf, project_env: Option>, - fs: &dyn Fs, ) -> anyhow::Result; fn activation_script( @@ -125,16 +49,6 @@ pub trait ToolchainLister: Send + Sync + 'static { fn meta(&self) -> ToolchainMetadata; } -#[derive(Clone, PartialEq, Eq, Hash)] -pub struct ToolchainMetadata { - /// Returns a term which we should use in UI to refer to toolchains produced by a given `[ToolchainLister]`. - pub term: SharedString, - /// A user-facing placeholder describing the semantic meaning of a path to a new toolchain. - pub new_toolchain_placeholder: SharedString, - /// The name of the manifest file for this toolchain. - pub manifest_name: ManifestName, -} - #[async_trait(?Send)] pub trait LanguageToolchainStore: Send + Sync + 'static { async fn active_toolchain( @@ -168,31 +82,3 @@ impl LanguageToolchainStore for T { self.active_toolchain(worktree_id, &relative_path, language_name, cx) } } - -type DefaultIndex = usize; -#[derive(Default, Clone, Debug)] -pub struct ToolchainList { - pub toolchains: Vec, - pub default: Option, - pub groups: Box<[(usize, SharedString)]>, -} - -impl ToolchainList { - pub fn toolchains(&self) -> &[Toolchain] { - &self.toolchains - } - pub fn default_toolchain(&self) -> Option { - self.default.and_then(|ix| self.toolchains.get(ix)).cloned() - } - pub fn group_for_index(&self, index: usize) -> Option<(usize, SharedString)> { - if index >= self.toolchains.len() { - return None; - } - let first_equal_or_greater = self - .groups - .partition_point(|(group_lower_bound, _)| group_lower_bound <= &index); - self.groups - .get(first_equal_or_greater.checked_sub(1)?) - .cloned() - } -} diff --git a/crates/language_core/Cargo.toml b/crates/language_core/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..4861632b4663c860706525c65cd8607133b3ec71 --- /dev/null +++ b/crates/language_core/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "language_core" +version = "0.1.0" +edition = "2024" +publish = false + +[lib] +path = "src/language_core.rs" + +[dependencies] +anyhow.workspace = true +collections.workspace = true +gpui.workspace = true +log.workspace = true +lsp.workspace = true +parking_lot.workspace = true +regex.workspace = true +schemars.workspace = true +serde.workspace = true +serde_json.workspace = true +toml.workspace = true +tree-sitter.workspace = true +util.workspace = true + +[dev-dependencies] +gpui = { workspace = true, features = ["test-support"] } + +[features] +test-support = [] diff --git a/crates/language_core/LICENSE-GPL b/crates/language_core/LICENSE-GPL new file mode 120000 index 0000000000000000000000000000000000000000..89e542f750cd3860a0598eff0dc34b56d7336dc4 --- /dev/null +++ b/crates/language_core/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/language_core/src/code_label.rs b/crates/language_core/src/code_label.rs new file mode 100644 index 0000000000000000000000000000000000000000..0a98743d02b3861d248498893eef3972422d4758 --- /dev/null +++ b/crates/language_core/src/code_label.rs @@ -0,0 +1,122 @@ +use crate::highlight_map::HighlightId; +use std::ops::Range; + +#[derive(Debug, Clone)] +pub struct Symbol { + pub name: String, + pub kind: lsp::SymbolKind, + pub container_name: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct CodeLabel { + /// The text to display. + pub text: String, + /// Syntax highlighting runs. + pub runs: Vec<(Range, HighlightId)>, + /// The portion of the text that should be used in fuzzy filtering. + pub filter_range: Range, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct CodeLabelBuilder { + /// The text to display. + text: String, + /// Syntax highlighting runs. + runs: Vec<(Range, HighlightId)>, + /// The portion of the text that should be used in fuzzy filtering. + filter_range: Range, +} + +impl CodeLabel { + pub fn plain(text: String, filter_text: Option<&str>) -> Self { + Self::filtered(text.clone(), text.len(), filter_text, Vec::new()) + } + + pub fn filtered( + text: String, + label_len: usize, + filter_text: Option<&str>, + runs: Vec<(Range, HighlightId)>, + ) -> Self { + assert!(label_len <= text.len()); + let filter_range = filter_text + .and_then(|filter| text.find(filter).map(|index| index..index + filter.len())) + .unwrap_or(0..label_len); + Self::new(text, filter_range, runs) + } + + pub fn new( + text: String, + filter_range: Range, + runs: Vec<(Range, HighlightId)>, + ) -> Self { + assert!( + text.get(filter_range.clone()).is_some(), + "invalid filter range" + ); + runs.iter().for_each(|(range, _)| { + assert!( + text.get(range.clone()).is_some(), + "invalid run range with inputs. Requested range {range:?} in text '{text}'", + ); + }); + Self { + runs, + filter_range, + text, + } + } + + pub fn text(&self) -> &str { + self.text.as_str() + } + + pub fn filter_text(&self) -> &str { + &self.text[self.filter_range.clone()] + } +} + +impl From for CodeLabel { + fn from(value: String) -> Self { + Self::plain(value, None) + } +} + +impl From<&str> for CodeLabel { + fn from(value: &str) -> Self { + Self::plain(value.to_string(), None) + } +} + +impl CodeLabelBuilder { + pub fn respan_filter_range(&mut self, filter_text: Option<&str>) { + self.filter_range = filter_text + .and_then(|filter| { + self.text + .find(filter) + .map(|index| index..index + filter.len()) + }) + .unwrap_or(0..self.text.len()); + } + + pub fn push_str(&mut self, text: &str, highlight: Option) { + let start_index = self.text.len(); + self.text.push_str(text); + if let Some(highlight) = highlight { + let end_index = self.text.len(); + self.runs.push((start_index..end_index, highlight)); + } + } + + pub fn build(mut self) -> CodeLabel { + if self.filter_range.end == 0 { + self.respan_filter_range(None); + } + CodeLabel { + text: self.text, + runs: self.runs, + filter_range: self.filter_range, + } + } +} diff --git a/crates/language_core/src/diagnostic.rs b/crates/language_core/src/diagnostic.rs new file mode 100644 index 0000000000000000000000000000000000000000..9a468a14b863a94ef23e00c3e15edd9fa2d8b09a --- /dev/null +++ b/crates/language_core/src/diagnostic.rs @@ -0,0 +1,76 @@ +use gpui::SharedString; +use lsp::{DiagnosticSeverity, NumberOrString}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// A diagnostic associated with a certain range of a buffer. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Diagnostic { + /// The name of the service that produced this diagnostic. + pub source: Option, + /// The ID provided by the dynamic registration that produced this diagnostic. + pub registration_id: Option, + /// A machine-readable code that identifies this diagnostic. + pub code: Option, + pub code_description: Option, + /// Whether this diagnostic is a hint, warning, or error. + pub severity: DiagnosticSeverity, + /// The human-readable message associated with this diagnostic. + pub message: String, + /// The human-readable message (in markdown format) + pub markdown: Option, + /// An id that identifies the group to which this diagnostic belongs. + /// + /// When a language server produces a diagnostic with + /// one or more associated diagnostics, those diagnostics are all + /// assigned a single group ID. + pub group_id: usize, + /// Whether this diagnostic is the primary diagnostic for its group. + /// + /// In a given group, the primary diagnostic is the top-level diagnostic + /// returned by the language server. The non-primary diagnostics are the + /// associated diagnostics. + pub is_primary: bool, + /// Whether this diagnostic is considered to originate from an analysis of + /// files on disk, as opposed to any unsaved buffer contents. This is a + /// property of a given diagnostic source, and is configured for a given + /// language server via the `LspAdapter::disk_based_diagnostic_sources` method + /// for the language server. + pub is_disk_based: bool, + /// Whether this diagnostic marks unnecessary code. + pub is_unnecessary: bool, + /// Quick separation of diagnostics groups based by their source. + pub source_kind: DiagnosticSourceKind, + /// Data from language server that produced this diagnostic. Passed back to the LS when we request code actions for this diagnostic. + pub data: Option, + /// Whether to underline the corresponding text range in the editor. + pub underline: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum DiagnosticSourceKind { + Pulled, + Pushed, + Other, +} + +impl Default for Diagnostic { + fn default() -> Self { + Self { + source: Default::default(), + source_kind: DiagnosticSourceKind::Other, + code: None, + code_description: None, + severity: DiagnosticSeverity::ERROR, + message: Default::default(), + markdown: None, + group_id: 0, + is_primary: false, + is_disk_based: false, + is_unnecessary: false, + underline: true, + data: None, + registration_id: None, + } + } +} diff --git a/crates/language_core/src/grammar.rs b/crates/language_core/src/grammar.rs new file mode 100644 index 0000000000000000000000000000000000000000..77e3805e52415a20f5d343bff98682744a50fdc2 --- /dev/null +++ b/crates/language_core/src/grammar.rs @@ -0,0 +1,756 @@ +use crate::{ + HighlightId, HighlightMap, LanguageConfig, LanguageConfigOverride, LanguageName, + LanguageQueries, language_config::BracketPairConfig, +}; +use anyhow::{Context as _, Result}; +use collections::HashMap; +use gpui::SharedString; +use lsp::LanguageServerName; +use parking_lot::Mutex; +use std::sync::atomic::{AtomicUsize, Ordering::SeqCst}; +use tree_sitter::Query; + +pub static NEXT_GRAMMAR_ID: AtomicUsize = AtomicUsize::new(0); + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] +pub struct GrammarId(pub usize); + +impl GrammarId { + pub fn new() -> Self { + Self(NEXT_GRAMMAR_ID.fetch_add(1, SeqCst)) + } +} + +impl Default for GrammarId { + fn default() -> Self { + Self::new() + } +} + +pub struct Grammar { + id: GrammarId, + pub ts_language: tree_sitter::Language, + pub error_query: Option, + pub highlights_config: Option, + pub brackets_config: Option, + pub redactions_config: Option, + pub runnable_config: Option, + pub indents_config: Option, + pub outline_config: Option, + pub text_object_config: Option, + pub injection_config: Option, + pub override_config: Option, + pub debug_variables_config: Option, + pub highlight_map: Mutex, +} + +pub struct HighlightsConfig { + pub query: Query, + pub identifier_capture_indices: Vec, +} + +pub struct IndentConfig { + pub query: Query, + pub indent_capture_ix: u32, + pub start_capture_ix: Option, + pub end_capture_ix: Option, + pub outdent_capture_ix: Option, + pub suffixed_start_captures: HashMap, +} + +pub struct OutlineConfig { + pub query: Query, + pub item_capture_ix: u32, + pub name_capture_ix: u32, + pub context_capture_ix: Option, + pub extra_context_capture_ix: Option, + pub open_capture_ix: Option, + pub close_capture_ix: Option, + pub annotation_capture_ix: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum DebuggerTextObject { + Variable, + Scope, +} + +impl DebuggerTextObject { + pub fn from_capture_name(name: &str) -> Option { + match name { + "debug-variable" => Some(DebuggerTextObject::Variable), + "debug-scope" => Some(DebuggerTextObject::Scope), + _ => None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum TextObject { + InsideFunction, + AroundFunction, + InsideClass, + AroundClass, + InsideComment, + AroundComment, +} + +impl TextObject { + pub fn from_capture_name(name: &str) -> Option { + match name { + "function.inside" => Some(TextObject::InsideFunction), + "function.around" => Some(TextObject::AroundFunction), + "class.inside" => Some(TextObject::InsideClass), + "class.around" => Some(TextObject::AroundClass), + "comment.inside" => Some(TextObject::InsideComment), + "comment.around" => Some(TextObject::AroundComment), + _ => None, + } + } + + pub fn around(&self) -> Option { + match self { + TextObject::InsideFunction => Some(TextObject::AroundFunction), + TextObject::InsideClass => Some(TextObject::AroundClass), + TextObject::InsideComment => Some(TextObject::AroundComment), + _ => None, + } + } +} + +pub struct TextObjectConfig { + pub query: Query, + pub text_objects_by_capture_ix: Vec<(u32, TextObject)>, +} + +pub struct InjectionConfig { + pub query: Query, + pub content_capture_ix: u32, + pub language_capture_ix: Option, + pub patterns: Vec, +} + +pub struct RedactionConfig { + pub query: Query, + pub redaction_capture_ix: u32, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum RunnableCapture { + Named(SharedString), + Run, +} + +pub struct RunnableConfig { + pub query: Query, + /// A mapping from capture index to capture kind + pub extra_captures: Vec, +} + +pub struct OverrideConfig { + pub query: Query, + pub values: HashMap, +} + +#[derive(Debug)] +pub struct OverrideEntry { + pub name: String, + pub range_is_inclusive: bool, + pub value: LanguageConfigOverride, +} + +#[derive(Default, Clone)] +pub struct InjectionPatternConfig { + pub language: Option>, + pub combined: bool, +} + +#[derive(Debug)] +pub struct BracketsConfig { + pub query: Query, + pub open_capture_ix: u32, + pub close_capture_ix: u32, + pub patterns: Vec, +} + +#[derive(Clone, Debug, Default)] +pub struct BracketsPatternConfig { + pub newline_only: bool, + pub rainbow_exclude: bool, +} + +pub struct DebugVariablesConfig { + pub query: Query, + pub objects_by_capture_ix: Vec<(u32, DebuggerTextObject)>, +} + +enum Capture<'a> { + Required(&'static str, &'a mut u32), + Optional(&'static str, &'a mut Option), +} + +fn populate_capture_indices( + query: &Query, + language_name: &LanguageName, + query_type: &str, + expected_prefixes: &[&str], + captures: &mut [Capture<'_>], +) -> bool { + let mut found_required_indices = Vec::new(); + 'outer: for (ix, name) in query.capture_names().iter().enumerate() { + for (required_ix, capture) in captures.iter_mut().enumerate() { + match capture { + Capture::Required(capture_name, index) if capture_name == name => { + **index = ix as u32; + found_required_indices.push(required_ix); + continue 'outer; + } + Capture::Optional(capture_name, index) if capture_name == name => { + **index = Some(ix as u32); + continue 'outer; + } + _ => {} + } + } + if !name.starts_with("_") + && !expected_prefixes + .iter() + .any(|&prefix| name.starts_with(prefix)) + { + log::warn!( + "unrecognized capture name '{}' in {} {} TreeSitter query \ + (suppress this warning by prefixing with '_')", + name, + language_name, + query_type + ); + } + } + let mut missing_required_captures = Vec::new(); + for (capture_ix, capture) in captures.iter().enumerate() { + if let Capture::Required(capture_name, _) = capture + && !found_required_indices.contains(&capture_ix) + { + missing_required_captures.push(*capture_name); + } + } + let success = missing_required_captures.is_empty(); + if !success { + log::error!( + "missing required capture(s) in {} {} TreeSitter query: {}", + language_name, + query_type, + missing_required_captures.join(", ") + ); + } + success +} + +impl Grammar { + pub fn new(ts_language: tree_sitter::Language) -> Self { + Self { + id: GrammarId::new(), + highlights_config: None, + brackets_config: None, + outline_config: None, + text_object_config: None, + indents_config: None, + injection_config: None, + override_config: None, + redactions_config: None, + runnable_config: None, + error_query: Query::new(&ts_language, "(ERROR) @error").ok(), + debug_variables_config: None, + ts_language, + highlight_map: Default::default(), + } + } + + pub fn id(&self) -> GrammarId { + self.id + } + + pub fn highlight_map(&self) -> HighlightMap { + self.highlight_map.lock().clone() + } + + pub fn highlight_id_for_name(&self, name: &str) -> Option { + let capture_id = self + .highlights_config + .as_ref()? + .query + .capture_index_for_name(name)?; + Some(self.highlight_map.lock().get(capture_id)) + } + + pub fn debug_variables_config(&self) -> Option<&DebugVariablesConfig> { + self.debug_variables_config.as_ref() + } + + /// Load all queries from `LanguageQueries` into this grammar, mutating the + /// associated `LanguageConfig` (the override query clears + /// `brackets.disabled_scopes_by_bracket_ix`). + pub fn with_queries( + mut self, + queries: LanguageQueries, + config: &mut LanguageConfig, + ) -> Result { + let name = &config.name; + if let Some(query) = queries.highlights { + self = self + .with_highlights_query(query.as_ref()) + .context("Error loading highlights query")?; + } + if let Some(query) = queries.brackets { + self = self + .with_brackets_query(query.as_ref(), name) + .context("Error loading brackets query")?; + } + if let Some(query) = queries.indents { + self = self + .with_indents_query(query.as_ref(), name) + .context("Error loading indents query")?; + } + if let Some(query) = queries.outline { + self = self + .with_outline_query(query.as_ref(), name) + .context("Error loading outline query")?; + } + if let Some(query) = queries.injections { + self = self + .with_injection_query(query.as_ref(), name) + .context("Error loading injection query")?; + } + if let Some(query) = queries.overrides { + self = self + .with_override_query( + query.as_ref(), + name, + &config.overrides, + &mut config.brackets, + &config.scope_opt_in_language_servers, + ) + .context("Error loading override query")?; + } + if let Some(query) = queries.redactions { + self = self + .with_redaction_query(query.as_ref(), name) + .context("Error loading redaction query")?; + } + if let Some(query) = queries.runnables { + self = self + .with_runnable_query(query.as_ref()) + .context("Error loading runnables query")?; + } + if let Some(query) = queries.text_objects { + self = self + .with_text_object_query(query.as_ref(), name) + .context("Error loading textobject query")?; + } + if let Some(query) = queries.debugger { + self = self + .with_debug_variables_query(query.as_ref(), name) + .context("Error loading debug variables query")?; + } + Ok(self) + } + + pub fn with_highlights_query(mut self, source: &str) -> Result { + let query = Query::new(&self.ts_language, source)?; + + let mut identifier_capture_indices = Vec::new(); + for name in [ + "variable", + "constant", + "constructor", + "function", + "function.method", + "function.method.call", + "function.special", + "property", + "type", + "type.interface", + ] { + identifier_capture_indices.extend(query.capture_index_for_name(name)); + } + + self.highlights_config = Some(HighlightsConfig { + query, + identifier_capture_indices, + }); + + Ok(self) + } + + pub fn with_runnable_query(mut self, source: &str) -> Result { + let query = Query::new(&self.ts_language, source)?; + let extra_captures: Vec<_> = query + .capture_names() + .iter() + .map(|&name| match name { + "run" => RunnableCapture::Run, + name => RunnableCapture::Named(name.to_string().into()), + }) + .collect(); + + self.runnable_config = Some(RunnableConfig { + extra_captures, + query, + }); + + Ok(self) + } + + pub fn with_outline_query( + mut self, + source: &str, + language_name: &LanguageName, + ) -> Result { + let query = Query::new(&self.ts_language, source)?; + let mut item_capture_ix = 0; + let mut name_capture_ix = 0; + let mut context_capture_ix = None; + let mut extra_context_capture_ix = None; + let mut open_capture_ix = None; + let mut close_capture_ix = None; + let mut annotation_capture_ix = None; + if populate_capture_indices( + &query, + language_name, + "outline", + &[], + &mut [ + Capture::Required("item", &mut item_capture_ix), + Capture::Required("name", &mut name_capture_ix), + Capture::Optional("context", &mut context_capture_ix), + Capture::Optional("context.extra", &mut extra_context_capture_ix), + Capture::Optional("open", &mut open_capture_ix), + Capture::Optional("close", &mut close_capture_ix), + Capture::Optional("annotation", &mut annotation_capture_ix), + ], + ) { + self.outline_config = Some(OutlineConfig { + query, + item_capture_ix, + name_capture_ix, + context_capture_ix, + extra_context_capture_ix, + open_capture_ix, + close_capture_ix, + annotation_capture_ix, + }); + } + Ok(self) + } + + pub fn with_text_object_query( + mut self, + source: &str, + language_name: &LanguageName, + ) -> Result { + let query = Query::new(&self.ts_language, source)?; + + let mut text_objects_by_capture_ix = Vec::new(); + for (ix, name) in query.capture_names().iter().enumerate() { + if let Some(text_object) = TextObject::from_capture_name(name) { + text_objects_by_capture_ix.push((ix as u32, text_object)); + } else { + log::warn!( + "unrecognized capture name '{}' in {} textobjects TreeSitter query", + name, + language_name, + ); + } + } + + self.text_object_config = Some(TextObjectConfig { + query, + text_objects_by_capture_ix, + }); + Ok(self) + } + + pub fn with_debug_variables_query( + mut self, + source: &str, + language_name: &LanguageName, + ) -> Result { + let query = Query::new(&self.ts_language, source)?; + + let mut objects_by_capture_ix = Vec::new(); + for (ix, name) in query.capture_names().iter().enumerate() { + if let Some(text_object) = DebuggerTextObject::from_capture_name(name) { + objects_by_capture_ix.push((ix as u32, text_object)); + } else { + log::warn!( + "unrecognized capture name '{}' in {} debugger TreeSitter query", + name, + language_name, + ); + } + } + + self.debug_variables_config = Some(DebugVariablesConfig { + query, + objects_by_capture_ix, + }); + Ok(self) + } + + pub fn with_brackets_query( + mut self, + source: &str, + language_name: &LanguageName, + ) -> Result { + let query = Query::new(&self.ts_language, source)?; + let mut open_capture_ix = 0; + let mut close_capture_ix = 0; + if populate_capture_indices( + &query, + language_name, + "brackets", + &[], + &mut [ + Capture::Required("open", &mut open_capture_ix), + Capture::Required("close", &mut close_capture_ix), + ], + ) { + let patterns = (0..query.pattern_count()) + .map(|ix| { + let mut config = BracketsPatternConfig::default(); + for setting in query.property_settings(ix) { + let setting_key = setting.key.as_ref(); + if setting_key == "newline.only" { + config.newline_only = true + } + if setting_key == "rainbow.exclude" { + config.rainbow_exclude = true + } + } + config + }) + .collect(); + self.brackets_config = Some(BracketsConfig { + query, + open_capture_ix, + close_capture_ix, + patterns, + }); + } + Ok(self) + } + + pub fn with_indents_query( + mut self, + source: &str, + language_name: &LanguageName, + ) -> Result { + let query = Query::new(&self.ts_language, source)?; + let mut indent_capture_ix = 0; + let mut start_capture_ix = None; + let mut end_capture_ix = None; + let mut outdent_capture_ix = None; + if populate_capture_indices( + &query, + language_name, + "indents", + &["start."], + &mut [ + Capture::Required("indent", &mut indent_capture_ix), + Capture::Optional("start", &mut start_capture_ix), + Capture::Optional("end", &mut end_capture_ix), + Capture::Optional("outdent", &mut outdent_capture_ix), + ], + ) { + let mut suffixed_start_captures = HashMap::default(); + for (ix, name) in query.capture_names().iter().enumerate() { + if let Some(suffix) = name.strip_prefix("start.") { + suffixed_start_captures.insert(ix as u32, suffix.to_owned().into()); + } + } + + self.indents_config = Some(IndentConfig { + query, + indent_capture_ix, + start_capture_ix, + end_capture_ix, + outdent_capture_ix, + suffixed_start_captures, + }); + } + Ok(self) + } + + pub fn with_injection_query( + mut self, + source: &str, + language_name: &LanguageName, + ) -> Result { + let query = Query::new(&self.ts_language, source)?; + let mut language_capture_ix = None; + let mut injection_language_capture_ix = None; + let mut content_capture_ix = None; + let mut injection_content_capture_ix = None; + if populate_capture_indices( + &query, + language_name, + "injections", + &[], + &mut [ + Capture::Optional("language", &mut language_capture_ix), + Capture::Optional("injection.language", &mut injection_language_capture_ix), + Capture::Optional("content", &mut content_capture_ix), + Capture::Optional("injection.content", &mut injection_content_capture_ix), + ], + ) { + language_capture_ix = match (language_capture_ix, injection_language_capture_ix) { + (None, Some(ix)) => Some(ix), + (Some(_), Some(_)) => { + anyhow::bail!("both language and injection.language captures are present"); + } + _ => language_capture_ix, + }; + content_capture_ix = match (content_capture_ix, injection_content_capture_ix) { + (None, Some(ix)) => Some(ix), + (Some(_), Some(_)) => { + anyhow::bail!("both content and injection.content captures are present") + } + _ => content_capture_ix, + }; + let patterns = (0..query.pattern_count()) + .map(|ix| { + let mut config = InjectionPatternConfig::default(); + for setting in query.property_settings(ix) { + match setting.key.as_ref() { + "language" | "injection.language" => { + config.language.clone_from(&setting.value); + } + "combined" | "injection.combined" => { + config.combined = true; + } + _ => {} + } + } + config + }) + .collect(); + if let Some(content_capture_ix) = content_capture_ix { + self.injection_config = Some(InjectionConfig { + query, + language_capture_ix, + content_capture_ix, + patterns, + }); + } else { + log::error!( + "missing required capture in injections {} TreeSitter query: \ + content or injection.content", + language_name, + ); + } + } + Ok(self) + } + + pub fn with_override_query( + mut self, + source: &str, + language_name: &LanguageName, + overrides: &HashMap, + brackets: &mut BracketPairConfig, + scope_opt_in_language_servers: &[LanguageServerName], + ) -> Result { + let query = Query::new(&self.ts_language, source)?; + + let mut override_configs_by_id = HashMap::default(); + for (ix, mut name) in query.capture_names().iter().copied().enumerate() { + let mut range_is_inclusive = false; + if name.starts_with('_') { + continue; + } + if let Some(prefix) = name.strip_suffix(".inclusive") { + name = prefix; + range_is_inclusive = true; + } + + let value = overrides.get(name).cloned().unwrap_or_default(); + for server_name in &value.opt_into_language_servers { + if !scope_opt_in_language_servers.contains(server_name) { + util::debug_panic!( + "Server {server_name:?} has been opted-in by scope {name:?} but has not been marked as an opt-in server" + ); + } + } + + override_configs_by_id.insert( + ix as u32, + OverrideEntry { + name: name.to_string(), + range_is_inclusive, + value, + }, + ); + } + + let referenced_override_names = overrides + .keys() + .chain(brackets.disabled_scopes_by_bracket_ix.iter().flatten()); + + for referenced_name in referenced_override_names { + if !override_configs_by_id + .values() + .any(|entry| entry.name == *referenced_name) + { + anyhow::bail!( + "language {:?} has overrides in config not in query: {referenced_name:?}", + language_name + ); + } + } + + for entry in override_configs_by_id.values_mut() { + entry.value.disabled_bracket_ixs = brackets + .disabled_scopes_by_bracket_ix + .iter() + .enumerate() + .filter_map(|(ix, disabled_scope_names)| { + if disabled_scope_names.contains(&entry.name) { + Some(ix as u16) + } else { + None + } + }) + .collect(); + } + + brackets.disabled_scopes_by_bracket_ix.clear(); + + self.override_config = Some(OverrideConfig { + query, + values: override_configs_by_id, + }); + Ok(self) + } + + pub fn with_redaction_query( + mut self, + source: &str, + language_name: &LanguageName, + ) -> Result { + let query = Query::new(&self.ts_language, source)?; + let mut redaction_capture_ix = 0; + if populate_capture_indices( + &query, + language_name, + "redactions", + &[], + &mut [Capture::Required("redact", &mut redaction_capture_ix)], + ) { + self.redactions_config = Some(RedactionConfig { + query, + redaction_capture_ix, + }); + } + Ok(self) + } +} diff --git a/crates/language_core/src/highlight_map.rs b/crates/language_core/src/highlight_map.rs new file mode 100644 index 0000000000000000000000000000000000000000..1235c7d62c72950f57de0cdad1363f49d8fbbd96 --- /dev/null +++ b/crates/language_core/src/highlight_map.rs @@ -0,0 +1,52 @@ +use std::sync::Arc; + +#[derive(Clone, Debug)] +pub struct HighlightMap(Arc<[HighlightId]>); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct HighlightId(pub u32); + +const DEFAULT_SYNTAX_HIGHLIGHT_ID: HighlightId = HighlightId(u32::MAX); + +impl HighlightMap { + #[inline] + pub fn from_ids(highlight_ids: impl IntoIterator) -> Self { + Self(highlight_ids.into_iter().collect()) + } + + #[inline] + pub fn get(&self, capture_id: u32) -> HighlightId { + self.0 + .get(capture_id as usize) + .copied() + .unwrap_or(DEFAULT_SYNTAX_HIGHLIGHT_ID) + } +} + +impl HighlightId { + pub const TABSTOP_INSERT_ID: HighlightId = HighlightId(u32::MAX - 1); + pub const TABSTOP_REPLACE_ID: HighlightId = HighlightId(u32::MAX - 2); + + #[inline] + pub fn is_default(&self) -> bool { + *self == DEFAULT_SYNTAX_HIGHLIGHT_ID + } +} + +impl Default for HighlightMap { + fn default() -> Self { + Self(Arc::new([])) + } +} + +impl Default for HighlightId { + fn default() -> Self { + DEFAULT_SYNTAX_HIGHLIGHT_ID + } +} + +impl From for usize { + fn from(value: HighlightId) -> Self { + value.0 as usize + } +} diff --git a/crates/language_core/src/language_config.rs b/crates/language_core/src/language_config.rs new file mode 100644 index 0000000000000000000000000000000000000000..f412af418b7948b40e3bdac5a3a649d12d008e8a --- /dev/null +++ b/crates/language_core/src/language_config.rs @@ -0,0 +1,528 @@ +use crate::LanguageName; +use collections::{HashMap, HashSet, IndexSet}; +use gpui::SharedString; +use lsp::LanguageServerName; +use regex::Regex; +use schemars::{JsonSchema, SchemaGenerator, json_schema}; +use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; +use std::{num::NonZeroU32, path::Path, sync::Arc}; +use util::serde::default_true; + +/// Controls the soft-wrapping behavior in the editor. +#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SoftWrap { + /// Prefer a single line generally, unless an overly long line is encountered. + None, + /// Deprecated: use None instead. Left to avoid breaking existing users' configs. + /// Prefer a single line generally, unless an overly long line is encountered. + PreferLine, + /// Soft wrap lines that exceed the editor width. + EditorWidth, + /// Soft wrap lines at the preferred line length. + PreferredLineLength, + /// Soft wrap line at the preferred line length or the editor width (whichever is smaller). + Bounded, +} + +/// Top-level configuration for a language, typically loaded from a `config.toml` +/// shipped alongside the grammar. +#[derive(Clone, Debug, Deserialize, JsonSchema)] +pub struct LanguageConfig { + /// Human-readable name of the language. + pub name: LanguageName, + /// The name of this language for a Markdown code fence block + pub code_fence_block_name: Option>, + /// Alternative language names that Jupyter kernels may report for this language. + /// Used when a kernel's `language` field differs from Zed's language name. + /// For example, the Nu extension would set this to `["nushell"]`. + #[serde(default)] + pub kernel_language_names: Vec>, + // The name of the grammar in a WASM bundle (experimental). + pub grammar: Option>, + /// The criteria for matching this language to a given file. + #[serde(flatten)] + pub matcher: LanguageMatcher, + /// List of bracket types in a language. + #[serde(default)] + pub brackets: BracketPairConfig, + /// If set to true, auto indentation uses last non empty line to determine + /// the indentation level for a new line. + #[serde(default = "auto_indent_using_last_non_empty_line_default")] + pub auto_indent_using_last_non_empty_line: bool, + // Whether indentation of pasted content should be adjusted based on the context. + #[serde(default)] + pub auto_indent_on_paste: Option, + /// A regex that is used to determine whether the indentation level should be + /// increased in the following line. + #[serde(default, deserialize_with = "deserialize_regex")] + #[schemars(schema_with = "regex_json_schema")] + pub increase_indent_pattern: Option, + /// A regex that is used to determine whether the indentation level should be + /// decreased in the following line. + #[serde(default, deserialize_with = "deserialize_regex")] + #[schemars(schema_with = "regex_json_schema")] + pub decrease_indent_pattern: Option, + /// A list of rules for decreasing indentation. Each rule pairs a regex with a set of valid + /// "block-starting" tokens. When a line matches a pattern, its indentation is aligned with + /// the most recent line that began with a corresponding token. This enables context-aware + /// outdenting, like aligning an `else` with its `if`. + #[serde(default)] + pub decrease_indent_patterns: Vec, + /// A list of characters that trigger the automatic insertion of a closing + /// bracket when they immediately precede the point where an opening + /// bracket is inserted. + #[serde(default)] + pub autoclose_before: String, + /// A placeholder used internally by Semantic Index. + #[serde(default)] + pub collapsed_placeholder: String, + /// A line comment string that is inserted in e.g. `toggle comments` action. + /// A language can have multiple flavours of line comments. All of the provided line comments are + /// used for comment continuations on the next line, but only the first one is used for Editor::ToggleComments. + #[serde(default)] + pub line_comments: Vec>, + /// Delimiters and configuration for recognizing and formatting block comments. + #[serde(default)] + pub block_comment: Option, + /// Delimiters and configuration for recognizing and formatting documentation comments. + #[serde(default, alias = "documentation")] + pub documentation_comment: Option, + /// List markers that are inserted unchanged on newline (e.g., `- `, `* `, `+ `). + #[serde(default)] + pub unordered_list: Vec>, + /// Configuration for ordered lists with auto-incrementing numbers on newline (e.g., `1. ` becomes `2. `). + #[serde(default)] + pub ordered_list: Vec, + /// Configuration for task lists where multiple markers map to a single continuation prefix (e.g., `- [x] ` continues as `- [ ] `). + #[serde(default)] + pub task_list: Option, + /// A list of additional regex patterns that should be treated as prefixes + /// for creating boundaries during rewrapping, ensuring content from one + /// prefixed section doesn't merge with another (e.g., markdown list items). + /// By default, Zed treats as paragraph and comment prefixes as boundaries. + #[serde(default, deserialize_with = "deserialize_regex_vec")] + #[schemars(schema_with = "regex_vec_json_schema")] + pub rewrap_prefixes: Vec, + /// A list of language servers that are allowed to run on subranges of a given language. + #[serde(default)] + pub scope_opt_in_language_servers: Vec, + #[serde(default)] + pub overrides: HashMap, + /// A list of characters that Zed should treat as word characters for the + /// purpose of features that operate on word boundaries, like 'move to next word end' + /// or a whole-word search in buffer search. + #[serde(default)] + pub word_characters: HashSet, + /// Whether to indent lines using tab characters, as opposed to multiple + /// spaces. + #[serde(default)] + pub hard_tabs: Option, + /// How many columns a tab should occupy. + #[serde(default)] + #[schemars(range(min = 1, max = 128))] + pub tab_size: Option, + /// How to soft-wrap long lines of text. + #[serde(default)] + pub soft_wrap: Option, + /// When set, selections can be wrapped using prefix/suffix pairs on both sides. + #[serde(default)] + pub wrap_characters: Option, + /// The name of a Prettier parser that will be used for this language when no file path is available. + /// If there's a parser name in the language settings, that will be used instead. + #[serde(default)] + pub prettier_parser_name: Option, + /// If true, this language is only for syntax highlighting via an injection into other + /// languages, but should not appear to the user as a distinct language. + #[serde(default)] + pub hidden: bool, + /// If configured, this language contains JSX style tags, and should support auto-closing of those tags. + #[serde(default)] + pub jsx_tag_auto_close: Option, + /// A list of characters that Zed should treat as word characters for completion queries. + #[serde(default)] + pub completion_query_characters: HashSet, + /// A list of characters that Zed should treat as word characters for linked edit operations. + #[serde(default)] + pub linked_edit_characters: HashSet, + /// A list of preferred debuggers for this language. + #[serde(default)] + pub debuggers: IndexSet, +} + +impl LanguageConfig { + pub const FILE_NAME: &str = "config.toml"; + + pub fn load(config_path: impl AsRef) -> anyhow::Result { + let config = std::fs::read_to_string(config_path.as_ref())?; + toml::from_str(&config).map_err(Into::into) + } +} + +impl Default for LanguageConfig { + fn default() -> Self { + Self { + name: LanguageName::new_static(""), + code_fence_block_name: None, + kernel_language_names: Default::default(), + grammar: None, + matcher: LanguageMatcher::default(), + brackets: Default::default(), + auto_indent_using_last_non_empty_line: auto_indent_using_last_non_empty_line_default(), + auto_indent_on_paste: None, + increase_indent_pattern: Default::default(), + decrease_indent_pattern: Default::default(), + decrease_indent_patterns: Default::default(), + autoclose_before: Default::default(), + line_comments: Default::default(), + block_comment: Default::default(), + documentation_comment: Default::default(), + unordered_list: Default::default(), + ordered_list: Default::default(), + task_list: Default::default(), + rewrap_prefixes: Default::default(), + scope_opt_in_language_servers: Default::default(), + overrides: Default::default(), + word_characters: Default::default(), + collapsed_placeholder: Default::default(), + hard_tabs: None, + tab_size: None, + soft_wrap: None, + wrap_characters: None, + prettier_parser_name: None, + hidden: false, + jsx_tag_auto_close: None, + completion_query_characters: Default::default(), + linked_edit_characters: Default::default(), + debuggers: Default::default(), + } + } +} + +#[derive(Clone, Debug, Deserialize, Default, JsonSchema)] +pub struct DecreaseIndentConfig { + #[serde(default, deserialize_with = "deserialize_regex")] + #[schemars(schema_with = "regex_json_schema")] + pub pattern: Option, + #[serde(default)] + pub valid_after: Vec, +} + +/// Configuration for continuing ordered lists with auto-incrementing numbers. +#[derive(Clone, Debug, Deserialize, JsonSchema)] +pub struct OrderedListConfig { + /// A regex pattern with a capture group for the number portion (e.g., `(\\d+)\\. `). + pub pattern: String, + /// A format string where `{1}` is replaced with the incremented number (e.g., `{1}. `). + pub format: String, +} + +/// Configuration for continuing task lists on newline. +#[derive(Clone, Debug, Deserialize, JsonSchema)] +pub struct TaskListConfig { + /// The list markers to match (e.g., `- [ ] `, `- [x] `). + pub prefixes: Vec>, + /// The marker to insert when continuing the list on a new line (e.g., `- [ ] `). + pub continuation: Arc, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Default, JsonSchema)] +pub struct LanguageMatcher { + /// Given a list of `LanguageConfig`'s, the language of a file can be determined based on the path extension matching any of the `path_suffixes`. + #[serde(default)] + pub path_suffixes: Vec, + /// A regex pattern that determines whether the language should be assigned to a file or not. + #[serde( + default, + serialize_with = "serialize_regex", + deserialize_with = "deserialize_regex" + )] + #[schemars(schema_with = "regex_json_schema")] + pub first_line_pattern: Option, + /// Alternative names for this language used in vim/emacs modelines. + /// These are matched case-insensitively against the `mode` (emacs) or + /// `filetype`/`ft` (vim) specified in the modeline. + #[serde(default)] + pub modeline_aliases: Vec, +} + +impl Ord for LanguageMatcher { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.path_suffixes + .cmp(&other.path_suffixes) + .then_with(|| { + self.first_line_pattern + .as_ref() + .map(Regex::as_str) + .cmp(&other.first_line_pattern.as_ref().map(Regex::as_str)) + }) + .then_with(|| self.modeline_aliases.cmp(&other.modeline_aliases)) + } +} + +impl PartialOrd for LanguageMatcher { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Eq for LanguageMatcher {} + +impl PartialEq for LanguageMatcher { + fn eq(&self, other: &Self) -> bool { + self.path_suffixes == other.path_suffixes + && self.first_line_pattern.as_ref().map(Regex::as_str) + == other.first_line_pattern.as_ref().map(Regex::as_str) + && self.modeline_aliases == other.modeline_aliases + } +} + +/// The configuration for JSX tag auto-closing. +#[derive(Clone, Deserialize, JsonSchema, Debug)] +pub struct JsxTagAutoCloseConfig { + /// The name of the node for a opening tag + pub open_tag_node_name: String, + /// The name of the node for an closing tag + pub close_tag_node_name: String, + /// The name of the node for a complete element with children for open and close tags + pub jsx_element_node_name: String, + /// The name of the node found within both opening and closing + /// tags that describes the tag name + pub tag_name_node_name: String, + /// Alternate Node names for tag names. + /// Specifically needed as TSX represents the name in `` + /// as `member_expression` rather than `identifier` as usual + #[serde(default)] + pub tag_name_node_name_alternates: Vec, + /// Some grammars are smart enough to detect a closing tag + /// that is not valid i.e. doesn't match it's corresponding + /// opening tag or does not have a corresponding opening tag + /// This should be set to the name of the node for invalid + /// closing tags if the grammar contains such a node, otherwise + /// detecting already closed tags will not work properly + #[serde(default)] + pub erroneous_close_tag_node_name: Option, + /// See above for erroneous_close_tag_node_name for details + /// This should be set if the node used for the tag name + /// within erroneous closing tags is different from the + /// normal tag name node name + #[serde(default)] + pub erroneous_close_tag_name_node_name: Option, +} + +/// The configuration for block comments for this language. +#[derive(Clone, Debug, JsonSchema, PartialEq)] +pub struct BlockCommentConfig { + /// A start tag of block comment. + pub start: Arc, + /// A end tag of block comment. + pub end: Arc, + /// A character to add as a prefix when a new line is added to a block comment. + pub prefix: Arc, + /// A indent to add for prefix and end line upon new line. + #[schemars(range(min = 1, max = 128))] + pub tab_size: u32, +} + +impl<'de> Deserialize<'de> for BlockCommentConfig { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum BlockCommentConfigHelper { + New { + start: Arc, + end: Arc, + prefix: Arc, + tab_size: u32, + }, + Old([Arc; 2]), + } + + match BlockCommentConfigHelper::deserialize(deserializer)? { + BlockCommentConfigHelper::New { + start, + end, + prefix, + tab_size, + } => Ok(BlockCommentConfig { + start, + end, + prefix, + tab_size, + }), + BlockCommentConfigHelper::Old([start, end]) => Ok(BlockCommentConfig { + start, + end, + prefix: "".into(), + tab_size: 0, + }), + } + } +} + +#[derive(Clone, Deserialize, Default, Debug, JsonSchema)] +pub struct LanguageConfigOverride { + #[serde(default)] + pub line_comments: Override>>, + #[serde(default)] + pub block_comment: Override, + #[serde(skip)] + pub disabled_bracket_ixs: Vec, + #[serde(default)] + pub word_characters: Override>, + #[serde(default)] + pub completion_query_characters: Override>, + #[serde(default)] + pub linked_edit_characters: Override>, + #[serde(default)] + pub opt_into_language_servers: Vec, + #[serde(default)] + pub prefer_label_for_snippet: Option, +} + +#[derive(Clone, Deserialize, Debug, Serialize, JsonSchema)] +#[serde(untagged)] +pub enum Override { + Remove { remove: bool }, + Set(T), +} + +impl Default for Override { + fn default() -> Self { + Override::Remove { remove: false } + } +} + +impl Override { + pub fn as_option<'a>(this: Option<&'a Self>, original: Option<&'a T>) -> Option<&'a T> { + match this { + Some(Self::Set(value)) => Some(value), + Some(Self::Remove { remove: true }) => None, + Some(Self::Remove { remove: false }) | None => original, + } + } +} + +/// Configuration of handling bracket pairs for a given language. +/// +/// This struct includes settings for defining which pairs of characters are considered brackets and +/// also specifies any language-specific scopes where these pairs should be ignored for bracket matching purposes. +#[derive(Clone, Debug, Default, JsonSchema)] +#[schemars(with = "Vec::")] +pub struct BracketPairConfig { + /// A list of character pairs that should be treated as brackets in the context of a given language. + pub pairs: Vec, + /// A list of tree-sitter scopes for which a given bracket should not be active. + /// N-th entry in `[Self::disabled_scopes_by_bracket_ix]` contains a list of disabled scopes for an n-th entry in `[Self::pairs]` + pub disabled_scopes_by_bracket_ix: Vec>, +} + +impl BracketPairConfig { + pub fn is_closing_brace(&self, c: char) -> bool { + self.pairs.iter().any(|pair| pair.end.starts_with(c)) + } +} + +#[derive(Deserialize, JsonSchema)] +pub struct BracketPairContent { + #[serde(flatten)] + pub bracket_pair: BracketPair, + #[serde(default)] + pub not_in: Vec, +} + +impl<'de> Deserialize<'de> for BracketPairConfig { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + let result = Vec::::deserialize(deserializer)?; + let (brackets, disabled_scopes_by_bracket_ix) = result + .into_iter() + .map(|entry| (entry.bracket_pair, entry.not_in)) + .unzip(); + + Ok(BracketPairConfig { + pairs: brackets, + disabled_scopes_by_bracket_ix, + }) + } +} + +/// Describes a single bracket pair and how an editor should react to e.g. inserting +/// an opening bracket or to a newline character insertion in between `start` and `end` characters. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, JsonSchema)] +pub struct BracketPair { + /// Starting substring for a bracket. + pub start: String, + /// Ending substring for a bracket. + pub end: String, + /// True if `end` should be automatically inserted right after `start` characters. + pub close: bool, + /// True if selected text should be surrounded by `start` and `end` characters. + #[serde(default = "default_true")] + pub surround: bool, + /// True if an extra newline should be inserted while the cursor is in the middle + /// of that bracket pair. + pub newline: bool, +} + +#[derive(Clone, Debug, Deserialize, JsonSchema)] +pub struct WrapCharactersConfig { + /// Opening token split into a prefix and suffix. The first caret goes + /// after the prefix (i.e., between prefix and suffix). + pub start_prefix: String, + pub start_suffix: String, + /// Closing token split into a prefix and suffix. The second caret goes + /// after the prefix (i.e., between prefix and suffix). + pub end_prefix: String, + pub end_suffix: String, +} + +pub fn auto_indent_using_last_non_empty_line_default() -> bool { + true +} + +pub fn deserialize_regex<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + let source = Option::::deserialize(d)?; + if let Some(source) = source { + Ok(Some(regex::Regex::new(&source).map_err(de::Error::custom)?)) + } else { + Ok(None) + } +} + +pub fn regex_json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { + json_schema!({ + "type": "string" + }) +} + +pub fn serialize_regex(regex: &Option, serializer: S) -> Result +where + S: Serializer, +{ + match regex { + Some(regex) => serializer.serialize_str(regex.as_str()), + None => serializer.serialize_none(), + } +} + +pub fn deserialize_regex_vec<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + let sources = Vec::::deserialize(d)?; + sources + .into_iter() + .map(|source| regex::Regex::new(&source)) + .collect::>() + .map_err(de::Error::custom) +} + +pub fn regex_vec_json_schema(_: &mut SchemaGenerator) -> schemars::Schema { + json_schema!({ + "type": "array", + "items": { "type": "string" } + }) +} diff --git a/crates/language_core/src/language_core.rs b/crates/language_core/src/language_core.rs new file mode 100644 index 0000000000000000000000000000000000000000..f3292e1978d976ce638ebe26c079b939648ffe52 --- /dev/null +++ b/crates/language_core/src/language_core.rs @@ -0,0 +1,39 @@ +// language_core: tree-sitter grammar infrastructure, LSP adapter traits, +// language configuration, and highlight mapping. + +pub mod diagnostic; +pub mod grammar; +pub mod highlight_map; +pub mod language_config; + +pub use diagnostic::{Diagnostic, DiagnosticSourceKind}; +pub use grammar::{ + BracketsConfig, BracketsPatternConfig, DebugVariablesConfig, DebuggerTextObject, Grammar, + GrammarId, HighlightsConfig, IndentConfig, InjectionConfig, InjectionPatternConfig, + NEXT_GRAMMAR_ID, OutlineConfig, OverrideConfig, OverrideEntry, RedactionConfig, + RunnableCapture, RunnableConfig, TextObject, TextObjectConfig, +}; +pub use highlight_map::{HighlightId, HighlightMap}; +pub use language_config::{ + BlockCommentConfig, BracketPair, BracketPairConfig, BracketPairContent, DecreaseIndentConfig, + JsxTagAutoCloseConfig, LanguageConfig, LanguageConfigOverride, LanguageMatcher, + OrderedListConfig, Override, SoftWrap, TaskListConfig, WrapCharactersConfig, + auto_indent_using_last_non_empty_line_default, deserialize_regex, deserialize_regex_vec, + regex_json_schema, regex_vec_json_schema, serialize_regex, +}; + +pub mod code_label; +pub mod language_name; +pub mod lsp_adapter; +pub mod manifest; +pub mod queries; +pub mod toolchain; + +pub use code_label::{CodeLabel, CodeLabelBuilder, Symbol}; +pub use language_name::{LanguageId, LanguageName}; +pub use lsp_adapter::{ + BinaryStatus, LanguageServerStatusUpdate, PromptResponseContext, ServerHealth, ToLspPosition, +}; +pub use manifest::ManifestName; +pub use queries::{LanguageQueries, QUERY_FILENAME_PREFIXES}; +pub use toolchain::{Toolchain, ToolchainList, ToolchainMetadata, ToolchainScope}; diff --git a/crates/language_core/src/language_name.rs b/crates/language_core/src/language_name.rs new file mode 100644 index 0000000000000000000000000000000000000000..764b54a48a566ad98212de3e22bce6aca9a1e393 --- /dev/null +++ b/crates/language_core/src/language_name.rs @@ -0,0 +1,109 @@ +use gpui::SharedString; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::{ + borrow::Borrow, + sync::atomic::{AtomicUsize, Ordering::SeqCst}, +}; + +static NEXT_LANGUAGE_ID: AtomicUsize = AtomicUsize::new(0); + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] +pub struct LanguageId(usize); + +impl LanguageId { + pub fn new() -> Self { + Self(NEXT_LANGUAGE_ID.fetch_add(1, SeqCst)) + } +} + +impl Default for LanguageId { + fn default() -> Self { + Self::new() + } +} + +#[derive( + Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, +)] +pub struct LanguageName(pub SharedString); + +impl LanguageName { + pub fn new(s: &str) -> Self { + Self(SharedString::new(s)) + } + + pub fn new_static(s: &'static str) -> Self { + Self(SharedString::new_static(s)) + } + + pub fn from_proto(s: String) -> Self { + Self(SharedString::from(s)) + } + + pub fn to_proto(&self) -> String { + self.0.to_string() + } + + pub fn lsp_id(&self) -> String { + match self.0.as_ref() { + "Plain Text" => "plaintext".to_string(), + language_name => language_name.to_lowercase(), + } + } +} + +impl From for SharedString { + fn from(value: LanguageName) -> Self { + value.0 + } +} + +impl From for LanguageName { + fn from(value: SharedString) -> Self { + LanguageName(value) + } +} + +impl AsRef for LanguageName { + fn as_ref(&self) -> &str { + self.0.as_ref() + } +} + +impl Borrow for LanguageName { + fn borrow(&self) -> &str { + self.0.as_ref() + } +} + +impl PartialEq for LanguageName { + fn eq(&self, other: &str) -> bool { + self.0.as_ref() == other + } +} + +impl PartialEq<&str> for LanguageName { + fn eq(&self, other: &&str) -> bool { + self.0.as_ref() == *other + } +} + +impl std::fmt::Display for LanguageName { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From<&'static str> for LanguageName { + fn from(str: &'static str) -> Self { + Self(SharedString::new_static(str)) + } +} + +impl From for String { + fn from(value: LanguageName) -> Self { + let value: &str = &value.0; + Self::from(value) + } +} diff --git a/crates/language_core/src/lsp_adapter.rs b/crates/language_core/src/lsp_adapter.rs new file mode 100644 index 0000000000000000000000000000000000000000..03012f71143428b49ea9d75a03b0118b50e413b4 --- /dev/null +++ b/crates/language_core/src/lsp_adapter.rs @@ -0,0 +1,44 @@ +use gpui::SharedString; +use serde::{Deserialize, Serialize}; + +/// Converts a value into an LSP position. +pub trait ToLspPosition { + /// Converts the value into an LSP position. + fn to_lsp_position(self) -> lsp::Position; +} + +/// 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, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum LanguageServerStatusUpdate { + Binary(BinaryStatus), + Health(ServerHealth, Option), +} + +#[derive(Debug, PartialEq, Eq, Deserialize, Serialize, Clone, Copy)] +#[serde(rename_all = "camelCase")] +pub enum ServerHealth { + Ok, + Warning, + Error, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum BinaryStatus { + None, + CheckingForUpdate, + Downloading, + Starting, + Stopping, + Stopped, + Failed { error: String }, +} diff --git a/crates/language_core/src/manifest.rs b/crates/language_core/src/manifest.rs new file mode 100644 index 0000000000000000000000000000000000000000..1e762ff6e7c364eef02eea16ce9e1ecaaa198554 --- /dev/null +++ b/crates/language_core/src/manifest.rs @@ -0,0 +1,36 @@ +use std::borrow::Borrow; + +use gpui::SharedString; + +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct ManifestName(SharedString); + +impl Borrow for ManifestName { + fn borrow(&self) -> &SharedString { + &self.0 + } +} + +impl Borrow for ManifestName { + fn borrow(&self) -> &str { + &self.0 + } +} + +impl From for ManifestName { + fn from(value: SharedString) -> Self { + Self(value) + } +} + +impl From for SharedString { + fn from(value: ManifestName) -> Self { + value.0 + } +} + +impl AsRef for ManifestName { + fn as_ref(&self) -> &SharedString { + &self.0 + } +} diff --git a/crates/language_core/src/queries.rs b/crates/language_core/src/queries.rs new file mode 100644 index 0000000000000000000000000000000000000000..510fb2e03c9b3a6876a2d72180ea238c9a3be4b6 --- /dev/null +++ b/crates/language_core/src/queries.rs @@ -0,0 +1,31 @@ +use std::borrow::Cow; + +pub type QueryFieldAccessor = fn(&mut LanguageQueries) -> &mut Option>; + +pub const QUERY_FILENAME_PREFIXES: &[(&str, QueryFieldAccessor)] = &[ + ("highlights", |q| &mut q.highlights), + ("brackets", |q| &mut q.brackets), + ("outline", |q| &mut q.outline), + ("indents", |q| &mut q.indents), + ("injections", |q| &mut q.injections), + ("overrides", |q| &mut q.overrides), + ("redactions", |q| &mut q.redactions), + ("runnables", |q| &mut q.runnables), + ("debugger", |q| &mut q.debugger), + ("textobjects", |q| &mut q.text_objects), +]; + +/// Tree-sitter language queries for a given language. +#[derive(Debug, Default)] +pub struct LanguageQueries { + pub highlights: Option>, + pub brackets: Option>, + pub indents: Option>, + pub outline: Option>, + pub injections: Option>, + pub overrides: Option>, + pub redactions: Option>, + pub runnables: Option>, + pub text_objects: Option>, + pub debugger: Option>, +} diff --git a/crates/language_core/src/toolchain.rs b/crates/language_core/src/toolchain.rs new file mode 100644 index 0000000000000000000000000000000000000000..a021cb86bd36295a065b16281209c5fc3b63cffc --- /dev/null +++ b/crates/language_core/src/toolchain.rs @@ -0,0 +1,124 @@ +//! Provides core data types for language toolchains. +//! +//! A language can have associated toolchains, +//! which is a set of tools used to interact with the projects written in said language. +//! For example, a Python project can have an associated virtual environment; a Rust project can have a toolchain override. + +use std::{path::Path, sync::Arc}; + +use gpui::SharedString; +use util::rel_path::RelPath; + +use crate::{LanguageName, ManifestName}; + +/// Represents a single toolchain. +#[derive(Clone, Eq, Debug)] +pub struct Toolchain { + /// User-facing label + pub name: SharedString, + /// Absolute path + pub path: SharedString, + pub language_name: LanguageName, + /// Full toolchain data (including language-specific details) + pub as_json: serde_json::Value, +} + +impl std::hash::Hash for Toolchain { + fn hash(&self, state: &mut H) { + let Self { + name, + path, + language_name, + as_json: _, + } = self; + name.hash(state); + path.hash(state); + language_name.hash(state); + } +} + +impl PartialEq for Toolchain { + fn eq(&self, other: &Self) -> bool { + let Self { + name, + path, + language_name, + as_json: _, + } = self; + // Do not use as_json for comparisons; it shouldn't impact equality, as it's not user-surfaced. + // Thus, there could be multiple entries that look the same in the UI. + (name, path, language_name).eq(&(&other.name, &other.path, &other.language_name)) + } +} + +/// Declares a scope of a toolchain added by user. +/// +/// When the user adds a toolchain, we give them an option to see that toolchain in: +/// - All of their projects +/// - A project they're currently in. +/// - Only in the subproject they're currently in. +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] +pub enum ToolchainScope { + Subproject(Arc, Arc), + Project, + /// Available in all projects on this box. It wouldn't make sense to show suggestions across machines. + Global, +} + +impl ToolchainScope { + pub fn label(&self) -> &'static str { + match self { + ToolchainScope::Subproject(_, _) => "Subproject", + ToolchainScope::Project => "Project", + ToolchainScope::Global => "Global", + } + } + + pub fn description(&self) -> &'static str { + match self { + ToolchainScope::Subproject(_, _) => { + "Available only in the subproject you're currently in." + } + ToolchainScope::Project => "Available in all locations in your current project.", + ToolchainScope::Global => "Available in all of your projects on this machine.", + } + } +} + +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct ToolchainMetadata { + /// Returns a term which we should use in UI to refer to toolchains produced by a given `ToolchainLister`. + pub term: SharedString, + /// A user-facing placeholder describing the semantic meaning of a path to a new toolchain. + pub new_toolchain_placeholder: SharedString, + /// The name of the manifest file for this toolchain. + pub manifest_name: ManifestName, +} + +type DefaultIndex = usize; +#[derive(Default, Clone, Debug)] +pub struct ToolchainList { + pub toolchains: Vec, + pub default: Option, + pub groups: Box<[(usize, SharedString)]>, +} + +impl ToolchainList { + pub fn toolchains(&self) -> &[Toolchain] { + &self.toolchains + } + pub fn default_toolchain(&self) -> Option { + self.default.and_then(|ix| self.toolchains.get(ix)).cloned() + } + pub fn group_for_index(&self, index: usize) -> Option<(usize, SharedString)> { + if index >= self.toolchains.len() { + return None; + } + let first_equal_or_greater = self + .groups + .partition_point(|(group_lower_bound, _)| group_lower_bound <= &index); + self.groups + .get(first_equal_or_greater.checked_sub(1)?) + .cloned() + } +} diff --git a/crates/language_extension/src/extension_lsp_adapter.rs b/crates/language_extension/src/extension_lsp_adapter.rs index 88401906fc28bb297fc2798346e110c9651b1387..13899f11c30556db189da48ed1fcb4b5d12b2f20 100644 --- a/crates/language_extension/src/extension_lsp_adapter.rs +++ b/crates/language_extension/src/extension_lsp_adapter.rs @@ -547,15 +547,16 @@ fn build_code_label( text.push_str(code_span); } extension::CodeLabelSpan::Literal(span) => { - let highlight_id = language + if let Some(highlight_id) = language .grammar() .zip(span.highlight_name.as_ref()) .and_then(|(grammar, highlight_name)| { grammar.highlight_id_for_name(highlight_name) }) - .unwrap_or_default(); - let ix = text.len(); - runs.push((ix..ix + span.text.len(), highlight_id)); + { + let ix = text.len(); + runs.push((ix..ix + span.text.len(), highlight_id)); + } text.push_str(&span.text); } } diff --git a/crates/language_model/src/model/cloud_model.rs b/crates/language_model/src/model/cloud_model.rs index f6ad907483e5946652752895d0a48ec129660b0b..a1362d78292082522f4e883efe42b2ca1e0a0300 100644 --- a/crates/language_model/src/model/cloud_model.rs +++ b/crates/language_model/src/model/cloud_model.rs @@ -154,9 +154,11 @@ impl RefreshLlmTokenListener { fn new(client: Arc, user_store: Entity, cx: &mut Context) -> Self { client.add_message_to_client_handler({ - let this = cx.entity(); + let this = cx.weak_entity(); move |message, cx| { - Self::handle_refresh_llm_token(this.clone(), message, cx); + if let Some(this) = this.upgrade() { + Self::handle_refresh_llm_token(this, message, cx); + } } }); diff --git a/crates/language_model/src/request.rs b/crates/language_model/src/request.rs index 3e8408f8334a52c2c807f991535cbbbd79800c4b..9a5e96078cd4d952185261c79032c5c5fdf30060 100644 --- a/crates/language_model/src/request.rs +++ b/crates/language_model/src/request.rs @@ -3,7 +3,6 @@ use std::sync::Arc; use anyhow::Result; use base64::write::EncoderWriter; -use cloud_llm_client::CompletionIntent; use gpui::{ App, AppContext as _, DevicePixels, Image, ImageFormat, ObjectFit, SharedString, Size, Task, point, px, size, @@ -443,6 +442,21 @@ pub enum LanguageModelToolChoice { None, } +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CompletionIntent { + UserPrompt, + Subagent, + ToolResults, + ThreadSummarization, + ThreadContextSummarization, + CreateFile, + EditFile, + InlineAssist, + TerminalInlineAssist, + GenerateGitCommitMessage, +} + #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] pub struct LanguageModelRequest { pub thread_id: Option, diff --git a/crates/language_model/src/tool_schema.rs b/crates/language_model/src/tool_schema.rs index f9402c28dc316f9ccdacc58afaa0eebd6699f92d..878870482a7527bf815797d16e03ad8edc79642e 100644 --- a/crates/language_model/src/tool_schema.rs +++ b/crates/language_model/src/tool_schema.rs @@ -17,7 +17,12 @@ pub enum LanguageModelToolSchemaFormat { pub fn root_schema_for(format: LanguageModelToolSchemaFormat) -> Schema { let mut generator = match format { - LanguageModelToolSchemaFormat::JsonSchema => SchemaSettings::draft07().into_generator(), + LanguageModelToolSchemaFormat::JsonSchema => SchemaSettings::draft07() + .with(|settings| { + settings.meta_schema = None; + settings.inline_subschemas = true; + }) + .into_generator(), LanguageModelToolSchemaFormat::JsonSchemaSubset => SchemaSettings::openapi3() .with(|settings| { settings.meta_schema = None; @@ -62,6 +67,7 @@ pub fn adapt_schema_to_format( if let Value::Object(obj) = json { obj.remove("$schema"); obj.remove("title"); + obj.remove("description"); } match format { @@ -100,9 +106,12 @@ fn adapt_to_json_schema_subset(json: &mut Value) -> Result<()> { ); } - const KEYS_TO_REMOVE: [(&str, fn(&Value) -> bool); 5] = [ + const KEYS_TO_REMOVE: [(&str, fn(&Value) -> bool); 6] = [ ("format", |value| value.is_string()), - ("additionalProperties", |value| value.is_boolean()), + // Gemini doesn't support `additionalProperties` in any form (boolean or schema object) + ("additionalProperties", |_| true), + // Gemini doesn't support `propertyNames` + ("propertyNames", |_| true), ("exclusiveMinimum", |value| value.is_number()), ("exclusiveMaximum", |value| value.is_number()), ("optional", |value| value.is_boolean()), @@ -229,6 +238,28 @@ mod tests { "format": {}, }) ); + + // additionalProperties as an object schema is also unsupported by Gemini + let mut json = json!({ + "type": "object", + "properties": { + "name": { "type": "string" } + }, + "additionalProperties": { "type": "string" }, + "propertyNames": { "pattern": "^[A-Za-z]+$" } + }); + + adapt_to_json_schema_subset(&mut json).unwrap(); + + assert_eq!( + json, + json!({ + "type": "object", + "properties": { + "name": { "type": "string" } + } + }) + ); } #[test] diff --git a/crates/language_models/src/language_models.rs b/crates/language_models/src/language_models.rs index 55624ed9d52d5dbb9cf8b724e0ea9ca2ef5a894a..4db1db8fa6ce5afb9d77a6685bfc0861d0fb8885 100644 --- a/crates/language_models/src/language_models.rs +++ b/crates/language_models/src/language_models.rs @@ -39,37 +39,43 @@ pub fn init(user_store: Entity, client: Arc, cx: &mut App) { // Subscribe to extension store events to track LLM extension installations if let Some(extension_store) = extension_host::ExtensionStore::try_global(cx) { cx.subscribe(&extension_store, { - let registry = registry.clone(); - move |extension_store, event, cx| match event { - extension_host::Event::ExtensionInstalled(extension_id) => { - if let Some(manifest) = extension_store - .read(cx) - .extension_manifest_for_id(extension_id) - { - if !manifest.language_model_providers.is_empty() { - registry.update(cx, |registry, cx| { - registry.extension_installed(extension_id.clone(), cx); - }); + let registry = registry.downgrade(); + move |extension_store, event, cx| { + let Some(registry) = registry.upgrade() else { + return; + }; + match event { + extension_host::Event::ExtensionInstalled(extension_id) => { + if let Some(manifest) = extension_store + .read(cx) + .extension_manifest_for_id(extension_id) + { + if !manifest.language_model_providers.is_empty() { + registry.update(cx, |registry, cx| { + registry.extension_installed(extension_id.clone(), cx); + }); + } } } - } - extension_host::Event::ExtensionUninstalled(extension_id) => { - registry.update(cx, |registry, cx| { - registry.extension_uninstalled(extension_id, cx); - }); - } - extension_host::Event::ExtensionsUpdated => { - let mut new_ids = HashSet::default(); - for (extension_id, entry) in extension_store.read(cx).installed_extensions() { - if !entry.manifest.language_model_providers.is_empty() { - new_ids.insert(extension_id.clone()); + extension_host::Event::ExtensionUninstalled(extension_id) => { + registry.update(cx, |registry, cx| { + registry.extension_uninstalled(extension_id, cx); + }); + } + extension_host::Event::ExtensionsUpdated => { + let mut new_ids = HashSet::default(); + for (extension_id, entry) in extension_store.read(cx).installed_extensions() + { + if !entry.manifest.language_model_providers.is_empty() { + new_ids.insert(extension_id.clone()); + } } + registry.update(cx, |registry, cx| { + registry.sync_installed_llm_extensions(new_ids, cx); + }); } - registry.update(cx, |registry, cx| { - registry.sync_installed_llm_extensions(new_ids, cx); - }); + _ => {} } - _ => {} } }) .detach(); @@ -101,7 +107,11 @@ pub fn init(user_store: Entity, client: Arc, cx: &mut App) { cx, ); }); + let registry = registry.downgrade(); cx.observe_global::(move |cx| { + let Some(registry) = registry.upgrade() else { + return; + }; let openai_compatible_providers_new = AllLanguageModelSettings::get_global(cx) .openai_compatible .keys() diff --git a/crates/language_models/src/provider/bedrock.rs b/crates/language_models/src/provider/bedrock.rs index 734e97ee335c4106fced9d334d31b5ed5b86d407..f53f145dbd387aa948b977d854ba77f1cbe49ded 100644 --- a/crates/language_models/src/provider/bedrock.rs +++ b/crates/language_models/src/provider/bedrock.rs @@ -344,7 +344,7 @@ impl State { .ok_or(AuthenticateError::CredentialsNotFound)?; let credentials_str = String::from_utf8(credentials_bytes) - .context("invalid {PROVIDER_NAME} credentials")?; + .with_context(|| format!("invalid {PROVIDER_NAME} credentials"))?; let credentials: BedrockCredentials = serde_json::from_str(&credentials_str).context("failed to parse credentials")?; diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index 1e68ad1971410445c8df731b6d7bae4243074cfe..161ee6e9abd5283dfbe10c4e7c9dc5597fc4b5b9 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -769,7 +769,6 @@ impl LanguageModel for CloudLanguageModel { > { let thread_id = request.thread_id.clone(); let prompt_id = request.prompt_id.clone(); - let intent = request.intent; let app_version = Some(cx.update(|cx| AppVersion::global(cx))); let user_store = self.user_store.clone(); let organization_id = cx.update(|cx| { @@ -822,7 +821,6 @@ impl LanguageModel for CloudLanguageModel { CompletionBody { thread_id, prompt_id, - intent, provider: cloud_llm_client::LanguageModelProvider::Anthropic, model: request.model.clone(), provider_request: serde_json::to_value(&request) @@ -881,7 +879,6 @@ impl LanguageModel for CloudLanguageModel { CompletionBody { thread_id, prompt_id, - intent, provider: cloud_llm_client::LanguageModelProvider::OpenAi, model: request.model.clone(), provider_request: serde_json::to_value(&request) @@ -923,7 +920,6 @@ impl LanguageModel for CloudLanguageModel { CompletionBody { thread_id, prompt_id, - intent, provider: cloud_llm_client::LanguageModelProvider::XAi, model: request.model.clone(), provider_request: serde_json::to_value(&request) @@ -958,7 +954,6 @@ impl LanguageModel for CloudLanguageModel { CompletionBody { thread_id, prompt_id, - intent, provider: cloud_llm_client::LanguageModelProvider::Google, model: request.model.model_id.clone(), provider_request: serde_json::to_value(&request) diff --git a/crates/language_models/src/provider/copilot_chat.rs b/crates/language_models/src/provider/copilot_chat.rs index 7063db83bf65b82a4f314ad97e9463b106400c0b..a2d39e1945e2791d9d5c998cc717a07498ebc157 100644 --- a/crates/language_models/src/provider/copilot_chat.rs +++ b/crates/language_models/src/provider/copilot_chat.rs @@ -4,7 +4,6 @@ use std::sync::Arc; use anthropic::AnthropicModelMode; use anyhow::{Result, anyhow}; -use cloud_llm_client::CompletionIntent; use collections::HashMap; use copilot::{GlobalCopilotAuth, Status}; use copilot_chat::responses as copilot_responses; @@ -21,7 +20,7 @@ use gpui::{AnyView, App, AsyncApp, Entity, Subscription, Task}; use http_client::StatusCode; use language::language_settings::all_language_settings; use language_model::{ - AuthenticateError, IconOrSvg, LanguageModel, LanguageModelCompletionError, + AuthenticateError, CompletionIntent, IconOrSvg, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelCostInfo, LanguageModelEffortLevel, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, LanguageModelRequestMessage, @@ -357,7 +356,8 @@ impl LanguageModel for CopilotChatLanguageModel { | CompletionIntent::TerminalInlineAssist | CompletionIntent::GenerateGitCommitMessage => true, - CompletionIntent::ToolResults + CompletionIntent::Subagent + | CompletionIntent::ToolResults | CompletionIntent::ThreadSummarization | CompletionIntent::CreateFile | CompletionIntent::EditFile => false, @@ -1046,7 +1046,7 @@ fn into_copilot_chat( tools, tool_choice: tool_choice.map(|choice| match choice { LanguageModelToolChoice::Auto => ToolChoice::Auto, - LanguageModelToolChoice::Any => ToolChoice::Any, + LanguageModelToolChoice::Any => ToolChoice::Required, LanguageModelToolChoice::None => ToolChoice::None, }), thinking_budget: None, @@ -1072,6 +1072,7 @@ fn compute_thinking_budget( fn intent_to_chat_location(intent: Option) -> ChatLocation { match intent { Some(CompletionIntent::UserPrompt) => ChatLocation::Agent, + Some(CompletionIntent::Subagent) => ChatLocation::Agent, Some(CompletionIntent::ToolResults) => ChatLocation::Agent, Some(CompletionIntent::ThreadSummarization) => ChatLocation::Panel, Some(CompletionIntent::ThreadContextSummarization) => ChatLocation::Panel, @@ -1255,7 +1256,7 @@ fn into_copilot_responses( let mapped_tool_choice = tool_choice.map(|choice| match choice { LanguageModelToolChoice::Auto => responses::ToolChoice::Auto, - LanguageModelToolChoice::Any => responses::ToolChoice::Any, + LanguageModelToolChoice::Any => responses::ToolChoice::Required, LanguageModelToolChoice::None => responses::ToolChoice::None, }); diff --git a/crates/language_onboarding/Cargo.toml b/crates/language_onboarding/Cargo.toml index 38cf8a604a87f403e2d2720be6a2ba69a61e7484..1ab0a75fc3f726de5ec81c18f5b7ae5c136caeea 100644 --- a/crates/language_onboarding/Cargo.toml +++ b/crates/language_onboarding/Cargo.toml @@ -21,9 +21,3 @@ gpui.workspace = true project.workspace = true ui.workspace = true workspace.workspace = true - -# Uncomment other workspace dependencies as needed -# assistant.workspace = true -# client.workspace = true -# project.workspace = true -# settings.workspace = true diff --git a/crates/language_tools/Cargo.toml b/crates/language_tools/Cargo.toml index 1698c7294969d3d3a641f0eb4611153efb658c6d..26e230c1d92f674642eab125f62787a3c29a3665 100644 --- a/crates/language_tools/Cargo.toml +++ b/crates/language_tools/Cargo.toml @@ -44,4 +44,5 @@ release_channel.workspace = true gpui = { workspace = true, features = ["test-support"] } semver.workspace = true util = { workspace = true, features = ["test-support"] } -zlog.workspace = true \ No newline at end of file +zlog.workspace = true +theme_settings.workspace = true \ No newline at end of file diff --git a/crates/language_tools/src/highlights_tree_view.rs b/crates/language_tools/src/highlights_tree_view.rs index 8a139958897c261816171c364b6d1f62ccb3b8c6..c2f684c11dc148c8f66b6cf20e0ca06e40905db7 100644 --- a/crates/language_tools/src/highlights_tree_view.rs +++ b/crates/language_tools/src/highlights_tree_view.rs @@ -9,6 +9,7 @@ use gpui::{ Task, UniformListScrollHandle, WeakEntity, Window, actions, div, rems, uniform_list, }; use language::ToOffset; + use menu::{SelectNext, SelectPrevious}; use std::{mem, ops::Range}; use theme::ActiveTheme; @@ -375,7 +376,9 @@ impl HighlightsTreeView { rule.style .iter() .find(|style_name| { - semantic_theme.get_opt(style_name).is_some() + semantic_theme + .style_for_name(style_name) + .is_some() }) .map(|style_name| { SharedString::from(style_name.clone()) @@ -417,12 +420,12 @@ impl HighlightsTreeView { for capture in captures { let highlight_id = highlight_maps[capture.grammar_index].get(capture.index); - let Some(style) = highlight_id.style(&syntax_theme) else { + let Some(style) = syntax_theme.get(highlight_id).cloned() else { continue; }; - let theme_key = highlight_id - .name(&syntax_theme) + let theme_key = syntax_theme + .get_capture_name(highlight_id) .map(|theme_key| SharedString::from(theme_key.to_string())); let capture_name = grammars[capture.grammar_index] diff --git a/crates/language_tools/src/lsp_log_view_tests.rs b/crates/language_tools/src/lsp_log_view_tests.rs index 0b4516f5d052260ac4274e9afe14d3bc1a5ef8ee..476f23ffd82c66a581587d8f8fb70c4192ab04e0 100644 --- a/crates/language_tools/src/lsp_log_view_tests.rs +++ b/crates/language_tools/src/lsp_log_view_tests.rs @@ -109,7 +109,7 @@ fn init_test(cx: &mut gpui::TestAppContext) { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); release_channel::init(semver::Version::new(0, 0, 0), cx); }); } diff --git a/crates/languages/Cargo.toml b/crates/languages/Cargo.toml index b66f661b5e8782a7a072332141e4e2246ab1a2b9..93c70d4b27a0b769df521618c22c0700430be2f8 100644 --- a/crates/languages/Cargo.toml +++ b/crates/languages/Cargo.toml @@ -13,24 +13,9 @@ test-support = [ "load-grammars" ] load-grammars = [ + "grammars/load-grammars", "tree-sitter", - "tree-sitter-bash", - "tree-sitter-c", - "tree-sitter-cpp", - "tree-sitter-css", - "tree-sitter-diff", "tree-sitter-gitcommit", - "tree-sitter-go", - "tree-sitter-go-mod", - "tree-sitter-gowork", - "tree-sitter-jsdoc", - "tree-sitter-json", - "tree-sitter-md", - "tree-sitter-python", - "tree-sitter-regex", - "tree-sitter-rust", - "tree-sitter-typescript", - "tree-sitter-yaml", ] [dependencies] @@ -44,6 +29,7 @@ collections.workspace = true futures.workspace = true globset.workspace = true gpui.workspace = true +grammars.workspace = true http_client.workspace = true itertools.workspace = true json_schema_store.workspace = true @@ -62,7 +48,6 @@ pet.workspace = true project.workspace = true regex.workspace = true rope.workspace = true -rust-embed.workspace = true serde.workspace = true serde_json.workspace = true serde_json_lenient.workspace = true @@ -74,29 +59,13 @@ snippet.workspace = true task.workspace = true terminal.workspace = true theme.workspace = true -toml.workspace = true tree-sitter = { workspace = true, optional = true } -tree-sitter-bash = { workspace = true, optional = true } -tree-sitter-c = { workspace = true, optional = true } -tree-sitter-cpp = { workspace = true, optional = true } -tree-sitter-css = { workspace = true, optional = true } -tree-sitter-diff = { workspace = true, optional = true } tree-sitter-gitcommit = { workspace = true, optional = true } -tree-sitter-go = { workspace = true, optional = true } -tree-sitter-go-mod = { workspace = true, optional = true } -tree-sitter-gowork = { workspace = true, optional = true } -tree-sitter-jsdoc = { workspace = true, optional = true } -tree-sitter-json = { workspace = true, optional = true } -tree-sitter-md = { workspace = true, optional = true } -tree-sitter-python = { workspace = true, optional = true } -tree-sitter-regex = { workspace = true, optional = true } -tree-sitter-rust = { workspace = true, optional = true } -tree-sitter-typescript = { workspace = true, optional = true } -tree-sitter-yaml = { workspace = true, optional = true } url.workspace = true util.workspace = true [dev-dependencies] +fs = { workspace = true, features = ["test-support"] } pretty_assertions.workspace = true theme = { workspace = true, features = ["test-support"] } tree-sitter-bash.workspace = true @@ -105,6 +74,7 @@ tree-sitter-cpp.workspace = true tree-sitter-css.workspace = true tree-sitter-go.workspace = true tree-sitter-python.workspace = true +tree-sitter-rust.workspace = true tree-sitter-typescript.workspace = true tree-sitter.workspace = true unindent.workspace = true diff --git a/crates/languages/src/c.rs b/crates/languages/src/c.rs index 3a9207329d58a60acb0da42699116336d4528c97..bc75a9dbabbf0687124da5e35e6435ebc377e854 100644 --- a/crates/languages/src/c.rs +++ b/crates/languages/src/c.rs @@ -368,7 +368,7 @@ impl super::LspAdapter for CLspAdapter { Ok(original) } - fn retain_old_diagnostic(&self, previous_diagnostic: &Diagnostic, _: &App) -> bool { + fn retain_old_diagnostic(&self, previous_diagnostic: &Diagnostic) -> bool { clangd_ext::is_inactive_region(previous_diagnostic) } diff --git a/crates/languages/src/c/imports.scm b/crates/languages/src/c/imports.scm deleted file mode 100644 index 2aaab2106f5422db426876a7fa65c9674fe93174..0000000000000000000000000000000000000000 --- a/crates/languages/src/c/imports.scm +++ /dev/null @@ -1,7 +0,0 @@ -(preproc_include - path: [ - ((system_lib_string) @source @wildcard - (#strip! @source "[<>]")) - (string_literal - (string_content) @source @wildcard) - ]) @import diff --git a/crates/languages/src/cpp.rs b/crates/languages/src/cpp.rs index 3207b492f4b11be345cd67a989f9667d025d6660..5985baa54808b86a62e9d7ade38dca3480931459 100644 --- a/crates/languages/src/cpp.rs +++ b/crates/languages/src/cpp.rs @@ -1,9 +1,7 @@ use settings::SemanticTokenRules; -use crate::LanguageDir; - pub(crate) fn semantic_token_rules() -> SemanticTokenRules { - let content = LanguageDir::get("cpp/semantic_token_rules.json") + let content = grammars::get_file("cpp/semantic_token_rules.json") .expect("missing cpp/semantic_token_rules.json"); let json = std::str::from_utf8(&content.data).expect("invalid utf-8 in semantic_token_rules"); settings::parse_json_with_comments::(json) diff --git a/crates/languages/src/cpp/imports.scm b/crates/languages/src/cpp/imports.scm deleted file mode 100644 index 43adde711b5352ef0d92566d4bdde91a847319b8..0000000000000000000000000000000000000000 --- a/crates/languages/src/cpp/imports.scm +++ /dev/null @@ -1,6 +0,0 @@ -(preproc_include - path: [ - (system_lib_string) @source @wildcard - (string_literal - (string_content) @source @wildcard) - ]) @import diff --git a/crates/languages/src/go.rs b/crates/languages/src/go.rs index 8d945ba3b9e1b501d52675ada80bea41d394d4ed..73e9b162f4d6e76c4a42d4e24accfd90e79733c9 100644 --- a/crates/languages/src/go.rs +++ b/crates/languages/src/go.rs @@ -2,12 +2,12 @@ use anyhow::{Context as _, Result}; use async_trait::async_trait; use collections::HashMap; use futures::StreamExt; -use gpui::{App, AsyncApp, Task}; +use gpui::{App, AsyncApp, Entity, Task}; use http_client::github::latest_github_release; pub use language::*; use language::{ LanguageName, LanguageToolchainStore, LspAdapterDelegate, LspInstaller, - language_settings::language_settings, + language_settings::LanguageSettings, }; use lsp::{LanguageServerBinary, LanguageServerName}; @@ -31,10 +31,8 @@ use std::{ use task::{TaskTemplate, TaskTemplates, TaskVariables, VariableName}; use util::{ResultExt, fs::remove_matching, maybe, merge_json_value_into}; -use crate::LanguageDir; - pub(crate) fn semantic_token_rules() -> SemanticTokenRules { - let content = LanguageDir::get("go/semantic_token_rules.json") + let content = grammars::get_file("go/semantic_token_rules.json") .expect("missing go/semantic_token_rules.json"); let json = std::str::from_utf8(&content.data).expect("invalid utf-8 in semantic_token_rules"); settings::parse_json_with_comments::(json) @@ -211,7 +209,7 @@ impl LspAdapter for GoLspAdapter { cx: &mut AsyncApp, ) -> Result> { let semantic_tokens_enabled = cx.update(|cx| { - language_settings(Some(LanguageName::new("Go")), None, cx) + LanguageSettings::resolve(None, Some(&LanguageName::new("Go")), cx) .semantic_tokens .enabled() }); @@ -593,7 +591,7 @@ impl ContextProvider for GoContextProvider { ))) } - fn associated_tasks(&self, _: Option>, _: &App) -> Task> { + fn associated_tasks(&self, _: Option>, _: &App) -> Task> { let package_cwd = if GO_PACKAGE_TASK_VARIABLE.template_value() == "." { None } else { diff --git a/crates/languages/src/go/imports.scm b/crates/languages/src/go/imports.scm deleted file mode 100644 index 23e480c10b20b76c6724df29a550e627c2aee799..0000000000000000000000000000000000000000 --- a/crates/languages/src/go/imports.scm +++ /dev/null @@ -1,12 +0,0 @@ -(import_spec - name: [ - (dot) - (package_identifier) - ] - path: (interpreted_string_literal - (interpreted_string_literal_content) @namespace)) @wildcard @import - -(import_spec - !name - path: (interpreted_string_literal - (interpreted_string_literal_content) @namespace)) @wildcard @import diff --git a/crates/languages/src/javascript/imports.scm b/crates/languages/src/javascript/imports.scm deleted file mode 100644 index 0e688d53fb6ed639c55c1fa84917711d19c3108a..0000000000000000000000000000000000000000 --- a/crates/languages/src/javascript/imports.scm +++ /dev/null @@ -1,16 +0,0 @@ -(import_statement - import_clause: (import_clause - [ - (identifier) @name - (named_imports - (import_specifier - name: (_) @name - alias: (_)? @alias)) - ]) - source: (string - (string_fragment) @source)) @import - -(import_statement - !import_clause - source: (string - (string_fragment) @source @wildcard)) @import diff --git a/crates/languages/src/json.rs b/crates/languages/src/json.rs index 3d8ba972eb17b0fe7f9d5070b73a4fb9e94adef3..de30d958d006016a118f2db077e38c1212f4f683 100644 --- a/crates/languages/src/json.rs +++ b/crates/languages/src/json.rs @@ -4,10 +4,10 @@ use async_tar::Archive; use async_trait::async_trait; use collections::HashMap; use futures::StreamExt; -use gpui::{App, AsyncApp, Task}; +use gpui::{App, AsyncApp, Entity, Task}; use http_client::github::{GitHubLspBinaryVersion, latest_github_release}; use language::{ - ContextProvider, LanguageName, LanguageRegistry, LocalFile as _, LspAdapter, + Buffer, ContextProvider, LanguageName, LanguageRegistry, LocalFile as _, LspAdapter, LspAdapterDelegate, LspInstaller, Toolchain, }; use lsp::{LanguageServerBinary, LanguageServerName, Uri}; @@ -44,10 +44,11 @@ pub(crate) struct JsonTaskProvider; impl ContextProvider for JsonTaskProvider { fn associated_tasks( &self, - file: Option>, + buffer: Option>, cx: &App, ) -> gpui::Task> { - let Some(file) = project::File::from_dyn(file.as_ref()).cloned() else { + let file = buffer.as_ref().and_then(|buf| buf.read(cx).file()); + let Some(file) = project::File::from_dyn(file).cloned() else { return Task::ready(None); }; let is_package_json = file.path.ends_with(RelPath::unix("package.json").unwrap()); diff --git a/crates/languages/src/lib.rs b/crates/languages/src/lib.rs index 240935d2f817b43b2aae03dfdff4321de6522bf3..9a0524dffd238b566931a4a612edd91b1e6361c3 100644 --- a/crates/languages/src/lib.rs +++ b/crates/languages/src/lib.rs @@ -1,14 +1,12 @@ -use anyhow::Context as _; use gpui::{App, SharedString, UpdateGlobal}; use node_runtime::NodeRuntime; use project::Fs; use python::PyprojectTomlManifestProvider; use rust::CargoManifestProvider; -use rust_embed::RustEmbed; use settings::{SemanticTokenRules, SettingsStore}; use smol::stream::StreamExt; -use std::{str, sync::Arc}; -use util::{ResultExt, asset_str}; +use std::sync::Arc; +use util::ResultExt; pub use language::*; @@ -35,11 +33,6 @@ mod yaml; pub(crate) use package_json::{PackageJson, PackageJsonData}; -#[derive(RustEmbed)] -#[folder = "src/"] -#[exclude = "*.rs"] -struct LanguageDir; - /// A shared grammar for plain text, exposed for reuse by downstream crates. #[cfg(feature = "tree-sitter-gitcommit")] pub static LANGUAGE_GIT_COMMIT: std::sync::LazyLock> = @@ -47,10 +40,11 @@ pub static LANGUAGE_GIT_COMMIT: std::sync::LazyLock> = Arc::new(Language::new( LanguageConfig { name: "Git Commit".into(), - soft_wrap: Some(language::language_settings::SoftWrap::EditorWidth), + soft_wrap: Some(language::SoftWrap::EditorWidth), matcher: LanguageMatcher { path_suffixes: vec!["COMMIT_EDITMSG".to_owned()], first_line_pattern: None, + ..LanguageMatcher::default() }, line_comments: vec![Arc::from("#")], ..LanguageConfig::default() @@ -61,28 +55,7 @@ pub static LANGUAGE_GIT_COMMIT: std::sync::LazyLock> = pub fn init(languages: Arc, fs: Arc, node: NodeRuntime, cx: &mut App) { #[cfg(feature = "load-grammars")] - languages.register_native_grammars([ - ("bash", tree_sitter_bash::LANGUAGE), - ("c", tree_sitter_c::LANGUAGE), - ("cpp", tree_sitter_cpp::LANGUAGE), - ("css", tree_sitter_css::LANGUAGE), - ("diff", tree_sitter_diff::LANGUAGE), - ("go", tree_sitter_go::LANGUAGE), - ("gomod", tree_sitter_go_mod::LANGUAGE), - ("gowork", tree_sitter_gowork::LANGUAGE), - ("jsdoc", tree_sitter_jsdoc::LANGUAGE), - ("json", tree_sitter_json::LANGUAGE), - ("jsonc", tree_sitter_json::LANGUAGE), - ("markdown", tree_sitter_md::LANGUAGE), - ("markdown-inline", tree_sitter_md::INLINE_LANGUAGE), - ("python", tree_sitter_python::LANGUAGE), - ("regex", tree_sitter_regex::LANGUAGE), - ("rust", tree_sitter_rust::LANGUAGE), - ("tsx", tree_sitter_typescript::LANGUAGE_TSX), - ("typescript", tree_sitter_typescript::LANGUAGE_TYPESCRIPT), - ("yaml", tree_sitter_yaml::LANGUAGE), - ("gitcommit", tree_sitter_gitcommit::LANGUAGE), - ]); + languages.register_native_grammars(grammars::native_grammars()); let c_lsp_adapter = Arc::new(c::CLspAdapter); let css_lsp_adapter = Arc::new(css::CssLspAdapter::new(node.clone())); @@ -98,7 +71,7 @@ pub fn init(languages: Arc, fs: Arc, node: NodeRuntime let python_lsp_adapter = Arc::new(python::PyrightLspAdapter::new(node.clone())); let basedpyright_lsp_adapter = Arc::new(BasedPyrightLspAdapter::new(node.clone())); let ruff_lsp_adapter = Arc::new(RuffLspAdapter::new(fs.clone())); - let python_toolchain_provider = Arc::new(python::PythonToolchainProvider); + let python_toolchain_provider = Arc::new(python::PythonToolchainProvider::new(fs.clone())); let rust_context_provider = Arc::new(rust::RustContextProvider); let rust_lsp_adapter = Arc::new(rust::RustLspAdapter); let tailwind_adapter = Arc::new(tailwind::TailwindLspAdapter::new(node.clone())); @@ -191,7 +164,7 @@ pub fn init(languages: Arc, fs: Arc, node: NodeRuntime context: Some(python_context_provider), toolchain: Some(python_toolchain_provider), manifest_name: Some(SharedString::new_static("pyproject.toml").into()), - ..Default::default() + semantic_token_rules: Some(python::semantic_token_rules()), }, LanguageInfo { name: "rust", @@ -298,7 +271,7 @@ pub fn init(languages: Arc, fs: Arc, node: NodeRuntime "CSS", "ERB", "HTML+ERB", - "HEEX", + "HEEx", "HTML", "JavaScript", "TypeScript", @@ -389,7 +362,7 @@ fn register_language( Arc::new(move || { Ok(LoadedLanguage { config: config.clone(), - queries: load_queries(name), + queries: grammars::load_queries(name), context_provider: context.clone(), toolchain_provider: toolchain.clone(), manifest_name: manifest_name.clone(), @@ -401,56 +374,13 @@ fn register_language( #[cfg(any(test, feature = "test-support"))] pub fn language(name: &str, grammar: tree_sitter::Language) -> Arc { Arc::new( - Language::new(load_config(name), Some(grammar)) - .with_queries(load_queries(name)) + Language::new(grammars::load_config(name), Some(grammar)) + .with_queries(grammars::load_queries(name)) .unwrap(), ) } fn load_config(name: &str) -> LanguageConfig { - let config_toml = String::from_utf8( - LanguageDir::get(&format!("{}/config.toml", name)) - .unwrap_or_else(|| panic!("missing config for language {:?}", name)) - .data - .to_vec(), - ) - .unwrap(); - - #[allow(unused_mut)] - let mut config: LanguageConfig = ::toml::from_str(&config_toml) - .with_context(|| format!("failed to load config.toml for language {name:?}")) - .unwrap(); - - #[cfg(not(any(feature = "load-grammars", test)))] - { - config = LanguageConfig { - name: config.name, - matcher: config.matcher, - jsx_tag_auto_close: config.jsx_tag_auto_close, - ..Default::default() - } - } - - config -} - -fn load_queries(name: &str) -> LanguageQueries { - let mut result = LanguageQueries::default(); - for path in LanguageDir::iter() { - if let Some(remainder) = path.strip_prefix(name).and_then(|p| p.strip_prefix('/')) { - if !remainder.ends_with(".scm") { - continue; - } - for (name, query) in QUERY_FILENAME_PREFIXES { - if remainder.starts_with(name) { - let contents = asset_str::(path.as_ref()); - match query(&mut result) { - None => *query(&mut result) = Some(contents), - Some(r) => r.to_mut().push_str(contents.as_ref()), - } - } - } - } - } - result + let grammars_loaded = cfg!(any(feature = "load-grammars", test)); + grammars::load_config_for_feature(name, grammars_loaded) } diff --git a/crates/languages/src/python.rs b/crates/languages/src/python.rs index 99d9788ddb5aa7497157a0694cad01ad0f4e6bf5..d27db372bf3d5f84ba282b30afd060f3ae4b183e 100644 --- a/crates/languages/src/python.rs +++ b/crates/languages/src/python.rs @@ -5,10 +5,12 @@ use collections::HashMap; use futures::future::BoxFuture; use futures::lock::OwnedMutexGuard; use futures::{AsyncBufReadExt, StreamExt as _}; -use gpui::{App, AsyncApp, SharedString, Task}; +use gpui::{App, AsyncApp, Entity, SharedString, Task}; use http_client::github::{AssetKind, GitHubLspBinaryVersion, latest_github_release}; -use language::language_settings::language_settings; -use language::{ContextLocation, DynLspInstaller, LanguageToolchainStore, LspInstaller, Symbol}; +use language::language_settings::LanguageSettings; +use language::{ + Buffer, ContextLocation, DynLspInstaller, LanguageToolchainStore, LspInstaller, Symbol, +}; use language::{ContextProvider, LspAdapter, LspAdapterDelegate}; use language::{LanguageName, ManifestName, ManifestProvider, ManifestQuery}; use language::{Toolchain, ToolchainList, ToolchainLister, ToolchainMetadata}; @@ -24,7 +26,7 @@ use project::lsp_store::language_server_settings; use semver::Version; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -use settings::Settings; +use settings::{SemanticTokenRules, Settings}; use terminal::terminal_settings::TerminalSettings; use smol::lock::OnceCell; @@ -49,6 +51,14 @@ use std::{ use task::{ShellKind, TaskTemplate, TaskTemplates, VariableName}; use util::{ResultExt, maybe}; +pub(crate) fn semantic_token_rules() -> SemanticTokenRules { + let content = grammars::get_file("python/semantic_token_rules.json") + .expect("missing python/semantic_token_rules.json"); + let json = std::str::from_utf8(&content.data).expect("invalid utf-8 in semantic_token_rules"); + settings::parse_json_with_comments::(json) + .expect("failed to parse python semantic_token_rules.json") +} + #[derive(Debug, Serialize, Deserialize)] pub(crate) struct PythonToolchainData { #[serde(flatten)] @@ -437,7 +447,7 @@ impl LspInstaller for TyLspAdapter { async_fs::create_dir_all(&destination_path).await?; let server_path = match Self::GITHUB_ASSET_KIND { - AssetKind::TarGz | AssetKind::Gz => destination_path + AssetKind::TarGz | AssetKind::TarBz2 | AssetKind::Gz => destination_path .join(Self::build_asset_name()?.0) .join("ty"), AssetKind::Zip => destination_path.clone().join("ty.exe"), @@ -527,7 +537,7 @@ impl LspInstaller for TyLspAdapter { let path = last.context("no cached binary")?; let path = match TyLspAdapter::GITHUB_ASSET_KIND { - AssetKind::TarGz | AssetKind::Gz => { + AssetKind::TarGz | AssetKind::TarBz2 | AssetKind::Gz => { path.join(Self::build_asset_name()?.0).join("ty") } AssetKind::Zip => path.join("ty.exe"), @@ -822,11 +832,10 @@ impl ContextProvider for PythonContextProvider { toolchains: Arc, cx: &mut gpui::App, ) -> Task> { - let test_target = - match selected_test_runner(location.file_location.buffer.read(cx).file(), cx) { - TestRunner::UNITTEST => self.build_unittest_target(variables), - TestRunner::PYTEST => self.build_pytest_target(variables), - }; + let test_target = match selected_test_runner(Some(&location.file_location.buffer), cx) { + TestRunner::UNITTEST => self.build_unittest_target(variables), + TestRunner::PYTEST => self.build_pytest_target(variables), + }; let module_target = self.build_module_target(variables); let location_file = location.file_location.buffer.read(cx).file().cloned(); @@ -864,10 +873,10 @@ impl ContextProvider for PythonContextProvider { fn associated_tasks( &self, - file: Option>, + buffer: Option>, cx: &App, ) -> Task> { - let test_runner = selected_test_runner(file.as_ref(), cx); + let test_runner = selected_test_runner(buffer.as_ref(), cx); let mut tasks = vec![ // Execute a selection @@ -974,9 +983,11 @@ impl ContextProvider for PythonContextProvider { } } -fn selected_test_runner(location: Option<&Arc>, cx: &App) -> TestRunner { +fn selected_test_runner(location: Option<&Entity>, cx: &App) -> TestRunner { const TEST_RUNNER_VARIABLE: &str = "TEST_RUNNER"; - language_settings(Some(LanguageName::new_static("Python")), location, cx) + let language = LanguageName::new_static("Python"); + let settings = LanguageSettings::resolve(location.map(|b| b.read(cx)), Some(&language), cx); + settings .tasks .variables .get(TEST_RUNNER_VARIABLE) @@ -1109,7 +1120,15 @@ fn python_env_kind_display(k: &PythonEnvironmentKind) -> &'static str { } } -pub(crate) struct PythonToolchainProvider; +pub(crate) struct PythonToolchainProvider { + fs: Arc, +} + +impl PythonToolchainProvider { + pub fn new(fs: Arc) -> Self { + Self { fs } + } +} static ENV_PRIORITY_LIST: &[PythonEnvironmentKind] = &[ // Prioritize non-Conda environments. @@ -1224,8 +1243,8 @@ impl ToolchainLister for PythonToolchainProvider { worktree_root: PathBuf, subroot_relative_path: Arc, project_env: Option>, - fs: &dyn Fs, ) -> ToolchainList { + let fs = &*self.fs; let env = project_env.unwrap_or_default(); let environment = EnvironmentApi::from_env(&env); let locators = pet::locators::create_locators( @@ -1356,8 +1375,8 @@ impl ToolchainLister for PythonToolchainProvider { &self, path: PathBuf, env: Option>, - fs: &dyn Fs, ) -> anyhow::Result { + let fs = &*self.fs; let env = env.unwrap_or_default(); let environment = EnvironmentApi::from_env(&env); let locators = pet::locators::create_locators( @@ -2508,7 +2527,7 @@ impl LspInstaller for RuffLspAdapter { } = latest_version; let destination_path = container_dir.join(format!("ruff-{name}")); let server_path = match Self::GITHUB_ASSET_KIND { - AssetKind::TarGz | AssetKind::Gz => destination_path + AssetKind::TarGz | AssetKind::TarBz2 | AssetKind::Gz => destination_path .join(Self::build_asset_name()?.0) .join("ruff"), AssetKind::Zip => destination_path.clone().join("ruff.exe"), @@ -2598,7 +2617,7 @@ impl LspInstaller for RuffLspAdapter { let path = last.context("no cached binary")?; let path = match Self::GITHUB_ASSET_KIND { - AssetKind::TarGz | AssetKind::Gz => { + AssetKind::TarGz | AssetKind::TarBz2 | AssetKind::Gz => { path.join(Self::build_asset_name()?.0).join("ruff") } AssetKind::Zip => path.join("ruff.exe"), @@ -2652,7 +2671,8 @@ mod tests { }); }); - let provider = PythonToolchainProvider; + let fs = project::FakeFs::new(cx.executor()); + let provider = PythonToolchainProvider::new(fs); let malicious_name = "foo; rm -rf /"; let manager_executable = std::env::current_exe().unwrap(); diff --git a/crates/languages/src/python/imports.scm b/crates/languages/src/python/imports.scm deleted file mode 100644 index 26538fee1b41df13f258c8b315cc5e266458efa1..0000000000000000000000000000000000000000 --- a/crates/languages/src/python/imports.scm +++ /dev/null @@ -1,38 +0,0 @@ -(import_statement - name: [ - (dotted_name - ((identifier) @namespace - ".")* - (identifier) @namespace .) - (aliased_import - name: (dotted_name - ((identifier) @namespace - ".")* - (identifier) @namespace .)) - ]) @wildcard @import - -(import_from_statement - module_name: [ - (dotted_name - ((identifier) @namespace - ".")* - (identifier) @namespace .) - (relative_import - (dotted_name - ((identifier) @namespace - ".")* - (identifier) @namespace .)?) - ] - (wildcard_import)? @wildcard - name: [ - (dotted_name - ((identifier) @namespace - ".")* - (identifier) @name .) - (aliased_import - name: (dotted_name - ((identifier) @namespace - ".")* - (identifier) @name .) - alias: (identifier) @alias) - ]?) @import diff --git a/crates/languages/src/rust.rs b/crates/languages/src/rust.rs index e463a6c62dd6a0625c8ee7c6d314b296b881157e..3bb8826d555308145847d47525cba9de84a6aa89 100644 --- a/crates/languages/src/rust.rs +++ b/crates/languages/src/rust.rs @@ -3,7 +3,7 @@ use async_trait::async_trait; use collections::HashMap; use futures::StreamExt; use futures::lock::OwnedMutexGuard; -use gpui::{App, AppContext, AsyncApp, SharedString, Task}; +use gpui::{App, AppContext, AsyncApp, Entity, SharedString, Task}; use http_client::github::AssetKind; use http_client::github::{GitHubLspBinaryVersion, latest_github_release}; use http_client::github_download::{GithubBinaryMetadata, download_server_binary}; @@ -31,11 +31,10 @@ use util::merge_json_value_into; use util::rel_path::RelPath; use util::{ResultExt, maybe}; -use crate::LanguageDir; -use crate::language_settings::language_settings; +use crate::language_settings::LanguageSettings; pub(crate) fn semantic_token_rules() -> SemanticTokenRules { - let content = LanguageDir::get("rust/semantic_token_rules.json") + let content = grammars::get_file("rust/semantic_token_rules.json") .expect("missing rust/semantic_token_rules.json"); let json = std::str::from_utf8(&content.data).expect("invalid utf-8 in semantic_token_rules"); settings::parse_json_with_comments::(json) @@ -202,6 +201,7 @@ impl RustLspAdapter { async fn build_asset_name() -> String { let extension = match Self::GITHUB_ASSET_KIND { AssetKind::TarGz => "tar.gz", + AssetKind::TarBz2 => "tar.bz2", AssetKind::Gz => "gz", AssetKind::Zip => "zip", }; @@ -262,12 +262,7 @@ impl LspAdapter for RustLspAdapter { Some("rust-analyzer/flycheck".into()) } - fn process_diagnostics( - &self, - params: &mut lsp::PublishDiagnosticsParams, - _: LanguageServerId, - _: Option<&'_ Buffer>, - ) { + fn process_diagnostics(&self, params: &mut lsp::PublishDiagnosticsParams, _: LanguageServerId) { static REGEX: LazyLock = LazyLock::new(|| Regex::new(r"(?m)`([^`]+)\n`$").expect("Failed to create REGEX")); @@ -706,7 +701,7 @@ impl LspInstaller for RustLspAdapter { } = version; let destination_path = container_dir.join(format!("rust-analyzer-{name}")); let server_path = match Self::GITHUB_ASSET_KIND { - AssetKind::TarGz | AssetKind::Gz => destination_path.clone(), // Tar and gzip extract in place. + AssetKind::TarGz | AssetKind::TarBz2 | AssetKind::Gz => destination_path.clone(), // Tar and gzip extract in place. AssetKind::Zip => destination_path.clone().join("rust-analyzer.exe"), // zip contains a .exe }; @@ -898,23 +893,16 @@ impl ContextProvider for RustContextProvider { fn associated_tasks( &self, - file: Option>, + buffer: Option>, cx: &App, ) -> Task> { const DEFAULT_RUN_NAME_STR: &str = "RUST_DEFAULT_PACKAGE_RUN"; const CUSTOM_TARGET_DIR: &str = "RUST_TARGET_DIR"; - let language_sets = language_settings(Some("Rust".into()), file.as_ref(), cx); - let package_to_run = language_sets - .tasks - .variables - .get(DEFAULT_RUN_NAME_STR) - .cloned(); - let custom_target_dir = language_sets - .tasks - .variables - .get(CUSTOM_TARGET_DIR) - .cloned(); + let language = LanguageName::new_static("Rust"); + let settings = LanguageSettings::resolve(buffer.map(|b| b.read(cx)), Some(&language), cx); + let package_to_run = settings.tasks.variables.get(DEFAULT_RUN_NAME_STR).cloned(); + let custom_target_dir = settings.tasks.variables.get(CUSTOM_TARGET_DIR).cloned(); let run_task_args = if let Some(package_to_run) = package_to_run { vec!["run".into(), "-p".into(), package_to_run] } else { @@ -1280,8 +1268,8 @@ async fn get_cached_server_binary(container_dir: PathBuf) -> Option 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 + AssetKind::TarGz | AssetKind::TarBz2 | AssetKind::Gz => path, // Tar and gzip extract in place. + AssetKind::Zip => path.join("rust-analyzer.exe"), // zip contains a .exe }; anyhow::Ok(Some(LanguageServerBinary { @@ -1364,7 +1352,7 @@ mod tests { }, ], }; - RustLspAdapter.process_diagnostics(&mut params, LanguageServerId(0), None); + RustLspAdapter.process_diagnostics(&mut params, LanguageServerId(0)); assert_eq!(params.diagnostics[0].message, "use of moved value `a`"); diff --git a/crates/languages/src/rust/imports.scm b/crates/languages/src/rust/imports.scm deleted file mode 100644 index 2c368523d63b9c6ae9494b1ab801192161fd7000..0000000000000000000000000000000000000000 --- a/crates/languages/src/rust/imports.scm +++ /dev/null @@ -1,29 +0,0 @@ -(use_declaration) @import - -(scoped_use_list - path: (_) @namespace - list: (_) @list) - -(scoped_identifier - path: (_) @namespace - name: (identifier) @name) - -(use_list - (identifier) @name) - -(use_declaration - (identifier) @name) - -(use_as_clause - path: (scoped_identifier - path: (_) @namespace - name: (_) @name) - alias: (_) @alias) - -(use_as_clause - path: (identifier) @name - alias: (_) @alias) - -(use_wildcard - (_)? @namespace - "*" @wildcard) diff --git a/crates/languages/src/tailwind.rs b/crates/languages/src/tailwind.rs index a74275af9631eea603cc957d44867d7d53327682..c78790b74c81c9a7fce89425f4499d41f343189e 100644 --- a/crates/languages/src/tailwind.rs +++ b/crates/languages/src/tailwind.rs @@ -197,11 +197,8 @@ impl LspAdapter for TailwindLspAdapter { "typescriptreact".to_string(), ), (LanguageName::new_static("Svelte"), "svelte".to_string()), - ( - LanguageName::new_static("Elixir"), - "phoenix-heex".to_string(), - ), - (LanguageName::new_static("HEEX"), "phoenix-heex".to_string()), + (LanguageName::new_static("Elixir"), "elixir".to_string()), + (LanguageName::new_static("HEEx"), "heex".to_string()), (LanguageName::new_static("ERB"), "erb".to_string()), (LanguageName::new_static("HTML+ERB"), "erb".to_string()), (LanguageName::new_static("PHP"), "php".to_string()), diff --git a/crates/languages/src/tsx/imports.scm b/crates/languages/src/tsx/imports.scm deleted file mode 100644 index 0e688d53fb6ed639c55c1fa84917711d19c3108a..0000000000000000000000000000000000000000 --- a/crates/languages/src/tsx/imports.scm +++ /dev/null @@ -1,16 +0,0 @@ -(import_statement - import_clause: (import_clause - [ - (identifier) @name - (named_imports - (import_specifier - name: (_) @name - alias: (_)? @alias)) - ]) - source: (string - (string_fragment) @source)) @import - -(import_statement - !import_clause - source: (string - (string_fragment) @source @wildcard)) @import diff --git a/crates/languages/src/typescript.rs b/crates/languages/src/typescript.rs index d15d01808137dd171cc7ee0ab440671bf58cac52..714191ace093aa4c592316692dae3db0cdc24223 100644 --- a/crates/languages/src/typescript.rs +++ b/crates/languages/src/typescript.rs @@ -3,11 +3,11 @@ use async_trait::async_trait; use chrono::{DateTime, Local}; use collections::HashMap; use futures::future::join_all; -use gpui::{App, AppContext, AsyncApp, Task}; +use gpui::{App, AppContext, AsyncApp, Entity, Task}; use itertools::Itertools as _; use language::{ - ContextLocation, ContextProvider, File, LanguageName, LanguageToolchainStore, LspAdapter, - LspAdapterDelegate, LspInstaller, Toolchain, + Buffer, ContextLocation, ContextProvider, File, LanguageName, LanguageToolchainStore, + LspAdapter, LspAdapterDelegate, LspInstaller, Toolchain, }; use lsp::{CodeActionKind, LanguageServerBinary, LanguageServerName, Uri}; use node_runtime::{NodeRuntime, VersionStrategy}; @@ -425,10 +425,11 @@ async fn detect_package_manager( impl ContextProvider for TypeScriptContextProvider { fn associated_tasks( &self, - file: Option>, + buffer: Option>, cx: &App, ) -> Task> { - let Some(file) = project::File::from_dyn(file.as_ref()).cloned() else { + let file = buffer.and_then(|buffer| buffer.read(cx).file()); + let Some(file) = project::File::from_dyn(file).cloned() else { return Task::ready(None); }; let Some(worktree_root) = file.worktree.read(cx).root_dir() else { diff --git a/crates/languages/src/typescript/imports.scm b/crates/languages/src/typescript/imports.scm deleted file mode 100644 index de8f8db418157511d5756d6b5ede1a02a03bd831..0000000000000000000000000000000000000000 --- a/crates/languages/src/typescript/imports.scm +++ /dev/null @@ -1,23 +0,0 @@ -(import_statement - import_clause: (import_clause - [ - (identifier) @name - (named_imports - (import_specifier - name: (_) @name - alias: (_)? @alias)) - (namespace_import) @wildcard - ]) - source: (string - (string_fragment) @source)) @import - -(import_statement - !source - import_clause: (import_require_clause - source: (string - (string_fragment) @source))) @wildcard @import - -(import_statement - !import_clause - source: (string - (string_fragment) @source)) @wildcard @import diff --git a/crates/livekit_client/src/livekit_client.rs b/crates/livekit_client/src/livekit_client.rs index 57b7f7c42e9f684497d508d7404a69ebc4fb6666..1c1cc5c3b7075b90950d85bbc92ba186a4f415ba 100644 --- a/crates/livekit_client/src/livekit_client.rs +++ b/crates/livekit_client/src/livekit_client.rs @@ -1,10 +1,10 @@ -use anyhow::{Context as _, Result, anyhow}; +use anyhow::{Context as _, Result}; use audio::AudioSettings; use collections::HashMap; use futures::{SinkExt, channel::mpsc}; use gpui::{App, AsyncApp, ScreenCaptureSource, ScreenCaptureStream, Task}; use gpui_tokio::Tokio; -use log::info; + use playback::capture_local_video_track; use settings::Settings; use std::sync::{Arc, atomic::AtomicU64}; @@ -13,10 +13,7 @@ use std::sync::{Arc, atomic::AtomicU64}; mod linux; mod playback; -use crate::{ - ConnectionQuality, LocalTrack, Participant, RemoteTrack, RoomEvent, TrackPublication, - livekit_client::playback::Speaker, -}; +use crate::{ConnectionQuality, LocalTrack, Participant, RemoteTrack, RoomEvent, TrackPublication}; pub use livekit::SessionStats; pub use livekit::webrtc::stats::RtcStats; pub use playback::AudioStream; @@ -144,24 +141,10 @@ impl Room { track: &RemoteAudioTrack, cx: &mut App, ) -> Result { - let speaker: Speaker = - serde_urlencoded::from_str(&track.0.name()).unwrap_or_else(|_| Speaker { - name: track.0.name(), - is_staff: false, - sends_legacy_audio: true, - }); - - if AudioSettings::get_global(cx).rodio_audio { - info!("Using experimental.rodio_audio audio pipeline for output"); - playback::play_remote_audio_track(&track.0, speaker, cx) - } else if speaker.sends_legacy_audio { - let output_audio_device = AudioSettings::get_global(cx).output_audio_device.clone(); - Ok(self - .playback - .play_remote_audio_track(&track.0, output_audio_device)) - } else { - Err(anyhow!("Client version too old to play audio in call")) - } + let output_audio_device = AudioSettings::get_global(cx).output_audio_device.clone(); + Ok(self + .playback + .play_remote_audio_track(&track.0, output_audio_device)) } pub async fn get_stats(&self) -> Result { diff --git a/crates/livekit_client/src/livekit_client/playback.rs b/crates/livekit_client/src/livekit_client/playback.rs index b887af10553e71cbe8dfa6f197538ee592daac03..cea5b1169b0c1c0c6b699884e107cf24795f5d9c 100644 --- a/crates/livekit_client/src/livekit_client/playback.rs +++ b/crates/livekit_client/src/livekit_client/playback.rs @@ -29,7 +29,7 @@ use serde::{Deserialize, Serialize}; use settings::Settings; use std::cell::RefCell; use std::sync::Weak; -use std::sync::atomic::{AtomicBool, AtomicI32, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicI32, AtomicU64, Ordering}; use std::time::{Duration, Instant}; use std::{borrow::Cow, collections::VecDeque, sync::Arc}; use util::{ResultExt as _, maybe}; @@ -39,8 +39,6 @@ struct TimestampedFrame { captured_at: Instant, } -mod source; - pub(crate) struct AudioStack { executor: BackgroundExecutor, apm: Arc>, @@ -49,38 +47,6 @@ pub(crate) struct AudioStack { next_ssrc: AtomicI32, } -pub(crate) fn play_remote_audio_track( - track: &livekit::track::RemoteAudioTrack, - speaker: Speaker, - cx: &mut gpui::App, -) -> Result { - info!("speaker: {speaker:?}"); - let stream = - source::LiveKitStream::new(cx.background_executor(), track, speaker.sends_legacy_audio); - - let stop_handle = Arc::new(AtomicBool::new(false)); - let stop_handle_clone = stop_handle.clone(); - let stream = stream - .stoppable() - .periodic_access(Duration::from_millis(50), move |s| { - if stop_handle.load(Ordering::Relaxed) { - s.stop(); - } - }); - - info!("sample_rate: {:?}", stream.sample_rate()); - info!("channel_count: {:?}", stream.channels()); - audio::Audio::play_voip_stream(stream, speaker.name, speaker.is_staff, cx) - .context("Could not play audio")?; - - let on_drop = util::defer(move || { - stop_handle_clone.store(true, Ordering::Relaxed); - }); - Ok(AudioStream::Output { - _drop: Box::new(on_drop), - }) -} - impl AudioStack { pub(crate) fn new(executor: BackgroundExecutor) -> Self { let apm = Arc::new(Mutex::new(apm::AudioProcessingModule::new( @@ -170,33 +136,17 @@ impl AudioStack { is_staff: bool, cx: &AsyncApp, ) -> Result<(crate::LocalAudioTrack, AudioStream, Arc)> { - let legacy_audio_compatible = - AudioSettings::try_read_global(cx, |setting| setting.legacy_audio_compatible) - .unwrap_or(true); - - let source = if legacy_audio_compatible { - NativeAudioSource::new( - // n.b. this struct's options are always ignored, noise cancellation is provided by apm. - AudioSourceOptions::default(), - SAMPLE_RATE.get(), // TODO(audio): this was legacy params, - // removed for now for simplicity - CHANNEL_COUNT.get().into(), - 10, - ) - } else { - NativeAudioSource::new( - // n.b. this struct's options are always ignored, noise cancellation is provided by apm. - AudioSourceOptions::default(), - SAMPLE_RATE.get(), - CHANNEL_COUNT.get().into(), - 10, - ) - }; + let source = NativeAudioSource::new( + // n.b. this struct's options are always ignored, noise cancellation is provided by apm. + AudioSourceOptions::default(), + SAMPLE_RATE.get(), + CHANNEL_COUNT.get().into(), + 10, + ); let speaker = Speaker { name: user_name, is_staff, - sends_legacy_audio: legacy_audio_compatible, }; log::info!("Microphone speaker: {speaker:?}"); let track_name = serde_urlencoded::to_string(speaker) @@ -221,21 +171,7 @@ impl AudioStack { } } }); - let rodio_pipeline = - AudioSettings::try_read_global(cx, |setting| setting.rodio_audio).unwrap_or_default(); - let capture_task = if rodio_pipeline { - info!("Using experimental.rodio_audio audio pipeline"); - let voip_parts = audio::VoipParts::new(cx)?; - // Audio needs to run real-time and should never be paused. That is - // why we are using a normal std::thread and not a background task - self.executor - .spawn_with_priority(Priority::RealtimeAudio, async move { - // microphone is non send on mac - let microphone = audio::Audio::open_microphone(voip_parts)?; - send_to_livekit(frame_tx, microphone); - Ok(()) - }) - } else { + let capture_task = { let input_audio_device = AudioSettings::try_read_global(cx, |settings| settings.input_audio_device.clone()) .flatten(); @@ -546,40 +482,6 @@ impl rodio::Source for RodioEffectsAdaptor { pub struct Speaker { pub name: String, pub is_staff: bool, - pub sends_legacy_audio: bool, -} - -fn send_to_livekit(mut frame_tx: Sender, mut microphone: impl Source) { - use cpal::Sample; - let sample_rate = microphone.sample_rate().get(); - let num_channels = microphone.channels().get() as u32; - let buffer_size = sample_rate / 100 * num_channels; - - loop { - let sampled: Vec<_> = microphone - .by_ref() - .take(buffer_size as usize) - .map(|s| s.to_sample()) - .collect(); - - match frame_tx.try_send(TimestampedFrame { - frame: AudioFrame { - sample_rate, - num_channels, - samples_per_channel: sampled.len() as u32 / num_channels, - data: Cow::Owned(sampled), - }, - captured_at: Instant::now(), - }) { - Ok(_) => {} - Err(err) => { - if !err.is_full() { - // must rx has dropped or is not consuming - break; - } - } - } - } } use super::LocalVideoTrack; diff --git a/crates/livekit_client/src/livekit_client/playback/source.rs b/crates/livekit_client/src/livekit_client/playback/source.rs deleted file mode 100644 index 2738109ff8fc972e9ab53768fd212d6f5ff5f194..0000000000000000000000000000000000000000 --- a/crates/livekit_client/src/livekit_client/playback/source.rs +++ /dev/null @@ -1,93 +0,0 @@ -use std::num::NonZero; - -use futures::StreamExt; -use libwebrtc::{audio_stream::native::NativeAudioStream, prelude::AudioFrame}; -use livekit::track::RemoteAudioTrack; -use rodio::{ - ChannelCount, SampleRate, Source, buffer::SamplesBuffer, conversions::SampleTypeConverter, -}; - -use audio::{CHANNEL_COUNT, SAMPLE_RATE}; - -fn frame_to_samplesbuffer(frame: AudioFrame) -> SamplesBuffer { - let samples = frame.data.iter().copied(); - let samples = SampleTypeConverter::<_, _>::new(samples); - let samples: Vec = samples.collect(); - SamplesBuffer::new( - NonZero::new(frame.num_channels as u16).expect("zero channels is nonsense"), - NonZero::new(frame.sample_rate).expect("samplerate zero is nonsense"), - samples, - ) -} - -pub struct LiveKitStream { - // shared_buffer: SharedBuffer, - inner: rodio::queue::SourcesQueueOutput, - _receiver_task: gpui::Task<()>, - channel_count: ChannelCount, - sample_rate: SampleRate, -} - -impl LiveKitStream { - pub fn new( - executor: &gpui::BackgroundExecutor, - track: &RemoteAudioTrack, - legacy: bool, - ) -> Self { - let (channel_count, sample_rate) = if legacy { - // (LEGACY_CHANNEL_COUNT, LEGACY_SAMPLE_RATE) TODO(audio): do this or remove - (CHANNEL_COUNT, SAMPLE_RATE) - } else { - (CHANNEL_COUNT, SAMPLE_RATE) - }; - - let mut stream = NativeAudioStream::new( - track.rtc_track(), - sample_rate.get() as i32, - channel_count.get().into(), - ); - let (queue_input, queue_output) = rodio::queue::queue(true); - // spawn rtc stream - let receiver_task = executor.spawn_with_priority(gpui::Priority::RealtimeAudio, { - async move { - while let Some(frame) = stream.next().await { - let samples = frame_to_samplesbuffer(frame); - queue_input.append(samples); - } - } - }); - - LiveKitStream { - _receiver_task: receiver_task, - inner: queue_output, - sample_rate, - channel_count, - } - } -} - -impl Iterator for LiveKitStream { - type Item = rodio::Sample; - - fn next(&mut self) -> Option { - self.inner.next() - } -} - -impl Source for LiveKitStream { - fn current_span_len(&self) -> Option { - self.inner.current_span_len() - } - - fn channels(&self) -> rodio::ChannelCount { - self.channel_count - } - - fn sample_rate(&self) -> rodio::SampleRate { - self.sample_rate - } - - fn total_duration(&self) -> Option { - self.inner.total_duration() - } -} diff --git a/crates/markdown/Cargo.toml b/crates/markdown/Cargo.toml index c923d3f704488a5364707486d25181188f74f166..be12bf2fe7f42e14c1723a8560a7f3c46ca83182 100644 --- a/crates/markdown/Cargo.toml +++ b/crates/markdown/Cargo.toml @@ -19,17 +19,23 @@ test-support = [ ] [dependencies] +anyhow.workspace = true base64.workspace = true collections.workspace = true futures.workspace = true gpui.workspace = true +html5ever.workspace = true language.workspace = true linkify.workspace = true log.workspace = true +markup5ever_rcdom.workspace = true +mermaid-rs-renderer.workspace = true pulldown-cmark.workspace = true settings.workspace = true +stacksafe.workspace = true sum_tree.workspace = true theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true diff --git a/crates/markdown/examples/markdown.rs b/crates/markdown/examples/markdown.rs index aa132443ee69201f0f1eba7b69c9627aee8f27e7..26c45377c725ec4d6701e4cf177615e3de4aba7e 100644 --- a/crates/markdown/examples/markdown.rs +++ b/crates/markdown/examples/markdown.rs @@ -41,7 +41,7 @@ pub fn main() { cx.bind_keys([KeyBinding::new("cmd-c", markdown::Copy, None)]); let node_runtime = NodeRuntime::unavailable(); - theme::init(LoadThemes::JustBase, cx); + theme_settings::init(LoadThemes::JustBase, cx); let fs = fs::FakeFs::new(cx.background_executor().clone()); let language_registry = LanguageRegistry::new(cx.background_executor().clone()); diff --git a/crates/markdown/examples/markdown_as_child.rs b/crates/markdown/examples/markdown_as_child.rs index b25b075dd3cb04ed949e1e30ed724c3b5f3088d1..38a4d2795f4ff97297c3d549f8de687827ff75ef 100644 --- a/crates/markdown/examples/markdown_as_child.rs +++ b/crates/markdown/examples/markdown_as_child.rs @@ -28,7 +28,7 @@ pub fn main() { let language_registry = Arc::new(LanguageRegistry::new(cx.background_executor().clone())); let fs = fs::FakeFs::new(cx.background_executor().clone()); languages::init(language_registry, fs, node_runtime, cx); - theme::init(LoadThemes::JustBase, cx); + theme_settings::init(LoadThemes::JustBase, cx); Assets.load_fonts(cx).unwrap(); cx.activate(true); diff --git a/crates/markdown/src/html.rs b/crates/markdown/src/html.rs new file mode 100644 index 0000000000000000000000000000000000000000..cf37f6138cd49733b5ca6f093ced9a00481f4edb --- /dev/null +++ b/crates/markdown/src/html.rs @@ -0,0 +1,3 @@ +mod html_minifier; +pub(crate) mod html_parser; +mod html_rendering; diff --git a/crates/markdown_preview/src/markdown_minifier.rs b/crates/markdown/src/html/html_minifier.rs similarity index 100% rename from crates/markdown_preview/src/markdown_minifier.rs rename to crates/markdown/src/html/html_minifier.rs diff --git a/crates/markdown/src/html/html_parser.rs b/crates/markdown/src/html/html_parser.rs new file mode 100644 index 0000000000000000000000000000000000000000..20338ec2abef2314b7cd6ca91e45ee05be909745 --- /dev/null +++ b/crates/markdown/src/html/html_parser.rs @@ -0,0 +1,883 @@ +use std::{cell::RefCell, collections::HashMap, mem, ops::Range}; + +use gpui::{DefiniteLength, FontWeight, SharedString, px, relative}; +use html5ever::{ + Attribute, LocalName, ParseOpts, local_name, parse_document, tendril::TendrilSink, +}; +use markup5ever_rcdom::{Node, NodeData, RcDom}; +use pulldown_cmark::{Alignment, HeadingLevel}; +use stacksafe::stacksafe; + +use crate::html::html_minifier::{Minifier, MinifierOptions}; + +#[derive(Debug, Clone, Default)] +#[cfg_attr(test, derive(PartialEq))] +pub(crate) struct ParsedHtmlBlock { + pub source_range: Range, + pub children: Vec, +} + +#[derive(Debug, Clone)] +#[cfg_attr(test, derive(PartialEq))] +pub(crate) enum ParsedHtmlElement { + Heading(ParsedHtmlHeading), + List(ParsedHtmlList), + Table(ParsedHtmlTable), + BlockQuote(ParsedHtmlBlockQuote), + Paragraph(HtmlParagraph), + Image(HtmlImage), +} + +impl ParsedHtmlElement { + pub fn source_range(&self) -> Option> { + Some(match self { + Self::Heading(heading) => heading.source_range.clone(), + Self::List(list) => list.source_range.clone(), + Self::Table(table) => table.source_range.clone(), + Self::BlockQuote(block_quote) => block_quote.source_range.clone(), + Self::Paragraph(text) => match text.first()? { + HtmlParagraphChunk::Text(text) => text.source_range.clone(), + HtmlParagraphChunk::Image(image) => image.source_range.clone(), + }, + Self::Image(image) => image.source_range.clone(), + }) + } +} + +pub(crate) type HtmlParagraph = Vec; + +#[derive(Debug, Clone)] +#[cfg_attr(test, derive(PartialEq))] +pub(crate) enum HtmlParagraphChunk { + Text(ParsedHtmlText), + Image(HtmlImage), +} + +#[derive(Debug, Clone)] +#[cfg_attr(test, derive(PartialEq))] +pub(crate) struct ParsedHtmlList { + pub source_range: Range, + pub depth: u16, + pub ordered: bool, + pub items: Vec, +} + +#[derive(Debug, Clone)] +#[cfg_attr(test, derive(PartialEq))] +pub(crate) struct ParsedHtmlListItem { + pub source_range: Range, + pub item_type: ParsedHtmlListItemType, + pub content: Vec, +} + +#[derive(Debug, Clone)] +#[cfg_attr(test, derive(PartialEq))] +pub(crate) enum ParsedHtmlListItemType { + Ordered(u64), + Unordered, +} + +#[derive(Debug, Clone)] +#[cfg_attr(test, derive(PartialEq))] +pub(crate) struct ParsedHtmlHeading { + pub source_range: Range, + pub level: HeadingLevel, + pub contents: HtmlParagraph, +} + +#[derive(Debug, Clone)] +#[cfg_attr(test, derive(PartialEq))] +pub(crate) struct ParsedHtmlTable { + pub source_range: Range, + pub header: Vec, + pub body: Vec, + pub caption: Option, +} + +#[derive(Debug, Clone)] +#[cfg_attr(test, derive(PartialEq))] +pub(crate) struct ParsedHtmlTableColumn { + pub col_span: usize, + pub row_span: usize, + pub is_header: bool, + pub children: HtmlParagraph, + pub alignment: Alignment, +} + +#[derive(Debug, Clone, Default)] +#[cfg_attr(test, derive(PartialEq))] +pub(crate) struct ParsedHtmlTableRow { + pub columns: Vec, +} + +#[derive(Debug, Clone)] +#[cfg_attr(test, derive(PartialEq))] +pub(crate) struct ParsedHtmlBlockQuote { + pub source_range: Range, + pub children: Vec, +} + +#[derive(Debug, Clone)] +#[cfg_attr(test, derive(PartialEq))] +pub(crate) struct ParsedHtmlText { + pub source_range: Range, + pub contents: SharedString, + pub highlights: Vec<(Range, HtmlHighlightStyle)>, + pub links: Vec<(Range, SharedString)>, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct HtmlHighlightStyle { + pub italic: bool, + pub underline: bool, + pub strikethrough: bool, + pub weight: FontWeight, + pub link: bool, + pub oblique: bool, +} + +#[derive(Debug, Clone)] +#[cfg_attr(test, derive(PartialEq))] +pub(crate) struct HtmlImage { + pub dest_url: SharedString, + pub source_range: Range, + pub alt_text: Option, + pub width: Option, + pub height: Option, +} + +impl HtmlImage { + fn new(dest_url: String, source_range: Range) -> Self { + Self { + dest_url: dest_url.into(), + source_range, + alt_text: None, + width: None, + height: None, + } + } + + fn set_alt_text(&mut self, alt_text: SharedString) { + self.alt_text = Some(alt_text); + } + + fn set_width(&mut self, width: DefiniteLength) { + self.width = Some(width); + } + + fn set_height(&mut self, height: DefiniteLength) { + self.height = Some(height); + } +} + +#[derive(Debug)] +struct ParseHtmlNodeContext { + list_item_depth: u16, +} + +impl Default for ParseHtmlNodeContext { + fn default() -> Self { + Self { list_item_depth: 1 } + } +} + +pub(crate) fn parse_html_block( + source: &str, + source_range: Range, +) -> Option { + let bytes = cleanup_html(source); + let mut cursor = std::io::Cursor::new(bytes); + let dom = parse_document(RcDom::default(), ParseOpts::default()) + .from_utf8() + .read_from(&mut cursor) + .ok()?; + + let mut children = Vec::new(); + parse_html_node( + source_range.clone(), + &dom.document, + &mut children, + &ParseHtmlNodeContext::default(), + ); + + Some(ParsedHtmlBlock { + source_range, + children, + }) +} + +fn cleanup_html(source: &str) -> Vec { + let mut writer = std::io::Cursor::new(Vec::new()); + let mut reader = std::io::Cursor::new(source); + let mut minify = Minifier::new( + &mut writer, + MinifierOptions { + omit_doctype: true, + collapse_whitespace: true, + ..Default::default() + }, + ); + if let Ok(()) = minify.minify(&mut reader) { + writer.into_inner() + } else { + source.bytes().collect() + } +} + +#[stacksafe] +fn parse_html_node( + source_range: Range, + node: &Node, + elements: &mut Vec, + context: &ParseHtmlNodeContext, +) { + match &node.data { + NodeData::Document => { + consume_children(source_range, node, elements, context); + } + NodeData::Text { contents } => { + elements.push(ParsedHtmlElement::Paragraph(vec![ + HtmlParagraphChunk::Text(ParsedHtmlText { + source_range, + highlights: Vec::default(), + links: Vec::default(), + contents: contents.borrow().to_string().into(), + }), + ])); + } + NodeData::Comment { .. } => {} + NodeData::Element { name, attrs, .. } => { + let mut styles = if let Some(styles) = + html_style_from_html_styles(extract_styles_from_attributes(attrs)) + { + vec![styles] + } else { + Vec::default() + }; + + if name.local == local_name!("img") { + if let Some(image) = extract_image(source_range, attrs) { + elements.push(ParsedHtmlElement::Image(image)); + } + } else if name.local == local_name!("p") { + let mut paragraph = HtmlParagraph::new(); + parse_paragraph( + source_range, + node, + &mut paragraph, + &mut styles, + &mut Vec::new(), + ); + + if !paragraph.is_empty() { + elements.push(ParsedHtmlElement::Paragraph(paragraph)); + } + } else if matches!( + name.local, + local_name!("h1") + | local_name!("h2") + | local_name!("h3") + | local_name!("h4") + | local_name!("h5") + | local_name!("h6") + ) { + let mut paragraph = HtmlParagraph::new(); + consume_paragraph( + source_range.clone(), + node, + &mut paragraph, + &mut styles, + &mut Vec::new(), + ); + + if !paragraph.is_empty() { + elements.push(ParsedHtmlElement::Heading(ParsedHtmlHeading { + source_range, + level: match name.local { + local_name!("h1") => HeadingLevel::H1, + local_name!("h2") => HeadingLevel::H2, + local_name!("h3") => HeadingLevel::H3, + local_name!("h4") => HeadingLevel::H4, + local_name!("h5") => HeadingLevel::H5, + local_name!("h6") => HeadingLevel::H6, + _ => unreachable!(), + }, + contents: paragraph, + })); + } + } else if name.local == local_name!("ul") || name.local == local_name!("ol") { + if let Some(list) = extract_html_list( + node, + name.local == local_name!("ol"), + context.list_item_depth, + source_range, + ) { + elements.push(ParsedHtmlElement::List(list)); + } + } else if name.local == local_name!("blockquote") { + if let Some(blockquote) = extract_html_blockquote(node, source_range) { + elements.push(ParsedHtmlElement::BlockQuote(blockquote)); + } + } else if name.local == local_name!("table") { + if let Some(table) = extract_html_table(node, source_range) { + elements.push(ParsedHtmlElement::Table(table)); + } + } else { + consume_children(source_range, node, elements, context); + } + } + _ => {} + } +} + +#[stacksafe] +fn parse_paragraph( + source_range: Range, + node: &Node, + paragraph: &mut HtmlParagraph, + highlights: &mut Vec, + links: &mut Vec, +) { + fn items_with_range( + range: Range, + items: impl IntoIterator, + ) -> Vec<(Range, T)> { + items + .into_iter() + .map(|item| (range.clone(), item)) + .collect() + } + + match &node.data { + NodeData::Text { contents } => { + if let Some(text) = + paragraph + .iter_mut() + .last() + .and_then(|paragraph_chunk| match paragraph_chunk { + HtmlParagraphChunk::Text(text) => Some(text), + _ => None, + }) + { + let mut new_text = text.contents.to_string(); + new_text.push_str(&contents.borrow()); + + text.highlights.extend(items_with_range( + text.contents.len()..new_text.len(), + mem::take(highlights), + )); + text.links.extend(items_with_range( + text.contents.len()..new_text.len(), + mem::take(links), + )); + text.contents = SharedString::from(new_text); + } else { + let contents = contents.borrow().to_string(); + paragraph.push(HtmlParagraphChunk::Text(ParsedHtmlText { + source_range, + highlights: items_with_range(0..contents.len(), mem::take(highlights)), + links: items_with_range(0..contents.len(), mem::take(links)), + contents: contents.into(), + })); + } + } + NodeData::Element { name, attrs, .. } => { + if name.local == local_name!("img") { + if let Some(image) = extract_image(source_range, attrs) { + paragraph.push(HtmlParagraphChunk::Image(image)); + } + } else if name.local == local_name!("b") || name.local == local_name!("strong") { + highlights.push(HtmlHighlightStyle { + weight: FontWeight::BOLD, + ..Default::default() + }); + consume_paragraph(source_range, node, paragraph, highlights, links); + } else if name.local == local_name!("i") { + highlights.push(HtmlHighlightStyle { + italic: true, + ..Default::default() + }); + consume_paragraph(source_range, node, paragraph, highlights, links); + } else if name.local == local_name!("em") { + highlights.push(HtmlHighlightStyle { + oblique: true, + ..Default::default() + }); + consume_paragraph(source_range, node, paragraph, highlights, links); + } else if name.local == local_name!("del") { + highlights.push(HtmlHighlightStyle { + strikethrough: true, + ..Default::default() + }); + consume_paragraph(source_range, node, paragraph, highlights, links); + } else if name.local == local_name!("ins") { + highlights.push(HtmlHighlightStyle { + underline: true, + ..Default::default() + }); + consume_paragraph(source_range, node, paragraph, highlights, links); + } else if name.local == local_name!("a") { + if let Some(url) = attr_value(attrs, local_name!("href")) { + highlights.push(HtmlHighlightStyle { + link: true, + ..Default::default() + }); + links.push(url.into()); + } + consume_paragraph(source_range, node, paragraph, highlights, links); + } else { + consume_paragraph(source_range, node, paragraph, highlights, links); + } + } + _ => {} + } +} + +fn consume_paragraph( + source_range: Range, + node: &Node, + paragraph: &mut HtmlParagraph, + highlights: &mut Vec, + links: &mut Vec, +) { + for child in node.children.borrow().iter() { + parse_paragraph(source_range.clone(), child, paragraph, highlights, links); + } +} + +fn parse_table_row(source_range: Range, node: &Node) -> Option { + let mut columns = Vec::new(); + + if let NodeData::Element { name, .. } = &node.data { + if name.local != local_name!("tr") { + return None; + } + + for child in node.children.borrow().iter() { + if let Some(column) = parse_table_column(source_range.clone(), child) { + columns.push(column); + } + } + } + + if columns.is_empty() { + None + } else { + Some(ParsedHtmlTableRow { columns }) + } +} + +fn parse_table_column(source_range: Range, node: &Node) -> Option { + match &node.data { + NodeData::Element { name, attrs, .. } => { + if !matches!(name.local, local_name!("th") | local_name!("td")) { + return None; + } + + let mut children = HtmlParagraph::new(); + consume_paragraph( + source_range, + node, + &mut children, + &mut Vec::new(), + &mut Vec::new(), + ); + + let is_header = name.local == local_name!("th"); + + Some(ParsedHtmlTableColumn { + col_span: std::cmp::max( + attr_value(attrs, local_name!("colspan")) + .and_then(|span| span.parse().ok()) + .unwrap_or(1), + 1, + ), + row_span: std::cmp::max( + attr_value(attrs, local_name!("rowspan")) + .and_then(|span| span.parse().ok()) + .unwrap_or(1), + 1, + ), + is_header, + children, + alignment: attr_value(attrs, local_name!("align")) + .and_then(|align| match align.as_str() { + "left" => Some(Alignment::Left), + "center" => Some(Alignment::Center), + "right" => Some(Alignment::Right), + _ => None, + }) + .unwrap_or(if is_header { + Alignment::Center + } else { + Alignment::None + }), + }) + } + _ => None, + } +} + +fn consume_children( + source_range: Range, + node: &Node, + elements: &mut Vec, + context: &ParseHtmlNodeContext, +) { + for child in node.children.borrow().iter() { + parse_html_node(source_range.clone(), child, elements, context); + } +} + +fn attr_value(attrs: &RefCell>, name: LocalName) -> Option { + attrs.borrow().iter().find_map(|attr| { + if attr.name.local == name { + Some(attr.value.to_string()) + } else { + None + } + }) +} + +fn html_style_from_html_styles(styles: HashMap) -> Option { + let mut html_style = HtmlHighlightStyle::default(); + + if let Some(text_decoration) = styles.get("text-decoration") { + match text_decoration.to_lowercase().as_str() { + "underline" => { + html_style.underline = true; + } + "line-through" => { + html_style.strikethrough = true; + } + _ => {} + } + } + + if let Some(font_style) = styles.get("font-style") { + match font_style.to_lowercase().as_str() { + "italic" => { + html_style.italic = true; + } + "oblique" => { + html_style.oblique = true; + } + _ => {} + } + } + + if let Some(font_weight) = styles.get("font-weight") { + match font_weight.to_lowercase().as_str() { + "bold" => { + html_style.weight = FontWeight::BOLD; + } + "lighter" => { + html_style.weight = FontWeight::THIN; + } + _ => { + if let Ok(weight) = font_weight.parse::() { + html_style.weight = FontWeight(weight); + } + } + } + } + + if html_style != HtmlHighlightStyle::default() { + Some(html_style) + } else { + None + } +} + +fn extract_styles_from_attributes(attrs: &RefCell>) -> HashMap { + let mut styles = HashMap::new(); + + if let Some(style) = attr_value(attrs, local_name!("style")) { + for declaration in style.split(';') { + let mut parts = declaration.splitn(2, ':'); + if let Some((key, value)) = parts.next().zip(parts.next()) { + styles.insert(key.trim().to_lowercase(), value.trim().to_string()); + } + } + } + + styles +} + +fn extract_image(source_range: Range, attrs: &RefCell>) -> Option { + let src = attr_value(attrs, local_name!("src"))?; + + let mut image = HtmlImage::new(src, source_range); + + if let Some(alt) = attr_value(attrs, local_name!("alt")) { + image.set_alt_text(alt.into()); + } + + let styles = extract_styles_from_attributes(attrs); + + if let Some(width) = attr_value(attrs, local_name!("width")) + .or_else(|| styles.get("width").cloned()) + .and_then(|width| parse_html_element_dimension(&width)) + { + image.set_width(width); + } + + if let Some(height) = attr_value(attrs, local_name!("height")) + .or_else(|| styles.get("height").cloned()) + .and_then(|height| parse_html_element_dimension(&height)) + { + image.set_height(height); + } + + Some(image) +} + +fn extract_html_list( + node: &Node, + ordered: bool, + depth: u16, + source_range: Range, +) -> Option { + let mut items = Vec::with_capacity(node.children.borrow().len()); + + for (index, child) in node.children.borrow().iter().enumerate() { + if let NodeData::Element { name, .. } = &child.data { + if name.local != local_name!("li") { + continue; + } + + let mut content = Vec::new(); + consume_children( + source_range.clone(), + child, + &mut content, + &ParseHtmlNodeContext { + list_item_depth: depth + 1, + }, + ); + + if !content.is_empty() { + items.push(ParsedHtmlListItem { + source_range: source_range.clone(), + item_type: if ordered { + ParsedHtmlListItemType::Ordered(index as u64 + 1) + } else { + ParsedHtmlListItemType::Unordered + }, + content, + }); + } + } + } + + if items.is_empty() { + None + } else { + Some(ParsedHtmlList { + source_range, + depth, + ordered, + items, + }) + } +} + +fn parse_html_element_dimension(value: &str) -> Option { + if value.ends_with('%') { + value + .trim_end_matches('%') + .parse::() + .ok() + .map(|value| relative(value / 100.)) + } else { + value + .trim_end_matches("px") + .parse() + .ok() + .map(|value| px(value).into()) + } +} + +fn extract_html_blockquote( + node: &Node, + source_range: Range, +) -> Option { + let mut children = Vec::new(); + consume_children( + source_range.clone(), + node, + &mut children, + &ParseHtmlNodeContext::default(), + ); + + if children.is_empty() { + None + } else { + Some(ParsedHtmlBlockQuote { + children, + source_range, + }) + } +} + +fn extract_html_table(node: &Node, source_range: Range) -> Option { + let mut header_rows = Vec::new(); + let mut body_rows = Vec::new(); + let mut caption = None; + + for child in node.children.borrow().iter() { + if let NodeData::Element { name, .. } = &child.data { + if name.local == local_name!("caption") { + let mut paragraph = HtmlParagraph::new(); + parse_paragraph( + source_range.clone(), + child, + &mut paragraph, + &mut Vec::new(), + &mut Vec::new(), + ); + caption = Some(paragraph); + } + + if name.local == local_name!("thead") { + for row in child.children.borrow().iter() { + if let Some(row) = parse_table_row(source_range.clone(), row) { + header_rows.push(row); + } + } + } else if name.local == local_name!("tbody") { + for row in child.children.borrow().iter() { + if let Some(row) = parse_table_row(source_range.clone(), row) { + body_rows.push(row); + } + } + } + } + } + + if !header_rows.is_empty() || !body_rows.is_empty() { + Some(ParsedHtmlTable { + source_range, + body: body_rows, + header: header_rows, + caption, + }) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_html_styled_text() { + let parsed = parse_html_block( + "", + 0..79, + ) + .unwrap(); + + assert_eq!(parsed.children.len(), 1); + let ParsedHtmlElement::Paragraph(paragraph) = &parsed.children[0] else { + panic!("expected paragraph"); + }; + let HtmlParagraphChunk::Text(text) = ¶graph[0] else { + panic!("expected text chunk"); + }; + + assert_eq!(text.contents.as_ref(), "Some text strong link"); + assert_eq!( + text.highlights, + vec![ + ( + 10..16, + HtmlHighlightStyle { + weight: FontWeight::BOLD, + ..Default::default() + } + ), + ( + 17..21, + HtmlHighlightStyle { + link: true, + ..Default::default() + } + ) + ] + ); + assert_eq!( + text.links, + vec![(17..21, SharedString::from("https://example.com"))] + ); + } + + #[test] + fn parses_html_table_spans() { + let parsed = parse_html_block( + "
a
bc
", + 0..91, + ) + .unwrap(); + + let ParsedHtmlElement::Table(table) = &parsed.children[0] else { + panic!("expected table"); + }; + assert_eq!(table.body.len(), 2); + assert_eq!(table.body[0].columns[0].col_span, 2); + assert_eq!(table.body[1].columns.len(), 2); + } + + #[test] + fn parses_html_list_as_explicit_list_node() { + let parsed = parse_html_block( + "
  • parent
    • child
  • sibling
", + 0..64, + ) + .unwrap(); + + assert_eq!(parsed.children.len(), 1); + + let ParsedHtmlElement::List(list) = &parsed.children[0] else { + panic!("expected list"); + }; + + assert!(!list.ordered); + assert_eq!(list.depth, 1); + assert_eq!(list.items.len(), 2); + + let first_item = &list.items[0]; + let ParsedHtmlElement::Paragraph(paragraph) = &first_item.content[0] else { + panic!("expected first item paragraph"); + }; + let HtmlParagraphChunk::Text(text) = ¶graph[0] else { + panic!("expected first item text"); + }; + assert_eq!(text.contents.as_ref(), "parent"); + + let ParsedHtmlElement::List(nested_list) = &first_item.content[1] else { + panic!("expected nested list"); + }; + assert_eq!(nested_list.depth, 2); + assert_eq!(nested_list.items.len(), 1); + + let ParsedHtmlElement::Paragraph(nested_paragraph) = &nested_list.items[0].content[0] + else { + panic!("expected nested item paragraph"); + }; + let HtmlParagraphChunk::Text(nested_text) = &nested_paragraph[0] else { + panic!("expected nested item text"); + }; + assert_eq!(nested_text.contents.as_ref(), "child"); + + let second_item = &list.items[1]; + let ParsedHtmlElement::Paragraph(second_paragraph) = &second_item.content[0] else { + panic!("expected second item paragraph"); + }; + let HtmlParagraphChunk::Text(second_text) = &second_paragraph[0] else { + panic!("expected second item text"); + }; + assert_eq!(second_text.contents.as_ref(), "sibling"); + } +} diff --git a/crates/markdown/src/html/html_rendering.rs b/crates/markdown/src/html/html_rendering.rs new file mode 100644 index 0000000000000000000000000000000000000000..56ab2db26b682e197c194157a87e646d9e55019d --- /dev/null +++ b/crates/markdown/src/html/html_rendering.rs @@ -0,0 +1,613 @@ +use std::ops::Range; + +use gpui::{App, FontStyle, FontWeight, StrikethroughStyle, TextStyleRefinement, UnderlineStyle}; +use pulldown_cmark::Alignment; +use ui::prelude::*; + +use crate::html::html_parser::{ + HtmlHighlightStyle, HtmlImage, HtmlParagraph, HtmlParagraphChunk, ParsedHtmlBlock, + ParsedHtmlElement, ParsedHtmlList, ParsedHtmlListItemType, ParsedHtmlTable, ParsedHtmlTableRow, + ParsedHtmlText, +}; +use crate::{MarkdownElement, MarkdownElementBuilder}; + +pub(crate) struct HtmlSourceAllocator { + source_range: Range, + next_source_index: usize, +} + +impl HtmlSourceAllocator { + pub(crate) fn new(source_range: Range) -> Self { + Self { + next_source_index: source_range.start, + source_range, + } + } + + pub(crate) fn allocate(&mut self, requested_len: usize) -> Range { + let remaining = self.source_range.end.saturating_sub(self.next_source_index); + let len = requested_len.min(remaining); + let start = self.next_source_index; + let end = start + len; + self.next_source_index = end; + start..end + } +} + +impl MarkdownElement { + pub(crate) fn render_html_block( + &self, + block: &ParsedHtmlBlock, + builder: &mut MarkdownElementBuilder, + markdown_end: usize, + cx: &mut App, + ) { + let mut source_allocator = HtmlSourceAllocator::new(block.source_range.clone()); + self.render_html_elements( + &block.children, + &mut source_allocator, + builder, + markdown_end, + cx, + ); + } + + fn render_html_elements( + &self, + elements: &[ParsedHtmlElement], + source_allocator: &mut HtmlSourceAllocator, + builder: &mut MarkdownElementBuilder, + markdown_end: usize, + cx: &mut App, + ) { + for element in elements { + self.render_html_element(element, source_allocator, builder, markdown_end, cx); + } + } + + fn render_html_element( + &self, + element: &ParsedHtmlElement, + source_allocator: &mut HtmlSourceAllocator, + builder: &mut MarkdownElementBuilder, + markdown_end: usize, + cx: &mut App, + ) { + let Some(source_range) = element.source_range() else { + return; + }; + + match element { + ParsedHtmlElement::Paragraph(paragraph) => { + self.push_markdown_paragraph(builder, &source_range, markdown_end); + self.render_html_paragraph(paragraph, source_allocator, builder, cx, markdown_end); + builder.pop_div(); + } + ParsedHtmlElement::Heading(heading) => { + self.push_markdown_heading( + builder, + heading.level, + &heading.source_range, + markdown_end, + ); + self.render_html_paragraph( + &heading.contents, + source_allocator, + builder, + cx, + markdown_end, + ); + self.pop_markdown_heading(builder); + } + ParsedHtmlElement::List(list) => { + self.render_html_list(list, source_allocator, builder, markdown_end, cx); + } + ParsedHtmlElement::BlockQuote(block_quote) => { + self.push_markdown_block_quote(builder, &block_quote.source_range, markdown_end); + self.render_html_elements( + &block_quote.children, + source_allocator, + builder, + markdown_end, + cx, + ); + self.pop_markdown_block_quote(builder); + } + ParsedHtmlElement::Table(table) => { + self.render_html_table(table, source_allocator, builder, markdown_end, cx); + } + ParsedHtmlElement::Image(image) => { + self.render_html_image(image, builder); + } + } + } + + fn render_html_list( + &self, + list: &ParsedHtmlList, + source_allocator: &mut HtmlSourceAllocator, + builder: &mut MarkdownElementBuilder, + markdown_end: usize, + cx: &mut App, + ) { + builder.push_div(div().pl_2p5(), &list.source_range, markdown_end); + + for list_item in &list.items { + let bullet = match list_item.item_type { + ParsedHtmlListItemType::Ordered(order) => html_list_item_prefix( + order as usize, + list.ordered, + list.depth.saturating_sub(1) as usize, + ), + ParsedHtmlListItemType::Unordered => { + html_list_item_prefix(1, false, list.depth.saturating_sub(1) as usize) + } + }; + + self.push_markdown_list_item( + builder, + div().child(bullet).into_any_element(), + &list_item.source_range, + markdown_end, + ); + self.render_html_elements( + &list_item.content, + source_allocator, + builder, + markdown_end, + cx, + ); + self.pop_markdown_list_item(builder); + } + + builder.pop_div(); + } + + fn render_html_table( + &self, + table: &ParsedHtmlTable, + source_allocator: &mut HtmlSourceAllocator, + builder: &mut MarkdownElementBuilder, + markdown_end: usize, + cx: &mut App, + ) { + if let Some(caption) = &table.caption { + builder.push_div( + div().when(!self.style.height_is_multiple_of_line_height, |el| { + el.mb_2().line_height(rems(1.3)) + }), + &table.source_range, + markdown_end, + ); + self.render_html_paragraph(caption, source_allocator, builder, cx, markdown_end); + builder.pop_div(); + } + + let actual_header_column_count = html_table_columns_count(&table.header); + let actual_body_column_count = html_table_columns_count(&table.body); + let max_column_count = actual_header_column_count.max(actual_body_column_count); + + if max_column_count == 0 { + return; + } + + let total_rows = table.header.len() + table.body.len(); + let mut grid_occupied = vec![vec![false; max_column_count]; total_rows]; + + builder.push_div( + div() + .id(("html-table", table.source_range.start)) + .grid() + .grid_cols(max_column_count as u16) + .when(self.style.table_columns_min_size, |this| { + this.grid_cols_min_content(max_column_count as u16) + }) + .when(!self.style.table_columns_min_size, |this| { + this.grid_cols(max_column_count as u16) + }) + .w_full() + .mb_2() + .border(px(1.5)) + .border_color(cx.theme().colors().border) + .rounded_sm() + .overflow_hidden(), + &table.source_range, + markdown_end, + ); + + for (row_index, row) in table.header.iter().chain(table.body.iter()).enumerate() { + let mut column_index = 0; + + for cell in &row.columns { + while column_index < max_column_count && grid_occupied[row_index][column_index] { + column_index += 1; + } + + if column_index >= max_column_count { + break; + } + + let max_span = max_column_count.saturating_sub(column_index); + let mut cell_div = div() + .col_span(cell.col_span.min(max_span) as u16) + .row_span(cell.row_span.min(total_rows - row_index) as u16) + .when(column_index > 0, |this| this.border_l_1()) + .when(row_index > 0, |this| this.border_t_1()) + .border_color(cx.theme().colors().border) + .px_2() + .py_1() + .when(cell.is_header, |this| { + this.bg(cx.theme().colors().title_bar_background) + }) + .when(!cell.is_header && row_index % 2 == 1, |this| { + this.bg(cx.theme().colors().panel_background) + }); + + cell_div = match cell.alignment { + Alignment::Center => cell_div.items_center(), + Alignment::Right => cell_div.items_end(), + _ => cell_div, + }; + + builder.push_div(cell_div, &table.source_range, markdown_end); + self.render_html_paragraph( + &cell.children, + source_allocator, + builder, + cx, + markdown_end, + ); + builder.pop_div(); + + for row_offset in 0..cell.row_span { + for column_offset in 0..cell.col_span { + if row_index + row_offset < total_rows + && column_index + column_offset < max_column_count + { + grid_occupied[row_index + row_offset][column_index + column_offset] = + true; + } + } + } + + column_index += cell.col_span; + } + + while column_index < max_column_count { + if grid_occupied[row_index][column_index] { + column_index += 1; + continue; + } + + builder.push_div( + div() + .when(column_index > 0, |this| this.border_l_1()) + .when(row_index > 0, |this| this.border_t_1()) + .border_color(cx.theme().colors().border) + .when(row_index % 2 == 1, |this| { + this.bg(cx.theme().colors().panel_background) + }), + &table.source_range, + markdown_end, + ); + builder.pop_div(); + column_index += 1; + } + } + + builder.pop_div(); + } + + fn render_html_paragraph( + &self, + paragraph: &HtmlParagraph, + source_allocator: &mut HtmlSourceAllocator, + builder: &mut MarkdownElementBuilder, + cx: &mut App, + _markdown_end: usize, + ) { + for chunk in paragraph { + match chunk { + HtmlParagraphChunk::Text(text) => { + self.render_html_text(text, source_allocator, builder, cx); + } + HtmlParagraphChunk::Image(image) => { + self.render_html_image(image, builder); + } + } + } + } + + fn render_html_text( + &self, + text: &ParsedHtmlText, + source_allocator: &mut HtmlSourceAllocator, + builder: &mut MarkdownElementBuilder, + cx: &mut App, + ) { + let text_contents = text.contents.as_ref(); + if text_contents.is_empty() { + return; + } + + let allocated_range = source_allocator.allocate(text_contents.len()); + let allocated_len = allocated_range.end.saturating_sub(allocated_range.start); + + let mut boundaries = vec![0, text_contents.len()]; + for (range, _) in &text.highlights { + boundaries.push(range.start); + boundaries.push(range.end); + } + for (range, _) in &text.links { + boundaries.push(range.start); + boundaries.push(range.end); + } + boundaries.sort_unstable(); + boundaries.dedup(); + + for segment in boundaries.windows(2) { + let start = segment[0]; + let end = segment[1]; + if start >= end { + continue; + } + + let source_start = allocated_range.start + start.min(allocated_len); + let source_end = allocated_range.start + end.min(allocated_len); + if source_start >= source_end { + continue; + } + + let mut refinement = TextStyleRefinement::default(); + let mut has_refinement = false; + + for (highlight_range, style) in &text.highlights { + if highlight_range.start < end && highlight_range.end > start { + apply_html_highlight_style(&mut refinement, style); + has_refinement = true; + } + } + + let link = text.links.iter().find_map(|(link_range, link)| { + if link_range.start < end && link_range.end > start { + Some(link.clone()) + } else { + None + } + }); + + if let Some(link) = link.as_ref() { + builder.push_link(link.clone(), source_start..source_end); + let link_style = self + .style + .link_callback + .as_ref() + .and_then(|callback| callback(link.as_ref(), cx)) + .unwrap_or_else(|| self.style.link.clone()); + builder.push_text_style(link_style); + } + + if has_refinement { + builder.push_text_style(refinement); + } + + builder.push_text(&text_contents[start..end], source_start..source_end); + + if has_refinement { + builder.pop_text_style(); + } + + if link.is_some() { + builder.pop_text_style(); + } + } + } + + fn render_html_image(&self, image: &HtmlImage, builder: &mut MarkdownElementBuilder) { + let Some(source) = self + .image_resolver + .as_ref() + .and_then(|resolve| resolve(image.dest_url.as_ref())) + else { + return; + }; + + self.push_markdown_image( + builder, + &image.source_range, + source, + image.width, + image.height, + ); + } +} + +fn apply_html_highlight_style(refinement: &mut TextStyleRefinement, style: &HtmlHighlightStyle) { + if style.weight != FontWeight::default() { + refinement.font_weight = Some(style.weight); + } + + if style.oblique { + refinement.font_style = Some(FontStyle::Oblique); + } else if style.italic { + refinement.font_style = Some(FontStyle::Italic); + } + + if style.underline { + refinement.underline = Some(UnderlineStyle { + thickness: px(1.), + color: None, + ..Default::default() + }); + } + + if style.strikethrough { + refinement.strikethrough = Some(StrikethroughStyle { + thickness: px(1.), + color: None, + }); + } +} + +fn html_list_item_prefix(order: usize, ordered: bool, depth: usize) -> String { + let index = order.saturating_sub(1); + const NUMBERED_PREFIXES_1: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + const NUMBERED_PREFIXES_2: &str = "abcdefghijklmnopqrstuvwxyz"; + const BULLETS: [&str; 5] = ["•", "◦", "▪", "‣", "⁃"]; + + if ordered { + match depth { + 0 => format!("{}. ", order), + 1 => format!( + "{}. ", + NUMBERED_PREFIXES_1 + .chars() + .nth(index % NUMBERED_PREFIXES_1.len()) + .unwrap() + ), + _ => format!( + "{}. ", + NUMBERED_PREFIXES_2 + .chars() + .nth(index % NUMBERED_PREFIXES_2.len()) + .unwrap() + ), + } + } else { + let depth = depth.min(BULLETS.len() - 1); + format!("{} ", BULLETS[depth]) + } +} + +fn html_table_columns_count(rows: &[ParsedHtmlTableRow]) -> usize { + let mut actual_column_count = 0; + for row in rows { + actual_column_count = actual_column_count.max( + row.columns + .iter() + .map(|column| column.col_span) + .sum::(), + ); + } + actual_column_count +} + +#[cfg(test)] +mod tests { + use gpui::{TestAppContext, size}; + use ui::prelude::*; + + use crate::{CodeBlockRenderer, Markdown, MarkdownElement, MarkdownOptions, MarkdownStyle}; + + fn ensure_theme_initialized(cx: &mut TestAppContext) { + cx.update(|cx| { + if !cx.has_global::() { + settings::init(cx); + } + if !cx.has_global::() { + theme_settings::init(theme::LoadThemes::JustBase, cx); + } + }); + } + + fn render_markdown_text(markdown: &str, cx: &mut TestAppContext) -> crate::RenderedText { + struct TestWindow; + + impl Render for TestWindow { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + } + } + + ensure_theme_initialized(cx); + + let (_, cx) = cx.add_window_view(|_, _| TestWindow); + let markdown = cx.new(|cx| Markdown::new(markdown.to_string().into(), None, None, cx)); + cx.run_until_parked(); + let (rendered, _) = cx.draw( + Default::default(), + size(px(600.0), px(600.0)), + |_window, _cx| { + MarkdownElement::new(markdown, MarkdownStyle::default()).code_block_renderer( + CodeBlockRenderer::Default { + copy_button: false, + copy_button_on_hover: false, + border: false, + }, + ) + }, + ); + rendered.text + } + + #[gpui::test] + fn test_html_block_rendering_smoke(cx: &mut TestAppContext) { + let rendered = render_markdown_text( + "

Hello

world

  • item
", + cx, + ); + + let rendered_lines = rendered + .lines + .iter() + .map(|line| line.layout.wrapped_text()) + .collect::>(); + + assert_eq!( + rendered_lines.concat().replace('\n', ""), + "

Hello

world

  • item
" + ); + } + + #[gpui::test] + fn test_html_block_rendering_can_be_enabled(cx: &mut TestAppContext) { + struct TestWindow; + + impl Render for TestWindow { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + } + } + + ensure_theme_initialized(cx); + + let (_, cx) = cx.add_window_view(|_, _| TestWindow); + let markdown = cx.new(|cx| { + Markdown::new_with_options( + "

Hello

world

  • item
".into(), + None, + None, + MarkdownOptions { + parse_html: true, + ..Default::default() + }, + cx, + ) + }); + cx.run_until_parked(); + let (rendered, _) = cx.draw( + Default::default(), + size(px(600.0), px(600.0)), + |_window, _cx| { + MarkdownElement::new(markdown, MarkdownStyle::default()).code_block_renderer( + CodeBlockRenderer::Default { + copy_button: false, + copy_button_on_hover: false, + border: false, + }, + ) + }, + ); + + let rendered_lines = rendered + .text + .lines + .iter() + .map(|line| line.layout.wrapped_text()) + .collect::>(); + + assert_eq!(rendered_lines[0], "Hello"); + assert_eq!(rendered_lines[1], "world"); + assert!(rendered_lines.iter().any(|line| line.contains("item"))); + } +} diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index cc4a0187c540a149693e696663bd8756408e5d64..7b95688df54610f92b6960d9afc3037bf484b8ed 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -1,3 +1,5 @@ +pub mod html; +mod mermaid; pub mod parser; mod path_range; @@ -7,10 +9,14 @@ use gpui::EdgesRefinement; use gpui::HitboxBehavior; use gpui::UnderlineStyle; use language::LanguageName; + use log::Level; +use mermaid::{ + MermaidState, ParsedMarkdownMermaidDiagram, extract_mermaid_diagrams, render_mermaid_diagram, +}; pub use path_range::{LineCol, PathWithRange}; use settings::Settings as _; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::Checkbox; use ui::CopyButton; @@ -28,13 +34,16 @@ use collections::{HashMap, HashSet}; use gpui::{ AnyElement, App, BorderStyle, Bounds, ClipboardItem, CursorStyle, DispatchPhase, Edges, Entity, FocusHandle, Focusable, FontStyle, FontWeight, GlobalElementId, Hitbox, Hsla, Image, - ImageFormat, KeyContext, Length, MouseButton, MouseDownEvent, MouseEvent, MouseMoveEvent, - MouseUpEvent, Point, ScrollHandle, Stateful, StrikethroughStyle, StyleRefinement, StyledText, - Task, TextLayout, TextRun, TextStyle, TextStyleRefinement, actions, img, point, quad, + ImageFormat, ImageSource, KeyContext, Length, MouseButton, MouseDownEvent, MouseEvent, + MouseMoveEvent, MouseUpEvent, Point, ScrollHandle, Stateful, StrikethroughStyle, + StyleRefinement, StyledText, Task, TextLayout, TextRun, TextStyle, TextStyleRefinement, + actions, img, point, quad, }; use language::{CharClassifier, Language, LanguageRegistry, Rope}; use parser::CodeBlockMetadata; -use parser::{MarkdownEvent, MarkdownTag, MarkdownTagEnd, parse_links_only, parse_markdown}; +use parser::{ + MarkdownEvent, MarkdownTag, MarkdownTagEnd, parse_links_only, parse_markdown_with_options, +}; use pulldown_cmark::Alignment; use sum_tree::TreeMap; use theme::SyntaxTheme; @@ -46,7 +55,8 @@ use crate::parser::CodeBlockKind; /// A callback function that can be used to customize the style of links based on the destination URL. /// If the callback returns `None`, the default link style will be used. type LinkStyleCallback = Rc Option>; - +type SourceClickCallback = Box bool>; +type CheckboxToggleCallback = Rc, bool, &mut Window, &mut App)>; /// Defines custom style refinements for each heading level (H1-H6) #[derive(Clone, Default)] pub struct HeadingLevelStyles { @@ -238,6 +248,7 @@ pub struct Markdown { selection: Selection, pressed_link: Option, autoscroll_request: Option, + active_root_block: Option, parsed_markdown: ParsedMarkdown, images_by_source_offset: HashMap>, should_reparse: bool, @@ -245,14 +256,18 @@ pub struct Markdown { focus_handle: FocusHandle, language_registry: Option>, fallback_code_block_language: Option, - options: Options, + options: MarkdownOptions, + mermaid_state: MermaidState, copied_code_blocks: HashSet, code_block_scroll_handles: BTreeMap, context_menu_selected_text: Option, } -struct Options { - parse_links_only: bool, +#[derive(Clone, Copy, Default)] +pub struct MarkdownOptions { + pub parse_links_only: bool, + pub parse_html: bool, + pub render_mermaid_diagrams: bool, } pub enum CodeBlockRenderer { @@ -299,6 +314,22 @@ impl Markdown { language_registry: Option>, fallback_code_block_language: Option, cx: &mut Context, + ) -> Self { + Self::new_with_options( + source, + language_registry, + fallback_code_block_language, + MarkdownOptions::default(), + cx, + ) + } + + pub fn new_with_options( + source: SharedString, + language_registry: Option>, + fallback_code_block_language: Option, + options: MarkdownOptions, + cx: &mut Context, ) -> Self { let focus_handle = cx.focus_handle(); let mut this = Self { @@ -306,6 +337,7 @@ impl Markdown { selection: Selection::default(), pressed_link: None, autoscroll_request: None, + active_root_block: None, should_reparse: false, images_by_source_offset: Default::default(), parsed_markdown: ParsedMarkdown::default(), @@ -313,9 +345,8 @@ impl Markdown { focus_handle, language_registry, fallback_code_block_language, - options: Options { - parse_links_only: false, - }, + options, + mermaid_state: MermaidState::default(), copied_code_blocks: HashSet::default(), code_block_scroll_handles: BTreeMap::default(), context_menu_selected_text: None, @@ -325,28 +356,16 @@ impl Markdown { } pub fn new_text(source: SharedString, cx: &mut Context) -> Self { - let focus_handle = cx.focus_handle(); - let mut this = Self { + Self::new_with_options( source, - selection: Selection::default(), - pressed_link: None, - autoscroll_request: None, - should_reparse: false, - parsed_markdown: ParsedMarkdown::default(), - images_by_source_offset: Default::default(), - pending_parse: None, - focus_handle, - language_registry: None, - fallback_code_block_language: None, - options: Options { + None, + None, + MarkdownOptions { parse_links_only: true, + ..Default::default() }, - copied_code_blocks: HashSet::default(), - code_block_scroll_handles: BTreeMap::default(), - context_menu_selected_text: None, - }; - this.parse(cx); - this + cx, + ) } fn code_block_scroll_handle(&mut self, id: usize) -> ScrollHandle { @@ -409,6 +428,30 @@ impl Markdown { self.parse(cx); } + pub fn request_autoscroll_to_source_index( + &mut self, + source_index: usize, + cx: &mut Context, + ) { + self.autoscroll_request = Some(source_index); + cx.refresh_windows(); + } + + pub fn set_active_root_for_source_index( + &mut self, + source_index: Option, + cx: &mut Context, + ) { + let active_root_block = + source_index.and_then(|index| self.parsed_markdown.root_block_for_source_index(index)); + if self.active_root_block == active_root_block { + return; + } + + self.active_root_block = active_root_block; + cx.notify(); + } + pub fn reset(&mut self, source: SharedString, cx: &mut Context) { if source == self.source() { return; @@ -488,6 +531,17 @@ impl Markdown { fn parse(&mut self, cx: &mut Context) { if self.source.is_empty() { + self.should_reparse = false; + self.pending_parse.take(); + self.parsed_markdown = ParsedMarkdown { + source: self.source.clone(), + ..Default::default() + }; + self.active_root_block = None; + self.images_by_source_offset.clear(); + self.mermaid_state.clear(); + cx.notify(); + cx.refresh_windows(); return; } @@ -502,6 +556,8 @@ impl Markdown { fn start_background_parse(&self, cx: &Context) -> Task<()> { let source = self.source.clone(); let should_parse_links_only = self.options.parse_links_only; + let should_parse_html = self.options.parse_html; + let should_render_mermaid_diagrams = self.options.render_mermaid_diagrams; let language_registry = self.language_registry.clone(); let fallback = self.fallback_code_block_language.clone(); @@ -513,12 +569,25 @@ impl Markdown { source, languages_by_name: TreeMap::default(), languages_by_path: TreeMap::default(), + root_block_starts: Arc::default(), + html_blocks: BTreeMap::default(), + mermaid_diagrams: BTreeMap::default(), }, Default::default(), ); } - let (events, language_names, paths) = parse_markdown(&source); + let parsed = parse_markdown_with_options(&source, should_parse_html); + let events = parsed.events; + let language_names = parsed.language_names; + let paths = parsed.language_paths; + let root_block_starts = parsed.root_block_starts; + let html_blocks = parsed.html_blocks; + let mermaid_diagrams = if should_render_mermaid_diagrams { + extract_mermaid_diagrams(&source, &events) + } else { + BTreeMap::default() + }; let mut images_by_source_offset = HashMap::default(); let mut languages_by_name = TreeMap::default(); let mut languages_by_path = TreeMap::default(); @@ -577,6 +646,9 @@ impl Markdown { events: Arc::from(events), languages_by_name, languages_by_path, + root_block_starts: Arc::from(root_block_starts), + html_blocks, + mermaid_diagrams, }, images_by_source_offset, ) @@ -588,10 +660,22 @@ impl Markdown { this.update(cx, |this, cx| { this.parsed_markdown = parsed; this.images_by_source_offset = images_by_source_offset; + if this.active_root_block.is_some_and(|block_index| { + block_index >= this.parsed_markdown.root_block_starts.len() + }) { + this.active_root_block = None; + } + if this.options.render_mermaid_diagrams { + let parsed_markdown = this.parsed_markdown.clone(); + this.mermaid_state.update(&parsed_markdown, cx); + } else { + this.mermaid_state.clear(); + } this.pending_parse.take(); if this.should_reparse { this.parse(cx); } + cx.notify(); cx.refresh_windows(); }) .ok(); @@ -685,6 +769,9 @@ pub struct ParsedMarkdown { pub events: Arc<[(Range, MarkdownEvent)]>, pub languages_by_name: TreeMap>, pub languages_by_path: TreeMap, Arc>, + pub root_block_starts: Arc<[usize]>, + pub(crate) html_blocks: BTreeMap, + pub(crate) mermaid_diagrams: BTreeMap, } impl ParsedMarkdown { @@ -695,6 +782,30 @@ impl ParsedMarkdown { pub fn events(&self) -> &Arc<[(Range, MarkdownEvent)]> { &self.events } + + pub fn root_block_starts(&self) -> &Arc<[usize]> { + &self.root_block_starts + } + + pub fn root_block_for_source_index(&self, source_index: usize) -> Option { + if self.root_block_starts.is_empty() { + return None; + } + + let partition = self + .root_block_starts + .partition_point(|block_start| *block_start <= source_index); + + Some(partition.saturating_sub(1)) + } +} + +pub enum AutoscrollBehavior { + /// Propagate the request up the element tree for the nearest + /// scrollable ancestor (e.g. `List`) to handle. + Propagate, + /// Directly control a specific scroll handle. + Controlled(ScrollHandle), } pub struct MarkdownElement { @@ -702,6 +813,11 @@ pub struct MarkdownElement { style: MarkdownStyle, code_block_renderer: CodeBlockRenderer, on_url_click: Option>, + on_source_click: Option, + on_checkbox_toggle: Option, + image_resolver: Option Option>>, + show_root_block_markers: bool, + autoscroll: AutoscrollBehavior, } impl MarkdownElement { @@ -715,6 +831,11 @@ impl MarkdownElement { border: false, }, on_url_click: None, + on_source_click: None, + on_checkbox_toggle: None, + image_resolver: None, + show_root_block_markers: false, + autoscroll: AutoscrollBehavior::Propagate, } } @@ -752,6 +873,147 @@ impl MarkdownElement { self } + pub fn on_source_click( + mut self, + handler: impl Fn(usize, usize, &mut Window, &mut App) -> bool + 'static, + ) -> Self { + self.on_source_click = Some(Box::new(handler)); + self + } + + pub fn on_checkbox_toggle( + mut self, + handler: impl Fn(Range, bool, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_checkbox_toggle = Some(Rc::new(handler)); + self + } + + pub fn image_resolver( + mut self, + resolver: impl Fn(&str) -> Option + 'static, + ) -> Self { + self.image_resolver = Some(Box::new(resolver)); + self + } + + pub fn show_root_block_markers(mut self) -> Self { + self.show_root_block_markers = true; + self + } + + pub fn scroll_handle(mut self, scroll_handle: ScrollHandle) -> Self { + self.autoscroll = AutoscrollBehavior::Controlled(scroll_handle); + self + } + + fn push_markdown_image( + &self, + builder: &mut MarkdownElementBuilder, + range: &Range, + source: ImageSource, + width: Option, + height: Option, + ) { + builder.modify_current_div(|el| { + el.items_center().flex().flex_row().child( + img(source) + .max_w_full() + .when_some(height, |this, height| this.h(height)) + .when_some(width, |this, width| this.w(width)), + ) + }); + let _ = range; + } + + fn push_markdown_paragraph( + &self, + builder: &mut MarkdownElementBuilder, + range: &Range, + markdown_end: usize, + ) { + builder.push_div( + div().when(!self.style.height_is_multiple_of_line_height, |el| { + el.mb_2().line_height(rems(1.3)) + }), + range, + markdown_end, + ); + } + + fn push_markdown_heading( + &self, + builder: &mut MarkdownElementBuilder, + level: pulldown_cmark::HeadingLevel, + range: &Range, + markdown_end: usize, + ) { + let mut heading = div().mb_2(); + heading = apply_heading_style(heading, level, self.style.heading_level_styles.as_ref()); + + let mut heading_style = self.style.heading.clone(); + let heading_text_style = heading_style.text_style().clone(); + heading.style().refine(&heading_style); + + builder.push_text_style(heading_text_style); + builder.push_div(heading, range, markdown_end); + } + + fn pop_markdown_heading(&self, builder: &mut MarkdownElementBuilder) { + builder.pop_div(); + builder.pop_text_style(); + } + + fn push_markdown_block_quote( + &self, + builder: &mut MarkdownElementBuilder, + range: &Range, + markdown_end: usize, + ) { + builder.push_text_style(self.style.block_quote.clone()); + builder.push_div( + div() + .pl_4() + .mb_2() + .border_l_4() + .border_color(self.style.block_quote_border_color), + range, + markdown_end, + ); + } + + fn pop_markdown_block_quote(&self, builder: &mut MarkdownElementBuilder) { + builder.pop_div(); + builder.pop_text_style(); + } + + fn push_markdown_list_item( + &self, + builder: &mut MarkdownElementBuilder, + bullet: AnyElement, + range: &Range, + markdown_end: usize, + ) { + builder.push_div( + div() + .when(!self.style.height_is_multiple_of_line_height, |el| { + el.mb_1().gap_1().line_height(rems(1.3)) + }) + .h_flex() + .items_start() + .child(bullet), + range, + markdown_end, + ); + // Without `w_0`, text doesn't wrap to the width of the container. + builder.push_div(div().flex_1().w_0(), range, markdown_end); + } + + fn pop_markdown_list_item(&self, builder: &mut MarkdownElementBuilder) { + builder.pop_div(); + builder.pop_div(); + } + fn paint_selection( &self, bounds: Bounds, @@ -845,6 +1107,7 @@ impl MarkdownElement { } let on_open_url = self.on_url_click.take(); + let on_source_click = self.on_source_click.take(); self.on_mouse_event(window, cx, { let hitbox = hitbox.clone(); @@ -872,6 +1135,16 @@ impl MarkdownElement { match rendered_text.source_index_for_position(event.position) { Ok(ix) | Err(ix) => ix, }; + if let Some(handler) = on_source_click.as_ref() { + let blocked = handler(source_index, event.click_count, window, cx); + if blocked { + markdown.selection = Selection::default(); + markdown.pressed_link = None; + window.prevent_default(); + cx.notify(); + return; + } + } let (range, mode) = match event.click_count { 1 => { let range = source_index..source_index; @@ -979,14 +1252,38 @@ impl MarkdownElement { .update(cx, |markdown, _| markdown.autoscroll_request.take())?; let (position, line_height) = rendered_text.position_for_source_index(autoscroll_index)?; - let text_style = self.style.base_text_style.clone(); - let font_id = window.text_system().resolve_font(&text_style.font()); - let font_size = text_style.font_size.to_pixels(window.rem_size()); - let em_width = window.text_system().em_width(font_id, font_size).unwrap(); - window.request_autoscroll(Bounds::from_corners( - point(position.x - 3. * em_width, position.y - 3. * line_height), - point(position.x + 3. * em_width, position.y + 3. * line_height), - )); + match &self.autoscroll { + AutoscrollBehavior::Controlled(scroll_handle) => { + let viewport = scroll_handle.bounds(); + let margin = line_height * 3.; + let top_goal = viewport.top() + margin; + let bottom_goal = viewport.bottom() - margin; + let current_offset = scroll_handle.offset(); + + let new_offset_y = if position.y < top_goal { + current_offset.y + (top_goal - position.y) + } else if position.y + line_height > bottom_goal { + current_offset.y + (bottom_goal - (position.y + line_height)) + } else { + current_offset.y + }; + + scroll_handle.set_offset(point( + current_offset.x, + new_offset_y.clamp(-scroll_handle.max_offset().y, Pixels::ZERO), + )); + } + AutoscrollBehavior::Propagate => { + let text_style = self.style.base_text_style.clone(); + let font_id = window.text_system().resolve_font(&text_style.font()); + let font_size = text_style.font_size.to_pixels(window.rem_size()); + let em_width = window.text_system().em_width(font_id, font_size).unwrap(); + window.request_autoscroll(Bounds::from_corners( + point(position.x - 3. * em_width, position.y - 3. * line_height), + point(position.x + 3. * em_width, position.y + 3. * line_height), + )); + } + } Some(()) } @@ -1038,11 +1335,14 @@ impl Element for MarkdownElement { self.style.base_text_style.clone(), self.style.syntax.clone(), ); - let (parsed_markdown, images) = { + let (parsed_markdown, images, active_root_block, render_mermaid_diagrams, mermaid_state) = { let markdown = self.markdown.read(cx); ( markdown.parsed_markdown.clone(), markdown.images_by_source_offset.clone(), + markdown.active_root_block, + markdown.options.render_mermaid_diagrams, + markdown.mermaid_state.clone(), ) }; let markdown_end = if let Some(last) = parsed_markdown.events.last() { @@ -1053,6 +1353,8 @@ impl Element for MarkdownElement { let mut code_block_ids = HashSet::default(); let mut current_img_block_range: Option> = None; + let mut handled_html_block = false; + let mut rendered_mermaid_block = false; for (index, (range, event)) in parsed_markdown.events.iter().enumerate() { // Skip alt text for images that rendered if let Some(current_img_block_range) = ¤t_img_block_range @@ -1061,58 +1363,83 @@ impl Element for MarkdownElement { continue; } + if handled_html_block { + if let MarkdownEvent::End(MarkdownTagEnd::HtmlBlock) = event { + handled_html_block = false; + } else { + continue; + } + } + + if rendered_mermaid_block { + if matches!(event, MarkdownEvent::End(MarkdownTagEnd::CodeBlock)) { + rendered_mermaid_block = false; + } + continue; + } + match event { + MarkdownEvent::RootStart => { + if self.show_root_block_markers { + builder.push_root_block(range, markdown_end); + } + } + MarkdownEvent::RootEnd(root_block_index) => { + if self.show_root_block_markers { + builder.pop_root_block( + active_root_block == Some(*root_block_index), + cx.theme().colors().border, + cx.theme().colors().border_variant, + ); + } + } MarkdownEvent::Start(tag) => { match tag { - MarkdownTag::Image { .. } => { + MarkdownTag::Image { dest_url, .. } => { if let Some(image) = images.get(&range.start) { current_img_block_range = Some(range.clone()); - builder.modify_current_div(|el| { - el.items_center() - .flex() - .flex_row() - .child(img(image.clone())) - }); + self.push_markdown_image( + &mut builder, + range, + image.clone().into(), + None, + None, + ); + } else if let Some(source) = self + .image_resolver + .as_ref() + .and_then(|resolve| resolve(dest_url.as_ref())) + { + current_img_block_range = Some(range.clone()); + self.push_markdown_image(&mut builder, range, source, None, None); } } MarkdownTag::Paragraph => { - builder.push_div( - div().when(!self.style.height_is_multiple_of_line_height, |el| { - el.mb_2().line_height(rems(1.3)) - }), - range, - markdown_end, - ); + self.push_markdown_paragraph(&mut builder, range, markdown_end); } MarkdownTag::Heading { level, .. } => { - let mut heading = div().mb_2(); - - heading = apply_heading_style( - heading, - *level, - self.style.heading_level_styles.as_ref(), - ); - - heading.style().refine(&self.style.heading); - - let text_style = self.style.heading.text_style().clone(); - - builder.push_text_style(text_style); - builder.push_div(heading, range, markdown_end); + self.push_markdown_heading(&mut builder, *level, range, markdown_end); } MarkdownTag::BlockQuote => { - builder.push_text_style(self.style.block_quote.clone()); - builder.push_div( - div() - .pl_4() - .mb_2() - .border_l_4() - .border_color(self.style.block_quote_border_color), - range, - markdown_end, - ); + self.push_markdown_block_quote(&mut builder, range, markdown_end); } MarkdownTag::CodeBlock { kind, .. } => { + if render_mermaid_diagrams + && let Some(mermaid_diagram) = + parsed_markdown.mermaid_diagrams.get(&range.start) + { + builder.push_sourced_element( + mermaid_diagram.content_range.clone(), + render_mermaid_diagram( + mermaid_diagram, + &mermaid_state, + &self.style, + ), + ); + rendered_mermaid_block = true; + continue; + } + let language = match kind { CodeBlockKind::Fenced => None, CodeBlockKind::FencedLang(language) => { @@ -1196,46 +1523,57 @@ impl Element for MarkdownElement { (CodeBlockRenderer::Custom { .. }, _) => {} } } - MarkdownTag::HtmlBlock => builder.push_div(div(), range, markdown_end), + MarkdownTag::HtmlBlock => { + builder.push_div(div(), range, markdown_end); + if let Some(block) = parsed_markdown.html_blocks.get(&range.start) { + self.render_html_block(block, &mut builder, markdown_end, cx); + handled_html_block = true; + } + } MarkdownTag::List(bullet_index) => { builder.push_list(*bullet_index); builder.push_div(div().pl_2p5(), range, markdown_end); } MarkdownTag::Item => { - let bullet = if let Some((_, MarkdownEvent::TaskListMarker(checked))) = - parsed_markdown.events.get(index.saturating_add(1)) - { - let source = &parsed_markdown.source()[range.clone()]; - - Checkbox::new( - ElementId::Name(source.to_string().into()), - if *checked { + let bullet = + if let Some((task_range, MarkdownEvent::TaskListMarker(checked))) = + parsed_markdown.events.get(index.saturating_add(1)) + { + let source = &parsed_markdown.source()[range.clone()]; + let checked = *checked; + let toggle_state = if checked { ToggleState::Selected } else { ToggleState::Unselected - }, - ) - .fill() - .visualization_only(true) - .into_any_element() - } else if let Some(bullet_index) = builder.next_bullet_index() { - div().child(format!("{}.", bullet_index)).into_any_element() - } else { - div().child("•").into_any_element() - }; - builder.push_div( - div() - .when(!self.style.height_is_multiple_of_line_height, |el| { - el.mb_1().gap_1().line_height(rems(1.3)) - }) - .h_flex() - .items_start() - .child(bullet), - range, - markdown_end, - ); - // Without `w_0`, text doesn't wrap to the width of the container. - builder.push_div(div().flex_1().w_0(), range, markdown_end); + }; + + let checkbox = Checkbox::new( + ElementId::Name(source.to_string().into()), + toggle_state, + ) + .fill(); + + if let Some(on_toggle) = self.on_checkbox_toggle.clone() { + let task_source_range = task_range.clone(); + checkbox + .on_click(move |_state, window, cx| { + on_toggle( + task_source_range.clone(), + !checked, + window, + cx, + ); + }) + .into_any_element() + } else { + checkbox.visualization_only(true).into_any_element() + } + } else if let Some(bullet_index) = builder.next_bullet_index() { + div().child(format!("{}.", bullet_index)).into_any_element() + } else { + div().child("•").into_any_element() + }; + self.push_markdown_list_item(&mut builder, bullet, range, markdown_end); } MarkdownTag::Emphasis => builder.push_text_style(TextStyleRefinement { font_style: Some(FontStyle::Italic), @@ -1340,12 +1678,10 @@ impl Element for MarkdownElement { builder.pop_div(); } MarkdownTagEnd::Heading(_) => { - builder.pop_div(); - builder.pop_text_style() + self.pop_markdown_heading(&mut builder); } MarkdownTagEnd::BlockQuote(_kind) => { - builder.pop_text_style(); - builder.pop_div() + self.pop_markdown_block_quote(&mut builder); } MarkdownTagEnd::CodeBlock => { builder.trim_trailing_newline(); @@ -1423,8 +1759,7 @@ impl Element for MarkdownElement { builder.pop_div(); } MarkdownTagEnd::Item => { - builder.pop_div(); - builder.pop_div(); + self.pop_markdown_list_item(&mut builder); } MarkdownTagEnd::Emphasis => builder.pop_text_style(), MarkdownTagEnd::Strong => builder.pop_text_style(), @@ -1842,6 +2177,15 @@ impl MarkdownElementBuilder { self.div_stack.push(div); } + fn push_root_block(&mut self, range: &Range, markdown_end: usize) { + self.push_div( + div().group("markdown-root-block").relative(), + range, + markdown_end, + ); + self.push_div(div().pl_4(), range, markdown_end); + } + fn modify_current_div(&mut self, f: impl FnOnce(AnyDiv) -> AnyDiv) { self.flush_text(); if let Some(div) = self.div_stack.pop() { @@ -1849,12 +2193,53 @@ impl MarkdownElementBuilder { } } + fn pop_root_block( + &mut self, + is_active: bool, + active_gutter_color: Hsla, + hovered_gutter_color: Hsla, + ) { + self.pop_div(); + self.modify_current_div(|el| { + el.child( + div() + .h_full() + .w(px(4.0)) + .when(is_active, |this| this.bg(active_gutter_color)) + .group_hover("markdown-root-block", |this| { + if is_active { + this + } else { + this.bg(hovered_gutter_color) + } + }) + .rounded_xs() + .absolute() + .left_0() + .top_0(), + ) + }); + self.pop_div(); + } + fn pop_div(&mut self) { self.flush_text(); let div = self.div_stack.pop().unwrap().into_any_element(); self.div_stack.last_mut().unwrap().extend(iter::once(div)); } + fn push_sourced_element(&mut self, source_range: Range, element: impl Into) { + self.flush_text(); + let anchor = self.render_source_anchor(source_range); + self.div_stack.last_mut().unwrap().extend([{ + div() + .relative() + .child(anchor) + .child(element.into()) + .into_any_element() + }]); + } + fn push_list(&mut self, bullet_index: Option) { self.list_stack.push(ListStackEntry { bullet_index }); } @@ -1904,9 +2289,10 @@ impl MarkdownElementBuilder { } let mut run_style = self.text_style(); - if let Some(highlight) = highlight_id.style(&self.syntax_theme) { + if let Some(highlight) = self.syntax_theme.get(highlight_id).cloned() { run_style = run_style.highlight(highlight); } + self.pending_line.runs.push(run_style.to_run(range.len())); offset = range.end; } @@ -1955,6 +2341,29 @@ impl MarkdownElementBuilder { } } + fn render_source_anchor(&mut self, source_range: Range) -> AnyElement { + let mut text_style = self.base_text_style.clone(); + text_style.color = Hsla::transparent_black(); + let text = "\u{200B}"; + let styled_text = StyledText::new(text).with_runs(vec![text_style.to_run(text.len())]); + self.rendered_lines.push(RenderedLine { + layout: styled_text.layout().clone(), + source_mappings: vec![SourceMapping { + rendered_index: 0, + source_index: source_range.start, + }], + source_end: source_range.end, + language: None, + }); + div() + .absolute() + .top_0() + .left_0() + .opacity(0.) + .child(styled_text) + .into_any_element() + } + fn flush_text(&mut self) { let line = mem::take(&mut self.pending_line); if line.text.is_empty() { @@ -2004,7 +2413,7 @@ impl RenderedLine { Ok(ix) => &self.source_mappings[ix], Err(ix) => &self.source_mappings[ix - 1], }; - mapping.rendered_index + (source_index - mapping.source_index) + (mapping.rendered_index + (source_index - mapping.source_index)).min(self.layout.len()) } fn source_index_for_rendered_index(&self, rendered_index: usize) -> usize { @@ -2268,7 +2677,7 @@ mod tests { settings::init(cx); } if !cx.has_global::() { - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); } }); } @@ -2332,6 +2741,15 @@ mod tests { markdown: &str, language_registry: Option>, cx: &mut TestAppContext, + ) -> RenderedText { + render_markdown_with_options(markdown, language_registry, MarkdownOptions::default(), cx) + } + + fn render_markdown_with_options( + markdown: &str, + language_registry: Option>, + options: MarkdownOptions, + cx: &mut TestAppContext, ) -> RenderedText { struct TestWindow; @@ -2344,8 +2762,15 @@ mod tests { ensure_theme_initialized(cx); let (_, cx) = cx.add_window_view(|_, _| TestWindow); - let markdown = - cx.new(|cx| Markdown::new(markdown.to_string().into(), language_registry, None, cx)); + let markdown = cx.new(|cx| { + Markdown::new_with_options( + markdown.to_string().into(), + language_registry, + None, + options, + cx, + ) + }); cx.run_until_parked(); let (rendered, _) = cx.draw( Default::default(), @@ -2525,7 +2950,7 @@ mod tests { #[test] fn test_table_checkbox_detection() { let md = "| Done |\n|------|\n| [x] |\n| [ ] |"; - let (events, _, _) = crate::parser::parse_markdown(md); + let events = crate::parser::parse_markdown_with_options(md, false).events; let mut in_table = false; let mut cell_texts: Vec = Vec::new(); diff --git a/crates/markdown/src/mermaid.rs b/crates/markdown/src/mermaid.rs new file mode 100644 index 0000000000000000000000000000000000000000..15f3de4d8e8c64010fe96846b05d75f012c5fc0d --- /dev/null +++ b/crates/markdown/src/mermaid.rs @@ -0,0 +1,613 @@ +use collections::HashMap; +use gpui::{ + Animation, AnimationExt, AnyElement, Context, ImageSource, RenderImage, StyledText, Task, img, + pulsating_between, +}; +use std::collections::BTreeMap; +use std::ops::Range; +use std::sync::{Arc, OnceLock}; +use std::time::Duration; +use ui::prelude::*; + +use crate::parser::{CodeBlockKind, MarkdownEvent, MarkdownTag}; + +use super::{Markdown, MarkdownStyle, ParsedMarkdown}; + +type MermaidDiagramCache = HashMap>; + +#[derive(Clone, Debug)] +pub(crate) struct ParsedMarkdownMermaidDiagram { + pub(crate) content_range: Range, + pub(crate) contents: ParsedMarkdownMermaidDiagramContents, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(crate) struct ParsedMarkdownMermaidDiagramContents { + pub(crate) contents: SharedString, + pub(crate) scale: u32, +} + +#[derive(Default, Clone)] +pub(crate) struct MermaidState { + cache: MermaidDiagramCache, + order: Vec, +} + +struct CachedMermaidDiagram { + render_image: Arc>>>, + fallback_image: Option>, + _task: Task<()>, +} + +impl MermaidState { + pub(crate) fn clear(&mut self) { + self.cache.clear(); + self.order.clear(); + } + + fn get_fallback_image( + idx: usize, + old_order: &[ParsedMarkdownMermaidDiagramContents], + new_order_len: usize, + cache: &MermaidDiagramCache, + ) -> Option> { + if old_order.len() != new_order_len { + return None; + } + + old_order.get(idx).and_then(|old_content| { + cache.get(old_content).and_then(|old_cached| { + old_cached + .render_image + .get() + .and_then(|result| result.as_ref().ok().cloned()) + .or_else(|| old_cached.fallback_image.clone()) + }) + }) + } + + pub(crate) fn update(&mut self, parsed: &ParsedMarkdown, cx: &mut Context) { + let mut new_order = Vec::new(); + for mermaid_diagram in parsed.mermaid_diagrams.values() { + new_order.push(mermaid_diagram.contents.clone()); + } + + for (idx, new_content) in new_order.iter().enumerate() { + if !self.cache.contains_key(new_content) { + let fallback = + Self::get_fallback_image(idx, &self.order, new_order.len(), &self.cache); + self.cache.insert( + new_content.clone(), + Arc::new(CachedMermaidDiagram::new(new_content.clone(), fallback, cx)), + ); + } + } + + let new_order_set: std::collections::HashSet<_> = new_order.iter().cloned().collect(); + self.cache + .retain(|content, _| new_order_set.contains(content)); + self.order = new_order; + } +} + +impl CachedMermaidDiagram { + fn new( + contents: ParsedMarkdownMermaidDiagramContents, + fallback_image: Option>, + cx: &mut Context, + ) -> Self { + let render_image = Arc::new(OnceLock::>>::new()); + let render_image_clone = render_image.clone(); + let svg_renderer = cx.svg_renderer(); + + let task = cx.spawn(async move |this, cx| { + let value = cx + .background_spawn(async move { + let svg_string = mermaid_rs_renderer::render(&contents.contents)?; + let scale = contents.scale as f32 / 100.0; + svg_renderer + .render_single_frame(svg_string.as_bytes(), scale) + .map_err(|error| anyhow::anyhow!("{error}")) + }) + .await; + let _ = render_image_clone.set(value); + this.update(cx, |_, cx| { + cx.notify(); + }) + .ok(); + }); + + Self { + render_image, + fallback_image, + _task: task, + } + } + + #[cfg(test)] + fn new_for_test( + render_image: Option>, + fallback_image: Option>, + ) -> Self { + let result = Arc::new(OnceLock::new()); + if let Some(render_image) = render_image { + let _ = result.set(Ok(render_image)); + } + Self { + render_image: result, + fallback_image, + _task: Task::ready(()), + } + } +} + +fn parse_mermaid_info(info: &str) -> Option { + let mut parts = info.split_whitespace(); + if parts.next()? != "mermaid" { + return None; + } + + Some( + parts + .next() + .and_then(|scale| scale.parse().ok()) + .unwrap_or(100) + .clamp(10, 500), + ) +} + +pub(crate) fn extract_mermaid_diagrams( + source: &str, + events: &[(Range, MarkdownEvent)], +) -> BTreeMap { + let mut mermaid_diagrams = BTreeMap::default(); + + for (source_range, event) in events { + let MarkdownEvent::Start(MarkdownTag::CodeBlock { kind, metadata }) = event else { + continue; + }; + let CodeBlockKind::FencedLang(info) = kind else { + continue; + }; + let Some(scale) = parse_mermaid_info(info.as_ref()) else { + continue; + }; + + let contents = source[metadata.content_range.clone()] + .strip_suffix('\n') + .unwrap_or(&source[metadata.content_range.clone()]) + .to_string(); + mermaid_diagrams.insert( + source_range.start, + ParsedMarkdownMermaidDiagram { + content_range: metadata.content_range.clone(), + contents: ParsedMarkdownMermaidDiagramContents { + contents: contents.into(), + scale, + }, + }, + ); + } + + mermaid_diagrams +} + +pub(crate) fn render_mermaid_diagram( + parsed: &ParsedMarkdownMermaidDiagram, + mermaid_state: &MermaidState, + style: &MarkdownStyle, +) -> AnyElement { + let cached = mermaid_state.cache.get(&parsed.contents); + let mut container = div().w_full(); + container.style().refine(&style.code_block); + + if let Some(result) = cached.and_then(|cached| cached.render_image.get()) { + match result { + Ok(render_image) => container + .child( + div().w_full().child( + img(ImageSource::Render(render_image.clone())) + .max_w_full() + .with_fallback(|| { + div() + .child(Label::new("Failed to load mermaid diagram")) + .into_any_element() + }), + ), + ) + .into_any_element(), + Err(_) => container + .child(StyledText::new(parsed.contents.contents.clone())) + .into_any_element(), + } + } else if let Some(fallback) = cached.and_then(|cached| cached.fallback_image.as_ref()) { + container + .child( + div() + .w_full() + .child( + img(ImageSource::Render(fallback.clone())) + .max_w_full() + .with_fallback(|| { + div() + .child(Label::new("Failed to load mermaid diagram")) + .into_any_element() + }), + ) + .with_animation( + "mermaid-fallback-pulse", + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(pulsating_between(0.6, 1.0)), + |element, delta| element.opacity(delta), + ), + ) + .into_any_element() + } else { + container + .child( + Label::new("Rendering mermaid diagram...") + .color(Color::Muted) + .with_animation( + "mermaid-loading-pulse", + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(pulsating_between(0.4, 0.8)), + |label, delta| label.alpha(delta), + ), + ) + .into_any_element() + } +} + +#[cfg(test)] +mod tests { + use super::{ + CachedMermaidDiagram, MermaidDiagramCache, MermaidState, + ParsedMarkdownMermaidDiagramContents, extract_mermaid_diagrams, parse_mermaid_info, + }; + use crate::{CodeBlockRenderer, Markdown, MarkdownElement, MarkdownOptions, MarkdownStyle}; + use collections::HashMap; + use gpui::{Context, IntoElement, Render, RenderImage, TestAppContext, Window, size}; + use std::sync::Arc; + use ui::prelude::*; + + fn ensure_theme_initialized(cx: &mut TestAppContext) { + cx.update(|cx| { + if !cx.has_global::() { + settings::init(cx); + } + if !cx.has_global::() { + theme_settings::init(theme::LoadThemes::JustBase, cx); + } + }); + } + + fn render_markdown_with_options( + markdown: &str, + options: MarkdownOptions, + cx: &mut TestAppContext, + ) -> crate::RenderedText { + struct TestWindow; + + impl Render for TestWindow { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + } + } + + ensure_theme_initialized(cx); + + let (_, cx) = cx.add_window_view(|_, _| TestWindow); + let markdown = cx.new(|cx| { + Markdown::new_with_options(markdown.to_string().into(), None, None, options, cx) + }); + cx.run_until_parked(); + let (rendered, _) = cx.draw( + Default::default(), + size(px(600.0), px(600.0)), + |_window, _cx| { + MarkdownElement::new(markdown, MarkdownStyle::default()).code_block_renderer( + CodeBlockRenderer::Default { + copy_button: false, + copy_button_on_hover: false, + border: false, + }, + ) + }, + ); + rendered.text + } + + fn mock_render_image(cx: &mut TestAppContext) -> Arc { + cx.update(|cx| { + cx.svg_renderer() + .render_single_frame( + br#""#, + 1.0, + ) + .unwrap() + }) + } + + fn mermaid_contents(contents: &str) -> ParsedMarkdownMermaidDiagramContents { + ParsedMarkdownMermaidDiagramContents { + contents: contents.to_string().into(), + scale: 100, + } + } + + fn mermaid_sequence(diagrams: &[&str]) -> Vec { + diagrams + .iter() + .map(|diagram| mermaid_contents(diagram)) + .collect() + } + + fn mermaid_fallback( + new_diagram: &str, + new_full_order: &[ParsedMarkdownMermaidDiagramContents], + old_full_order: &[ParsedMarkdownMermaidDiagramContents], + cache: &MermaidDiagramCache, + ) -> Option> { + let new_content = mermaid_contents(new_diagram); + let idx = new_full_order + .iter() + .position(|diagram| diagram == &new_content)?; + MermaidState::get_fallback_image(idx, old_full_order, new_full_order.len(), cache) + } + + #[test] + fn test_parse_mermaid_info() { + assert_eq!(parse_mermaid_info("mermaid"), Some(100)); + assert_eq!(parse_mermaid_info("mermaid 150"), Some(150)); + assert_eq!(parse_mermaid_info("mermaid 5"), Some(10)); + assert_eq!(parse_mermaid_info("mermaid 999"), Some(500)); + assert_eq!(parse_mermaid_info("rust"), None); + } + + #[test] + fn test_extract_mermaid_diagrams_parses_scale() { + let markdown = "```mermaid 150\ngraph TD;\n```\n\n```rust\nfn main() {}\n```"; + let events = crate::parser::parse_markdown_with_options(markdown, false).events; + let diagrams = extract_mermaid_diagrams(markdown, &events); + + assert_eq!(diagrams.len(), 1); + let diagram = diagrams.values().next().unwrap(); + assert_eq!(diagram.contents.contents, "graph TD;"); + assert_eq!(diagram.contents.scale, 150); + } + + #[gpui::test] + fn test_mermaid_fallback_on_edit(cx: &mut TestAppContext) { + let old_full_order = mermaid_sequence(&["graph A", "graph B", "graph C"]); + let new_full_order = mermaid_sequence(&["graph A", "graph B modified", "graph C"]); + + let svg_b = mock_render_image(cx); + + let mut cache: MermaidDiagramCache = HashMap::default(); + cache.insert( + mermaid_contents("graph A"), + Arc::new(CachedMermaidDiagram::new_for_test( + Some(mock_render_image(cx)), + None, + )), + ); + cache.insert( + mermaid_contents("graph B"), + Arc::new(CachedMermaidDiagram::new_for_test( + Some(svg_b.clone()), + None, + )), + ); + cache.insert( + mermaid_contents("graph C"), + Arc::new(CachedMermaidDiagram::new_for_test( + Some(mock_render_image(cx)), + None, + )), + ); + + let fallback = + mermaid_fallback("graph B modified", &new_full_order, &old_full_order, &cache); + + assert_eq!(fallback.as_ref().map(|image| image.id), Some(svg_b.id)); + } + + #[gpui::test] + fn test_mermaid_no_fallback_on_add_in_middle(cx: &mut TestAppContext) { + let old_full_order = mermaid_sequence(&["graph A", "graph C"]); + let new_full_order = mermaid_sequence(&["graph A", "graph NEW", "graph C"]); + + let mut cache: MermaidDiagramCache = HashMap::default(); + cache.insert( + mermaid_contents("graph A"), + Arc::new(CachedMermaidDiagram::new_for_test( + Some(mock_render_image(cx)), + None, + )), + ); + cache.insert( + mermaid_contents("graph C"), + Arc::new(CachedMermaidDiagram::new_for_test( + Some(mock_render_image(cx)), + None, + )), + ); + + let fallback = mermaid_fallback("graph NEW", &new_full_order, &old_full_order, &cache); + + assert!(fallback.is_none()); + } + + #[gpui::test] + fn test_mermaid_fallback_chains_on_rapid_edits(cx: &mut TestAppContext) { + let old_full_order = mermaid_sequence(&["graph A", "graph B modified", "graph C"]); + let new_full_order = mermaid_sequence(&["graph A", "graph B modified again", "graph C"]); + + let original_svg = mock_render_image(cx); + + let mut cache: MermaidDiagramCache = HashMap::default(); + cache.insert( + mermaid_contents("graph A"), + Arc::new(CachedMermaidDiagram::new_for_test( + Some(mock_render_image(cx)), + None, + )), + ); + cache.insert( + mermaid_contents("graph B modified"), + Arc::new(CachedMermaidDiagram::new_for_test( + None, + Some(original_svg.clone()), + )), + ); + cache.insert( + mermaid_contents("graph C"), + Arc::new(CachedMermaidDiagram::new_for_test( + Some(mock_render_image(cx)), + None, + )), + ); + + let fallback = mermaid_fallback( + "graph B modified again", + &new_full_order, + &old_full_order, + &cache, + ); + + assert_eq!( + fallback.as_ref().map(|image| image.id), + Some(original_svg.id) + ); + } + + #[gpui::test] + fn test_mermaid_fallback_with_duplicate_blocks_edit_second(cx: &mut TestAppContext) { + let old_full_order = mermaid_sequence(&["graph A", "graph A", "graph B"]); + let new_full_order = mermaid_sequence(&["graph A", "graph A edited", "graph B"]); + + let svg_a = mock_render_image(cx); + + let mut cache: MermaidDiagramCache = HashMap::default(); + cache.insert( + mermaid_contents("graph A"), + Arc::new(CachedMermaidDiagram::new_for_test( + Some(svg_a.clone()), + None, + )), + ); + cache.insert( + mermaid_contents("graph B"), + Arc::new(CachedMermaidDiagram::new_for_test( + Some(mock_render_image(cx)), + None, + )), + ); + + let fallback = mermaid_fallback("graph A edited", &new_full_order, &old_full_order, &cache); + + assert_eq!(fallback.as_ref().map(|image| image.id), Some(svg_a.id)); + } + + #[gpui::test] + fn test_mermaid_rendering_replaces_code_block_text(cx: &mut TestAppContext) { + let rendered = render_markdown_with_options( + "```mermaid\ngraph TD;\n```", + MarkdownOptions { + render_mermaid_diagrams: true, + ..Default::default() + }, + cx, + ); + + let text = rendered + .lines + .iter() + .map(|line| line.layout.wrapped_text()) + .collect::>() + .join("\n"); + + assert!(!text.contains("graph TD;")); + } + + #[gpui::test] + fn test_mermaid_source_anchor_maps_inside_block(cx: &mut TestAppContext) { + struct TestWindow; + + impl Render for TestWindow { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + } + } + + ensure_theme_initialized(cx); + + let (_, cx) = cx.add_window_view(|_, _| TestWindow); + let markdown = cx.new(|cx| { + Markdown::new_with_options( + "```mermaid\ngraph TD;\n```".into(), + None, + None, + MarkdownOptions { + render_mermaid_diagrams: true, + ..Default::default() + }, + cx, + ) + }); + cx.run_until_parked(); + let render_image = mock_render_image(cx); + markdown.update(cx, |markdown, _| { + let contents = markdown + .parsed_markdown + .mermaid_diagrams + .values() + .next() + .unwrap() + .contents + .clone(); + markdown.mermaid_state.cache.insert( + contents.clone(), + Arc::new(CachedMermaidDiagram::new_for_test(Some(render_image), None)), + ); + markdown.mermaid_state.order = vec![contents]; + }); + + let (rendered, _) = cx.draw( + Default::default(), + size(px(600.0), px(600.0)), + |_window, _cx| { + MarkdownElement::new(markdown.clone(), MarkdownStyle::default()) + .code_block_renderer(CodeBlockRenderer::Default { + copy_button: false, + copy_button_on_hover: false, + border: false, + }) + }, + ); + + let mermaid_diagram = markdown.update(cx, |markdown, _| { + markdown + .parsed_markdown + .mermaid_diagrams + .values() + .next() + .unwrap() + .clone() + }); + assert!( + rendered + .text + .position_for_source_index(mermaid_diagram.content_range.start) + .is_some() + ); + assert!( + rendered + .text + .position_for_source_index(mermaid_diagram.content_range.end.saturating_sub(1)) + .is_some() + ); + } +} diff --git a/crates/markdown/src/parser.rs b/crates/markdown/src/parser.rs index f530b88908380be13de2005bb8b3ec2b7e6e31b5..2c0ca0cdd2e3f383342be5457d127ce7112e330e 100644 --- a/crates/markdown/src/parser.rs +++ b/crates/markdown/src/parser.rs @@ -4,11 +4,11 @@ pub use pulldown_cmark::TagEnd as MarkdownTagEnd; use pulldown_cmark::{ Alignment, CowStr, HeadingLevel, LinkType, MetadataBlockKind, Options, Parser, }; -use std::{ops::Range, sync::Arc}; +use std::{collections::BTreeMap, ops::Range, sync::Arc}; use collections::HashSet; -use crate::path_range::PathWithRange; +use crate::{html, path_range::PathWithRange}; pub const PARSE_OPTIONS: Options = Options::ENABLE_TABLES .union(Options::ENABLE_FOOTNOTES) @@ -22,16 +22,69 @@ pub const PARSE_OPTIONS: Options = Options::ENABLE_TABLES .union(Options::ENABLE_SUPERSCRIPT) .union(Options::ENABLE_SUBSCRIPT); -pub fn parse_markdown( - text: &str, -) -> ( - Vec<(Range, MarkdownEvent)>, - HashSet, - HashSet>, -) { - let mut events = Vec::new(); +#[derive(Default)] +struct ParseState { + events: Vec<(Range, MarkdownEvent)>, + root_block_starts: Vec, + depth: usize, +} + +#[derive(Debug, Default)] +#[cfg_attr(test, derive(PartialEq))] +pub(crate) struct ParsedMarkdownData { + pub events: Vec<(Range, MarkdownEvent)>, + pub language_names: HashSet, + pub language_paths: HashSet>, + pub root_block_starts: Vec, + pub html_blocks: BTreeMap, +} + +impl ParseState { + fn push_event(&mut self, range: Range, event: MarkdownEvent) { + match &event { + MarkdownEvent::Start(_) => { + if self.depth == 0 { + self.root_block_starts.push(range.start); + self.events.push((range.clone(), MarkdownEvent::RootStart)); + } + self.depth += 1; + self.events.push((range, event)); + } + MarkdownEvent::End(_) => { + self.events.push((range.clone(), event)); + if self.depth > 0 { + self.depth -= 1; + if self.depth == 0 { + let root_block_index = self.root_block_starts.len() - 1; + self.events + .push((range, MarkdownEvent::RootEnd(root_block_index))); + } + } + } + MarkdownEvent::Rule => { + if self.depth == 0 && !range.is_empty() { + self.root_block_starts.push(range.start); + let root_block_index = self.root_block_starts.len() - 1; + self.events.push((range.clone(), MarkdownEvent::RootStart)); + self.events.push((range.clone(), event)); + self.events + .push((range, MarkdownEvent::RootEnd(root_block_index))); + } else { + self.events.push((range, event)); + } + } + _ => { + self.events.push((range, event)); + } + } + } +} + +pub(crate) fn parse_markdown_with_options(text: &str, parse_html: bool) -> ParsedMarkdownData { + let mut state = ParseState::default(); let mut language_names = HashSet::default(); let mut language_paths = HashSet::default(); + let mut html_blocks = BTreeMap::default(); let mut within_link = false; let mut within_metadata = false; let mut parser = Parser::new_ext(text, PARSE_OPTIONS) @@ -48,6 +101,32 @@ pub fn parse_markdown( } match pulldown_event { pulldown_cmark::Event::Start(tag) => { + if let pulldown_cmark::Tag::HtmlBlock = &tag { + state.push_event(range.clone(), MarkdownEvent::Start(MarkdownTag::HtmlBlock)); + + if parse_html { + if let Some(block) = + html::html_parser::parse_html_block(&text[range.clone()], range.clone()) + { + html_blocks.insert(range.start, block); + + while let Some((event, end_range)) = parser.next() { + if let pulldown_cmark::Event::End( + pulldown_cmark::TagEnd::HtmlBlock, + ) = event + { + state.push_event( + end_range, + MarkdownEvent::End(MarkdownTagEnd::HtmlBlock), + ); + break; + } + } + } + } + continue; + } + let tag = match tag { pulldown_cmark::Tag::Link { link_type, @@ -63,9 +142,9 @@ pub fn parse_markdown( id: SharedString::from(id.into_string()), } } - pulldown_cmark::Tag::MetadataBlock(kind) => { + pulldown_cmark::Tag::MetadataBlock(_kind) => { within_metadata = true; - MarkdownTag::MetadataBlock(kind) + continue; } pulldown_cmark::Tag::CodeBlock(pulldown_cmark::CodeBlockKind::Indented) => { MarkdownTag::CodeBlock { @@ -164,20 +243,20 @@ pub fn parse_markdown( title: SharedString::from(title.into_string()), id: SharedString::from(id.into_string()), }, - pulldown_cmark::Tag::HtmlBlock => MarkdownTag::HtmlBlock, + pulldown_cmark::Tag::HtmlBlock => MarkdownTag::HtmlBlock, // this is handled above separately pulldown_cmark::Tag::DefinitionList => MarkdownTag::DefinitionList, pulldown_cmark::Tag::DefinitionListTitle => MarkdownTag::DefinitionListTitle, pulldown_cmark::Tag::DefinitionListDefinition => { MarkdownTag::DefinitionListDefinition } }; - events.push((range, MarkdownEvent::Start(tag))) + state.push_event(range, MarkdownEvent::Start(tag)) } pulldown_cmark::Event::End(tag) => { if let pulldown_cmark::TagEnd::Link = tag { within_link = false; } - events.push((range, MarkdownEvent::End(tag))); + state.push_event(range, MarkdownEvent::End(tag)); } pulldown_cmark::Event::Text(parsed) => { fn event_for( @@ -205,16 +284,26 @@ pub fn parse_markdown( parsed, }]; - while matches!(parser.peek(), Some((pulldown_cmark::Event::Text(_), _))) { - let Some((pulldown_cmark::Event::Text(next_event), next_range)) = parser.next() - else { + while matches!(parser.peek(), Some((pulldown_cmark::Event::Text(_), _))) + || (parse_html + && matches!( + parser.peek(), + Some((pulldown_cmark::Event::InlineHtml(_), _)) + )) + { + let Some((next_event, next_range)) = parser.next() else { unreachable!() }; - let next_len = last_len + next_event.len(); + let next_text = match next_event { + pulldown_cmark::Event::Text(next_event) => next_event, + pulldown_cmark::Event::InlineHtml(_) => CowStr::Borrowed(""), + _ => unreachable!(), + }; + let next_len = last_len + next_text.len(); ranges.push(TextRange { source_range: next_range.clone(), merged_range: last_len..next_len, - parsed: next_event, + parsed: next_text, }); last_len = next_len; } @@ -241,7 +330,8 @@ pub fn parse_markdown( .is_some_and(|range| range.merged_range.end <= link_start_in_merged) { let range = ranges.next().unwrap(); - events.push(event_for(text, range.source_range, &range.parsed)); + let (range, event) = event_for(text, range.source_range, &range.parsed); + state.push_event(range, event); } let Some(range) = ranges.peek_mut() else { @@ -250,11 +340,12 @@ pub fn parse_markdown( let prefix_len = link_start_in_merged - range.merged_range.start; if prefix_len > 0 { let (head, tail) = range.parsed.split_at(prefix_len); - events.push(event_for( + let (event_range, event) = event_for( text, range.source_range.start..range.source_range.start + prefix_len, head, - )); + ); + state.push_event(event_range, event); range.parsed = CowStr::Boxed(tail.into()); range.merged_range.start += prefix_len; range.source_range.start += prefix_len; @@ -290,7 +381,7 @@ pub fn parse_markdown( } let link_range = link_start_in_source..link_end_in_source; - events.push(( + state.push_event( link_range.clone(), MarkdownEvent::Start(MarkdownTag::Link { link_type: LinkType::Autolink, @@ -298,37 +389,52 @@ pub fn parse_markdown( title: SharedString::default(), id: SharedString::default(), }), - )); - events.extend(link_events); - events.push((link_range.clone(), MarkdownEvent::End(MarkdownTagEnd::Link))); + ); + for (range, event) in link_events { + state.push_event(range, event); + } + state.push_event( + link_range.clone(), + MarkdownEvent::End(MarkdownTagEnd::Link), + ); } } for range in ranges { - events.push(event_for(text, range.source_range, &range.parsed)); + let (range, event) = event_for(text, range.source_range, &range.parsed); + state.push_event(range, event); } } pulldown_cmark::Event::Code(_) => { let content_range = extract_code_content_range(&text[range.clone()]); let content_range = content_range.start + range.start..content_range.end + range.start; - events.push((content_range, MarkdownEvent::Code)) + state.push_event(content_range, MarkdownEvent::Code) + } + pulldown_cmark::Event::Html(_) => state.push_event(range, MarkdownEvent::Html), + pulldown_cmark::Event::InlineHtml(_) => { + state.push_event(range, MarkdownEvent::InlineHtml) } - pulldown_cmark::Event::Html(_) => events.push((range, MarkdownEvent::Html)), - pulldown_cmark::Event::InlineHtml(_) => events.push((range, MarkdownEvent::InlineHtml)), pulldown_cmark::Event::FootnoteReference(_) => { - events.push((range, MarkdownEvent::FootnoteReference)) + state.push_event(range, MarkdownEvent::FootnoteReference) } - pulldown_cmark::Event::SoftBreak => events.push((range, MarkdownEvent::SoftBreak)), - pulldown_cmark::Event::HardBreak => events.push((range, MarkdownEvent::HardBreak)), - pulldown_cmark::Event::Rule => events.push((range, MarkdownEvent::Rule)), + pulldown_cmark::Event::SoftBreak => state.push_event(range, MarkdownEvent::SoftBreak), + pulldown_cmark::Event::HardBreak => state.push_event(range, MarkdownEvent::HardBreak), + pulldown_cmark::Event::Rule => state.push_event(range, MarkdownEvent::Rule), pulldown_cmark::Event::TaskListMarker(checked) => { - events.push((range, MarkdownEvent::TaskListMarker(checked))) + state.push_event(range, MarkdownEvent::TaskListMarker(checked)) } pulldown_cmark::Event::InlineMath(_) | pulldown_cmark::Event::DisplayMath(_) => {} } } - (events, language_names, language_paths) + + ParsedMarkdownData { + events: state.events, + language_names, + language_paths, + root_block_starts: state.root_block_starts, + html_blocks, + } } pub fn parse_links_only(text: &str) -> Vec<(Range, MarkdownEvent)> { @@ -401,6 +507,10 @@ pub enum MarkdownEvent { Rule, /// A task list marker, rendered as a checkbox in HTML. Contains a true when it is checked. TaskListMarker(bool), + /// Start of a root-level block (a top-level structural element like a paragraph, heading, list, etc.). + RootStart, + /// End of a root-level block. Contains the root block index. + RootEnd(usize), } /// Tags for elements that can contain other elements. @@ -575,31 +685,39 @@ mod tests { #[test] fn test_html_comments() { assert_eq!( - parse_markdown(" \nReturns"), - ( - vec![ + parse_markdown_with_options(" \nReturns", false), + ParsedMarkdownData { + events: vec![ + (2..30, RootStart), (2..30, Start(HtmlBlock)), (2..2, SubstitutedText(" ".into())), (2..7, Html), (7..26, Html), (26..30, Html), (2..30, End(MarkdownTagEnd::HtmlBlock)), + (2..30, RootEnd(0)), + (30..37, RootStart), (30..37, Start(Paragraph)), (30..37, Text), - (30..37, End(MarkdownTagEnd::Paragraph)) + (30..37, End(MarkdownTagEnd::Paragraph)), + (30..37, RootEnd(1)), ], - HashSet::default(), - HashSet::default() - ) + root_block_starts: vec![2, 30], + ..Default::default() + } ) } #[test] fn test_plain_urls_and_escaped_text() { assert_eq!( - parse_markdown("   https://some.url some \\`►\\` text"), - ( - vec![ + parse_markdown_with_options( + "   https://some.url some \\`►\\` text", + false + ), + ParsedMarkdownData { + events: vec![ + (0..51, RootStart), (0..51, Start(Paragraph)), (0..6, SubstitutedText("\u{a0}".into())), (6..12, SubstitutedText("\u{a0}".into())), @@ -620,19 +738,25 @@ mod tests { (37..44, SubstitutedText("►".into())), (45..46, Text), // Escaped backtick (46..51, Text), - (0..51, End(MarkdownTagEnd::Paragraph)) + (0..51, End(MarkdownTagEnd::Paragraph)), + (0..51, RootEnd(0)), ], - HashSet::default(), - HashSet::default() - ) + root_block_starts: vec![0], + ..Default::default() + } ); } #[test] fn test_incomplete_link() { assert_eq!( - parse_markdown("You can use the [GitHub Search API](https://docs.github.com/en").0, + parse_markdown_with_options( + "You can use the [GitHub Search API](https://docs.github.com/en", + false + ) + .events, vec![ + (0..62, RootStart), (0..62, Start(Paragraph)), (0..16, Text), (16..17, Text), @@ -650,7 +774,8 @@ mod tests { ), (36..62, Text), (36..62, End(MarkdownTagEnd::Link)), - (0..62, End(MarkdownTagEnd::Paragraph)) + (0..62, End(MarkdownTagEnd::Paragraph)), + (0..62, RootEnd(0)), ], ); } @@ -658,9 +783,13 @@ mod tests { #[test] fn test_smart_punctuation() { assert_eq!( - parse_markdown("-- --- ... \"double quoted\" 'single quoted' ----------"), - ( - vec![ + parse_markdown_with_options( + "-- --- ... \"double quoted\" 'single quoted' ----------", + false + ), + ParsedMarkdownData { + events: vec![ + (0..53, RootStart), (0..53, Start(Paragraph)), (0..2, SubstitutedText("–".into())), (2..3, Text), @@ -668,29 +797,31 @@ mod tests { (6..7, Text), (7..10, SubstitutedText("…".into())), (10..11, Text), - (11..12, SubstitutedText("“".into())), + (11..12, SubstitutedText("\u{201c}".into())), (12..25, Text), - (25..26, SubstitutedText("”".into())), + (25..26, SubstitutedText("\u{201d}".into())), (26..27, Text), - (27..28, SubstitutedText("‘".into())), + (27..28, SubstitutedText("\u{2018}".into())), (28..41, Text), - (41..42, SubstitutedText("’".into())), + (41..42, SubstitutedText("\u{2019}".into())), (42..43, Text), (43..53, SubstitutedText("–––––".into())), - (0..53, End(MarkdownTagEnd::Paragraph)) + (0..53, End(MarkdownTagEnd::Paragraph)), + (0..53, RootEnd(0)), ], - HashSet::default(), - HashSet::default() - ) + root_block_starts: vec![0], + ..Default::default() + } ) } #[test] fn test_code_block_metadata() { assert_eq!( - parse_markdown("```rust\nfn main() {\n let a = 1;\n}\n```"), - ( - vec![ + parse_markdown_with_options("```rust\nfn main() {\n let a = 1;\n}\n```", false), + ParsedMarkdownData { + events: vec![ + (0..37, RootStart), ( 0..37, Start(CodeBlock { @@ -703,19 +834,22 @@ mod tests { ), (8..34, Text), (0..37, End(MarkdownTagEnd::CodeBlock)), + (0..37, RootEnd(0)), ], - { + language_names: { let mut h = HashSet::default(); h.insert("rust".into()); h }, - HashSet::default() - ) + root_block_starts: vec![0], + ..Default::default() + } ); assert_eq!( - parse_markdown(" fn main() {}"), - ( - vec![ + parse_markdown_with_options(" fn main() {}", false), + ParsedMarkdownData { + events: vec![ + (4..16, RootStart), ( 4..16, Start(CodeBlock { @@ -727,14 +861,76 @@ mod tests { }) ), (4..16, Text), - (4..16, End(MarkdownTagEnd::CodeBlock)) + (4..16, End(MarkdownTagEnd::CodeBlock)), + (4..16, RootEnd(0)), ], - HashSet::default(), - HashSet::default() - ) + root_block_starts: vec![4], + ..Default::default() + } ); } + #[test] + fn test_metadata_blocks_do_not_affect_root_blocks() { + assert_eq!( + parse_markdown_with_options("+++\ntitle = \"Example\"\n+++\n\nParagraph", false), + ParsedMarkdownData { + events: vec![ + (27..36, RootStart), + (27..36, Start(Paragraph)), + (27..36, Text), + (27..36, End(MarkdownTagEnd::Paragraph)), + (27..36, RootEnd(0)), + ], + root_block_starts: vec![27], + ..Default::default() + } + ); + } + + #[test] + fn test_table_checkboxes_remain_text_in_cells() { + let markdown = "\ +| Done | Task | +|------|---------| +| [x] | Fix bug | +| [ ] | Add feature |"; + let parsed = parse_markdown_with_options(markdown, false); + + let mut in_table = false; + let mut saw_task_list_marker = false; + let mut cell_texts = Vec::new(); + let mut current_cell = String::new(); + + for (range, event) in &parsed.events { + match event { + Start(Table(_)) => in_table = true, + End(MarkdownTagEnd::Table) => in_table = false, + Start(TableCell) => current_cell.clear(), + End(MarkdownTagEnd::TableCell) => { + if in_table { + cell_texts.push(current_cell.clone()); + } + } + Text if in_table => current_cell.push_str(&markdown[range.clone()]), + TaskListMarker(_) if in_table => saw_task_list_marker = true, + _ => {} + } + } + + let checkbox_cells: Vec<&str> = cell_texts + .iter() + .map(|cell| cell.trim()) + .filter(|cell| *cell == "[x]" || *cell == "[X]" || *cell == "[ ]") + .collect(); + + assert!( + !saw_task_list_marker, + "Table checkboxes should remain text, not task-list markers" + ); + assert_eq!(checkbox_cells, vec!["[x]", "[ ]"]); + } + #[test] fn test_extract_code_content_range() { let input = "```let x = 5;```"; @@ -776,8 +972,13 @@ mod tests { // Note: In real usage, pulldown_cmark creates separate text events for the escaped character // We're verifying our parser can handle this correctly assert_eq!( - parse_markdown("https:/\\/example.com is equivalent to https://example.com!").0, + parse_markdown_with_options( + "https:/\\/example.com is equivalent to https://example.com!", + false + ) + .events, vec![ + (0..62, RootStart), (0..62, Start(Paragraph)), ( 0..20, @@ -806,13 +1007,19 @@ mod tests { (58..61, Text), (38..61, End(MarkdownTagEnd::Link)), (61..62, Text), - (0..62, End(MarkdownTagEnd::Paragraph)) + (0..62, End(MarkdownTagEnd::Paragraph)), + (0..62, RootEnd(0)), ], ); assert_eq!( - parse_markdown("Visit https://example.com/cat\\/é‍☕ for coffee!").0, + parse_markdown_with_options( + "Visit https://example.com/cat\\/é‍☕ for coffee!", + false + ) + .events, [ + (0..55, RootStart), (0..55, Start(Paragraph)), (0..6, Text), ( @@ -830,7 +1037,8 @@ mod tests { (40..43, Text), (6..43, End(MarkdownTagEnd::Link)), (43..55, Text), - (0..55, End(MarkdownTagEnd::Paragraph)) + (0..55, End(MarkdownTagEnd::Paragraph)), + (0..55, RootEnd(0)), ] ); } diff --git a/crates/markdown_preview/Cargo.toml b/crates/markdown_preview/Cargo.toml index 782de627ec26273820bb3505b778a862659f315f..19f1270bb91e8a7e9e660a62d8191a9d12b66641 100644 --- a/crates/markdown_preview/Cargo.toml +++ b/crates/markdown_preview/Cargo.toml @@ -16,28 +16,18 @@ test-support = [] [dependencies] anyhow.workspace = true -async-recursion.workspace = true -collections.workspace = true editor.workspace = true gpui.workspace = true -html5ever.workspace = true language.workspace = true -linkify.workspace = true log.workspace = true markdown.workspace = true -markup5ever_rcdom.workspace = true -pretty_assertions.workspace = true -pulldown-cmark.workspace = true settings.workspace = true -stacksafe.workspace = true -theme.workspace = true +theme_settings.workspace = true ui.workspace = true urlencoding.workspace = true util.workspace = true workspace.workspace = true zed_actions.workspace = true -mermaid-rs-renderer.workspace = true [dev-dependencies] -editor = { workspace = true, features = ["test-support"] } -language = { workspace = true, features = ["test-support"] } +tempfile.workspace = true diff --git a/crates/markdown_preview/src/markdown_elements.rs b/crates/markdown_preview/src/markdown_elements.rs deleted file mode 100644 index 1887da31621901fe7582192770018bd4e53a3c64..0000000000000000000000000000000000000000 --- a/crates/markdown_preview/src/markdown_elements.rs +++ /dev/null @@ -1,373 +0,0 @@ -use gpui::{ - DefiniteLength, FontStyle, FontWeight, HighlightStyle, SharedString, StrikethroughStyle, - UnderlineStyle, px, -}; -use language::HighlightId; -use std::{fmt::Display, ops::Range, path::PathBuf}; -use urlencoding; - -#[derive(Debug)] -#[cfg_attr(test, derive(PartialEq))] -pub enum ParsedMarkdownElement { - Heading(ParsedMarkdownHeading), - ListItem(ParsedMarkdownListItem), - Table(ParsedMarkdownTable), - BlockQuote(ParsedMarkdownBlockQuote), - CodeBlock(ParsedMarkdownCodeBlock), - MermaidDiagram(ParsedMarkdownMermaidDiagram), - /// A paragraph of text and other inline elements. - Paragraph(MarkdownParagraph), - HorizontalRule(Range), - Image(Image), -} - -impl ParsedMarkdownElement { - pub fn source_range(&self) -> Option> { - Some(match self { - Self::Heading(heading) => heading.source_range.clone(), - Self::ListItem(list_item) => list_item.source_range.clone(), - Self::Table(table) => table.source_range.clone(), - Self::BlockQuote(block_quote) => block_quote.source_range.clone(), - Self::CodeBlock(code_block) => code_block.source_range.clone(), - Self::MermaidDiagram(mermaid) => mermaid.source_range.clone(), - Self::Paragraph(text) => match text.get(0)? { - MarkdownParagraphChunk::Text(t) => t.source_range.clone(), - MarkdownParagraphChunk::Image(image) => image.source_range.clone(), - }, - Self::HorizontalRule(range) => range.clone(), - Self::Image(image) => image.source_range.clone(), - }) - } - - pub fn is_list_item(&self) -> bool { - matches!(self, Self::ListItem(_)) - } -} - -pub type MarkdownParagraph = Vec; - -#[derive(Debug)] -#[cfg_attr(test, derive(PartialEq))] -pub enum MarkdownParagraphChunk { - Text(ParsedMarkdownText), - Image(Image), -} - -#[derive(Debug)] -#[cfg_attr(test, derive(PartialEq))] -pub struct ParsedMarkdown { - pub children: Vec, -} - -#[derive(Debug)] -#[cfg_attr(test, derive(PartialEq))] -pub struct ParsedMarkdownListItem { - pub source_range: Range, - /// How many indentations deep this item is. - pub depth: u16, - pub item_type: ParsedMarkdownListItemType, - pub content: Vec, - /// Whether we can expect nested list items inside of this items `content`. - pub nested: bool, -} - -#[derive(Debug)] -#[cfg_attr(test, derive(PartialEq))] -pub enum ParsedMarkdownListItemType { - Ordered(u64), - Task(bool, Range), - Unordered, -} - -#[derive(Debug)] -#[cfg_attr(test, derive(PartialEq))] -pub struct ParsedMarkdownCodeBlock { - pub source_range: Range, - pub language: Option, - pub contents: SharedString, - pub highlights: Option, HighlightId)>>, -} - -#[derive(Debug)] -#[cfg_attr(test, derive(PartialEq))] -pub struct ParsedMarkdownMermaidDiagram { - pub source_range: Range, - pub contents: ParsedMarkdownMermaidDiagramContents, -} - -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub struct ParsedMarkdownMermaidDiagramContents { - pub contents: SharedString, - pub scale: u32, -} - -#[derive(Debug)] -#[cfg_attr(test, derive(PartialEq))] -pub struct ParsedMarkdownHeading { - pub source_range: Range, - pub level: HeadingLevel, - pub contents: MarkdownParagraph, -} - -#[derive(Debug, PartialEq)] -pub enum HeadingLevel { - H1, - H2, - H3, - H4, - H5, - H6, -} - -#[derive(Debug)] -pub struct ParsedMarkdownTable { - pub source_range: Range, - pub header: Vec, - pub body: Vec, - pub caption: Option, -} - -#[derive(Debug, Clone, Copy, Default)] -#[cfg_attr(test, derive(PartialEq))] -pub enum ParsedMarkdownTableAlignment { - #[default] - None, - Left, - Center, - Right, -} - -#[derive(Debug)] -#[cfg_attr(test, derive(PartialEq))] -pub struct ParsedMarkdownTableColumn { - pub col_span: usize, - pub row_span: usize, - pub is_header: bool, - pub children: MarkdownParagraph, - pub alignment: ParsedMarkdownTableAlignment, -} - -#[derive(Debug)] -#[cfg_attr(test, derive(PartialEq))] -pub struct ParsedMarkdownTableRow { - pub columns: Vec, -} - -impl Default for ParsedMarkdownTableRow { - fn default() -> Self { - Self::new() - } -} - -impl ParsedMarkdownTableRow { - pub fn new() -> Self { - Self { - columns: Vec::new(), - } - } - - pub fn with_columns(columns: Vec) -> Self { - Self { columns } - } -} - -#[derive(Debug)] -#[cfg_attr(test, derive(PartialEq))] -pub struct ParsedMarkdownBlockQuote { - pub source_range: Range, - pub children: Vec, -} - -#[derive(Debug, Clone)] -pub struct ParsedMarkdownText { - /// Where the text is located in the source Markdown document. - pub source_range: Range, - /// The text content stripped of any formatting symbols. - pub contents: SharedString, - /// The list of highlights contained in the Markdown document. - pub highlights: Vec<(Range, MarkdownHighlight)>, - /// The regions of the Markdown document. - pub regions: Vec<(Range, ParsedRegion)>, -} - -/// A run of highlighted Markdown text. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum MarkdownHighlight { - /// A styled Markdown highlight. - Style(MarkdownHighlightStyle), - /// A highlighted code block. - Code(HighlightId), -} - -impl MarkdownHighlight { - /// Converts this [`MarkdownHighlight`] to a [`HighlightStyle`]. - pub fn to_highlight_style(&self, theme: &theme::SyntaxTheme) -> Option { - match self { - MarkdownHighlight::Style(style) => { - let mut highlight = HighlightStyle::default(); - - if style.italic { - highlight.font_style = Some(FontStyle::Italic); - } - - if style.underline { - highlight.underline = Some(UnderlineStyle { - thickness: px(1.), - ..Default::default() - }); - } - - if style.strikethrough { - highlight.strikethrough = Some(StrikethroughStyle { - thickness: px(1.), - ..Default::default() - }); - } - - if style.weight != FontWeight::default() { - highlight.font_weight = Some(style.weight); - } - - if style.link { - highlight.underline = Some(UnderlineStyle { - thickness: px(1.), - ..Default::default() - }); - } - - if style.oblique { - highlight.font_style = Some(FontStyle::Oblique) - } - - Some(highlight) - } - - MarkdownHighlight::Code(id) => id.style(theme), - } - } -} - -/// The style for a Markdown highlight. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct MarkdownHighlightStyle { - /// Whether the text should be italicized. - pub italic: bool, - /// Whether the text should be underlined. - pub underline: bool, - /// Whether the text should be struck through. - pub strikethrough: bool, - /// The weight of the text. - pub weight: FontWeight, - /// Whether the text should be stylized as link. - pub link: bool, - // Whether the text should be obliqued. - pub oblique: bool, -} - -/// A parsed region in a Markdown document. -#[derive(Debug, Clone)] -#[cfg_attr(test, derive(PartialEq))] -pub struct ParsedRegion { - /// Whether the region is a code block. - pub code: bool, - /// The link contained in this region, if it has one. - pub link: Option, -} - -/// A Markdown link. -#[derive(Debug, Clone)] -#[cfg_attr(test, derive(PartialEq))] -pub enum Link { - /// A link to a webpage. - Web { - /// The URL of the webpage. - url: String, - }, - /// A link to a path on the filesystem. - Path { - /// The path as provided in the Markdown document. - display_path: PathBuf, - /// The absolute path to the item. - path: PathBuf, - }, -} - -impl Link { - pub fn identify(file_location_directory: Option, text: String) -> Option { - if text.starts_with("http") { - return Some(Link::Web { url: text }); - } - - // URL decode the text to handle spaces and other special characters - let decoded_text = urlencoding::decode(&text) - .map(|s| s.into_owned()) - .unwrap_or(text); - - let path = PathBuf::from(&decoded_text); - if path.is_absolute() && path.exists() { - return Some(Link::Path { - display_path: path.clone(), - path, - }); - } - - if let Some(file_location_directory) = file_location_directory { - let display_path = path; - let path = file_location_directory.join(decoded_text); - if path.exists() { - return Some(Link::Path { display_path, path }); - } - } - - None - } -} - -impl Display for Link { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Link::Web { url } => write!(f, "{}", url), - Link::Path { display_path, .. } => write!(f, "{}", display_path.display()), - } - } -} - -/// A Markdown Image -#[derive(Debug, Clone)] -#[cfg_attr(test, derive(PartialEq))] -pub struct Image { - pub link: Link, - pub source_range: Range, - pub alt_text: Option, - pub width: Option, - pub height: Option, -} - -impl Image { - pub fn identify( - text: String, - source_range: Range, - file_location_directory: Option, - ) -> Option { - let link = Link::identify(file_location_directory, text)?; - Some(Self { - source_range, - link, - alt_text: None, - width: None, - height: None, - }) - } - - pub fn set_alt_text(&mut self, alt_text: SharedString) { - self.alt_text = Some(alt_text); - } - - pub fn set_width(&mut self, width: DefiniteLength) { - self.width = Some(width); - } - - pub fn set_height(&mut self, height: DefiniteLength) { - self.height = Some(height); - } -} diff --git a/crates/markdown_preview/src/markdown_parser.rs b/crates/markdown_preview/src/markdown_parser.rs deleted file mode 100644 index 40a1ed804f750a7e3173a76643ad1f6b1a362bd3..0000000000000000000000000000000000000000 --- a/crates/markdown_preview/src/markdown_parser.rs +++ /dev/null @@ -1,3320 +0,0 @@ -use crate::{ - markdown_elements::*, - markdown_minifier::{Minifier, MinifierOptions}, -}; -use async_recursion::async_recursion; -use collections::FxHashMap; -use gpui::{DefiniteLength, FontWeight, px, relative}; -use html5ever::{ParseOpts, local_name, parse_document, tendril::TendrilSink}; -use language::LanguageRegistry; -use markdown::parser::PARSE_OPTIONS; -use markup5ever_rcdom::RcDom; -use pulldown_cmark::{Alignment, Event, Parser, Tag, TagEnd}; -use stacksafe::stacksafe; -use std::{ - cell::RefCell, collections::HashMap, mem, ops::Range, path::PathBuf, rc::Rc, sync::Arc, vec, -}; -use ui::SharedString; - -pub async fn parse_markdown( - markdown_input: &str, - file_location_directory: Option, - language_registry: Option>, -) -> ParsedMarkdown { - let parser = Parser::new_ext(markdown_input, PARSE_OPTIONS); - let parser = MarkdownParser::new( - parser.into_offset_iter().collect(), - file_location_directory, - language_registry, - ); - let renderer = parser.parse_document().await; - ParsedMarkdown { - children: renderer.parsed, - } -} - -fn cleanup_html(source: &str) -> Vec { - let mut writer = std::io::Cursor::new(Vec::new()); - let mut reader = std::io::Cursor::new(source); - let mut minify = Minifier::new( - &mut writer, - MinifierOptions { - omit_doctype: true, - collapse_whitespace: true, - ..Default::default() - }, - ); - if let Ok(()) = minify.minify(&mut reader) { - writer.into_inner() - } else { - source.bytes().collect() - } -} - -struct MarkdownParser<'a> { - tokens: Vec<(Event<'a>, Range)>, - /// The current index in the tokens array - cursor: usize, - /// The blocks that we have successfully parsed so far - parsed: Vec, - file_location_directory: Option, - language_registry: Option>, -} - -#[derive(Debug)] -struct ParseHtmlNodeContext { - list_item_depth: u16, -} - -impl Default for ParseHtmlNodeContext { - fn default() -> Self { - Self { list_item_depth: 1 } - } -} - -struct MarkdownListItem { - content: Vec, - item_type: ParsedMarkdownListItemType, -} - -impl Default for MarkdownListItem { - fn default() -> Self { - Self { - content: Vec::new(), - item_type: ParsedMarkdownListItemType::Unordered, - } - } -} - -impl<'a> MarkdownParser<'a> { - fn new( - tokens: Vec<(Event<'a>, Range)>, - file_location_directory: Option, - language_registry: Option>, - ) -> Self { - Self { - tokens, - file_location_directory, - language_registry, - cursor: 0, - parsed: vec![], - } - } - - fn eof(&self) -> bool { - if self.tokens.is_empty() { - return true; - } - self.cursor >= self.tokens.len() - 1 - } - - fn peek(&self, steps: usize) -> Option<&(Event<'_>, Range)> { - if self.eof() || (steps + self.cursor) >= self.tokens.len() { - return self.tokens.last(); - } - self.tokens.get(self.cursor + steps) - } - - fn previous(&self) -> Option<&(Event<'_>, Range)> { - if self.cursor == 0 || self.cursor > self.tokens.len() { - return None; - } - self.tokens.get(self.cursor - 1) - } - - fn current(&self) -> Option<&(Event<'_>, Range)> { - self.peek(0) - } - - fn current_event(&self) -> Option<&Event<'_>> { - self.current().map(|(event, _)| event) - } - - fn is_text_like(event: &Event) -> bool { - match event { - Event::Text(_) - // Represent an inline code block - | Event::Code(_) - | Event::Html(_) - | Event::InlineHtml(_) - | Event::FootnoteReference(_) - | Event::Start(Tag::Link { .. }) - | Event::Start(Tag::Emphasis) - | Event::Start(Tag::Strong) - | Event::Start(Tag::Strikethrough) - | Event::Start(Tag::Image { .. }) => { - true - } - _ => false, - } - } - - async fn parse_document(mut self) -> Self { - while !self.eof() { - if let Some(block) = self.parse_block().await { - self.parsed.extend(block); - } else { - self.cursor += 1; - } - } - self - } - - #[async_recursion] - async fn parse_block(&mut self) -> Option> { - let (current, source_range) = self.current().unwrap(); - let source_range = source_range.clone(); - match current { - Event::Start(tag) => match tag { - Tag::Paragraph => { - self.cursor += 1; - let text = self.parse_text(false, Some(source_range)); - Some(vec![ParsedMarkdownElement::Paragraph(text)]) - } - Tag::Heading { level, .. } => { - let level = *level; - self.cursor += 1; - let heading = self.parse_heading(level); - Some(vec![ParsedMarkdownElement::Heading(heading)]) - } - Tag::Table(alignment) => { - let alignment = alignment.clone(); - self.cursor += 1; - let table = self.parse_table(alignment); - Some(vec![ParsedMarkdownElement::Table(table)]) - } - Tag::List(order) => { - let order = *order; - self.cursor += 1; - let list = self.parse_list(order).await; - Some(list) - } - Tag::BlockQuote(_kind) => { - self.cursor += 1; - let block_quote = self.parse_block_quote().await; - Some(vec![ParsedMarkdownElement::BlockQuote(block_quote)]) - } - Tag::CodeBlock(kind) => { - let (language, scale) = match kind { - pulldown_cmark::CodeBlockKind::Indented => (None, None), - pulldown_cmark::CodeBlockKind::Fenced(language) => { - if language.is_empty() { - (None, None) - } else { - let parts: Vec<&str> = language.split_whitespace().collect(); - let lang = parts.first().map(|s| s.to_string()); - let scale = parts.get(1).and_then(|s| s.parse::().ok()); - (lang, scale) - } - } - }; - - self.cursor += 1; - - if language.as_deref() == Some("mermaid") { - let mermaid_diagram = self.parse_mermaid_diagram(scale).await?; - Some(vec![ParsedMarkdownElement::MermaidDiagram(mermaid_diagram)]) - } else { - let code_block = self.parse_code_block(language).await?; - Some(vec![ParsedMarkdownElement::CodeBlock(code_block)]) - } - } - Tag::HtmlBlock => { - self.cursor += 1; - - Some(self.parse_html_block().await) - } - _ => None, - }, - Event::Rule => { - self.cursor += 1; - Some(vec![ParsedMarkdownElement::HorizontalRule(source_range)]) - } - _ => None, - } - } - - fn parse_text( - &mut self, - should_complete_on_soft_break: bool, - source_range: Option>, - ) -> MarkdownParagraph { - let source_range = source_range.unwrap_or_else(|| { - self.current() - .map(|(_, range)| range.clone()) - .unwrap_or_default() - }); - - let mut markdown_text_like = Vec::new(); - let mut text = String::new(); - let mut bold_depth = 0; - let mut italic_depth = 0; - let mut strikethrough_depth = 0; - let mut link: Option = None; - let mut image: Option = None; - let mut regions: Vec<(Range, ParsedRegion)> = vec![]; - let mut highlights: Vec<(Range, MarkdownHighlight)> = vec![]; - let mut link_urls: Vec = vec![]; - let mut link_ranges: Vec> = vec![]; - - loop { - if self.eof() { - break; - } - - let (current, _) = self.current().unwrap(); - let prev_len = text.len(); - match current { - Event::SoftBreak => { - if should_complete_on_soft_break { - break; - } - text.push(' '); - } - - Event::HardBreak => { - text.push('\n'); - } - - // We want to ignore any inline HTML tags in the text but keep - // the text between them - Event::InlineHtml(_) => {} - - Event::Text(t) => { - text.push_str(t.as_ref()); - let mut style = MarkdownHighlightStyle::default(); - - if bold_depth > 0 { - style.weight = FontWeight::BOLD; - } - - if italic_depth > 0 { - style.italic = true; - } - - if strikethrough_depth > 0 { - style.strikethrough = true; - } - - let last_run_len = if let Some(link) = link.clone() { - regions.push(( - prev_len..text.len(), - ParsedRegion { - code: false, - link: Some(link), - }, - )); - style.link = true; - prev_len - } else { - // Manually scan for links - let mut finder = linkify::LinkFinder::new(); - finder.kinds(&[linkify::LinkKind::Url]); - let mut last_link_len = prev_len; - for link in finder.links(t) { - let start = prev_len + link.start(); - let end = prev_len + link.end(); - let range = start..end; - link_ranges.push(range.clone()); - link_urls.push(link.as_str().to_string()); - - // If there is a style before we match a link, we have to add this to the highlighted ranges - if style != MarkdownHighlightStyle::default() && last_link_len < start { - highlights.push(( - last_link_len..start, - MarkdownHighlight::Style(style.clone()), - )); - } - - highlights.push(( - range.clone(), - MarkdownHighlight::Style(MarkdownHighlightStyle { - underline: true, - ..style - }), - )); - - regions.push(( - range.clone(), - ParsedRegion { - code: false, - link: Some(Link::Web { - url: link.as_str().to_string(), - }), - }, - )); - last_link_len = end; - } - last_link_len - }; - - if style != MarkdownHighlightStyle::default() && last_run_len < text.len() { - let mut new_highlight = true; - if let Some((last_range, last_style)) = highlights.last_mut() - && last_range.end == last_run_len - && last_style == &MarkdownHighlight::Style(style.clone()) - { - last_range.end = text.len(); - new_highlight = false; - } - if new_highlight { - highlights.push(( - last_run_len..text.len(), - MarkdownHighlight::Style(style.clone()), - )); - } - } - } - Event::Code(t) => { - text.push_str(t.as_ref()); - let range = prev_len..text.len(); - - if link.is_some() { - highlights.push(( - range.clone(), - MarkdownHighlight::Style(MarkdownHighlightStyle { - link: true, - ..Default::default() - }), - )); - } - regions.push(( - range, - ParsedRegion { - code: true, - link: link.clone(), - }, - )); - } - Event::Start(tag) => match tag { - Tag::Emphasis => italic_depth += 1, - Tag::Strong => bold_depth += 1, - Tag::Strikethrough => strikethrough_depth += 1, - Tag::Link { dest_url, .. } => { - link = Link::identify( - self.file_location_directory.clone(), - dest_url.to_string(), - ); - } - Tag::Image { dest_url, .. } => { - if !text.is_empty() { - let parsed_regions = MarkdownParagraphChunk::Text(ParsedMarkdownText { - source_range: source_range.clone(), - contents: mem::take(&mut text).into(), - highlights: mem::take(&mut highlights), - regions: mem::take(&mut regions), - }); - markdown_text_like.push(parsed_regions); - } - image = Image::identify( - dest_url.to_string(), - source_range.clone(), - self.file_location_directory.clone(), - ); - } - _ => { - break; - } - }, - - Event::End(tag) => match tag { - TagEnd::Emphasis => italic_depth -= 1, - TagEnd::Strong => bold_depth -= 1, - TagEnd::Strikethrough => strikethrough_depth -= 1, - TagEnd::Link => { - link = None; - } - TagEnd::Image => { - if let Some(mut image) = image.take() { - if !text.is_empty() { - image.set_alt_text(std::mem::take(&mut text).into()); - mem::take(&mut highlights); - mem::take(&mut regions); - } - markdown_text_like.push(MarkdownParagraphChunk::Image(image)); - } - } - TagEnd::Paragraph => { - self.cursor += 1; - break; - } - _ => { - break; - } - }, - _ => { - break; - } - } - - self.cursor += 1; - } - if !text.is_empty() { - markdown_text_like.push(MarkdownParagraphChunk::Text(ParsedMarkdownText { - source_range, - contents: text.into(), - highlights, - regions, - })); - } - markdown_text_like - } - - fn parse_heading(&mut self, level: pulldown_cmark::HeadingLevel) -> ParsedMarkdownHeading { - let (_event, source_range) = self.previous().unwrap(); - let source_range = source_range.clone(); - let text = self.parse_text(true, None); - - // Advance past the heading end tag - self.cursor += 1; - - ParsedMarkdownHeading { - source_range, - level: match level { - pulldown_cmark::HeadingLevel::H1 => HeadingLevel::H1, - pulldown_cmark::HeadingLevel::H2 => HeadingLevel::H2, - pulldown_cmark::HeadingLevel::H3 => HeadingLevel::H3, - pulldown_cmark::HeadingLevel::H4 => HeadingLevel::H4, - pulldown_cmark::HeadingLevel::H5 => HeadingLevel::H5, - pulldown_cmark::HeadingLevel::H6 => HeadingLevel::H6, - }, - contents: text, - } - } - - fn parse_table(&mut self, alignment: Vec) -> ParsedMarkdownTable { - let (_event, source_range) = self.previous().unwrap(); - let source_range = source_range.clone(); - let mut header = vec![]; - let mut body = vec![]; - let mut row_columns = vec![]; - let mut in_header = true; - let column_alignments = alignment - .iter() - .map(Self::convert_alignment) - .collect::>(); - - loop { - if self.eof() { - break; - } - - let (current, source_range) = self.current().unwrap(); - let source_range = source_range.clone(); - match current { - Event::Start(Tag::TableHead) - | Event::Start(Tag::TableRow) - | Event::End(TagEnd::TableCell) => { - self.cursor += 1; - } - Event::Start(Tag::TableCell) => { - self.cursor += 1; - let cell_contents = self.parse_text(false, Some(source_range)); - row_columns.push(ParsedMarkdownTableColumn { - col_span: 1, - row_span: 1, - is_header: in_header, - children: cell_contents, - alignment: column_alignments - .get(row_columns.len()) - .copied() - .unwrap_or_default(), - }); - } - Event::End(TagEnd::TableHead) | Event::End(TagEnd::TableRow) => { - self.cursor += 1; - let columns = std::mem::take(&mut row_columns); - if in_header { - header.push(ParsedMarkdownTableRow { columns: columns }); - in_header = false; - } else { - body.push(ParsedMarkdownTableRow::with_columns(columns)); - } - } - Event::End(TagEnd::Table) => { - self.cursor += 1; - break; - } - _ => { - break; - } - } - } - - ParsedMarkdownTable { - source_range, - header, - body, - caption: None, - } - } - - fn convert_alignment(alignment: &Alignment) -> ParsedMarkdownTableAlignment { - match alignment { - Alignment::None => ParsedMarkdownTableAlignment::None, - Alignment::Left => ParsedMarkdownTableAlignment::Left, - Alignment::Center => ParsedMarkdownTableAlignment::Center, - Alignment::Right => ParsedMarkdownTableAlignment::Right, - } - } - - async fn parse_list(&mut self, order: Option) -> Vec { - let (_, list_source_range) = self.previous().unwrap(); - - let mut items = Vec::new(); - let mut items_stack = vec![MarkdownListItem::default()]; - let mut depth = 1; - let mut order = order; - let mut order_stack = Vec::new(); - - let mut insertion_indices = FxHashMap::default(); - let mut source_ranges = FxHashMap::default(); - let mut start_item_range = list_source_range.clone(); - - while !self.eof() { - let (current, source_range) = self.current().unwrap(); - match current { - Event::Start(Tag::List(new_order)) => { - if items_stack.last().is_some() && !insertion_indices.contains_key(&depth) { - insertion_indices.insert(depth, items.len()); - } - - // We will use the start of the nested list as the end for the current item's range, - // because we don't care about the hierarchy of list items - if let collections::hash_map::Entry::Vacant(e) = source_ranges.entry(depth) { - e.insert(start_item_range.start..source_range.start); - } - - order_stack.push(order); - order = *new_order; - self.cursor += 1; - depth += 1; - } - Event::End(TagEnd::List(_)) => { - order = order_stack.pop().flatten(); - self.cursor += 1; - depth -= 1; - - if depth == 0 { - break; - } - } - Event::Start(Tag::Item) => { - start_item_range = source_range.clone(); - - self.cursor += 1; - items_stack.push(MarkdownListItem::default()); - - let mut task_list = None; - // Check for task list marker (`- [ ]` or `- [x]`) - if let Some(event) = self.current_event() { - // If there is a linebreak in between two list items the task list marker will actually be the first element of the paragraph - if event == &Event::Start(Tag::Paragraph) { - self.cursor += 1; - } - - if let Some((Event::TaskListMarker(checked), range)) = self.current() { - task_list = Some((*checked, range.clone())); - self.cursor += 1; - } - } - - if let Some((event, range)) = self.current() { - // This is a plain list item. - // For example `- some text` or `1. [Docs](./docs.md)` - if MarkdownParser::is_text_like(event) { - let text = self.parse_text(false, Some(range.clone())); - let block = ParsedMarkdownElement::Paragraph(text); - if let Some(content) = items_stack.last_mut() { - let item_type = if let Some((checked, range)) = task_list { - ParsedMarkdownListItemType::Task(checked, range) - } else if let Some(order) = order { - ParsedMarkdownListItemType::Ordered(order) - } else { - ParsedMarkdownListItemType::Unordered - }; - content.item_type = item_type; - content.content.push(block); - } - } else { - let block = self.parse_block().await; - if let Some(block) = block - && let Some(list_item) = items_stack.last_mut() - { - list_item.content.extend(block); - } - } - } - - // If there is a linebreak in between two list items the task list marker will actually be the first element of the paragraph - if self.current_event() == Some(&Event::End(TagEnd::Paragraph)) { - self.cursor += 1; - } - } - Event::End(TagEnd::Item) => { - self.cursor += 1; - - if let Some(current) = order { - order = Some(current + 1); - } - - if let Some(list_item) = items_stack.pop() { - let source_range = source_ranges - .remove(&depth) - .unwrap_or(start_item_range.clone()); - - // We need to remove the last character of the source range, because it includes the newline character - let source_range = source_range.start..source_range.end - 1; - let item = ParsedMarkdownElement::ListItem(ParsedMarkdownListItem { - source_range, - content: list_item.content, - depth, - item_type: list_item.item_type, - nested: false, - }); - - if let Some(index) = insertion_indices.get(&depth) { - items.insert(*index, item); - insertion_indices.remove(&depth); - } else { - items.push(item); - } - } - } - _ => { - if depth == 0 { - break; - } - // This can only happen if a list item starts with more then one paragraph, - // or the list item contains blocks that should be rendered after the nested list items - let block = self.parse_block().await; - if let Some(block) = block { - if let Some(list_item) = items_stack.last_mut() { - // If we did not insert any nested items yet (in this case insertion index is set), we can append the block to the current list item - if !insertion_indices.contains_key(&depth) { - list_item.content.extend(block); - continue; - } - } - - // Otherwise we need to insert the block after all the nested items - // that have been parsed so far - items.extend(block); - } else { - self.cursor += 1; - } - } - } - } - - items - } - - #[async_recursion] - async fn parse_block_quote(&mut self) -> ParsedMarkdownBlockQuote { - let (_event, source_range) = self.previous().unwrap(); - let source_range = source_range.clone(); - let mut nested_depth = 1; - - let mut children: Vec = vec![]; - - while !self.eof() { - let block = self.parse_block().await; - - if let Some(block) = block { - children.extend(block); - } else { - break; - } - - if self.eof() { - break; - } - - let (current, _source_range) = self.current().unwrap(); - match current { - // This is a nested block quote. - // Record that we're in a nested block quote and continue parsing. - // We don't need to advance the cursor since the next - // call to `parse_block` will handle it. - Event::Start(Tag::BlockQuote(_kind)) => { - nested_depth += 1; - } - Event::End(TagEnd::BlockQuote(_kind)) => { - nested_depth -= 1; - if nested_depth == 0 { - self.cursor += 1; - break; - } - } - _ => {} - }; - } - - ParsedMarkdownBlockQuote { - source_range, - children, - } - } - - async fn parse_code_block( - &mut self, - language: Option, - ) -> Option { - let Some((_event, source_range)) = self.previous() else { - return None; - }; - - let source_range = source_range.clone(); - let mut code = String::new(); - - while !self.eof() { - let Some((current, _source_range)) = self.current() else { - break; - }; - - match current { - Event::Text(text) => { - code.push_str(text); - self.cursor += 1; - } - Event::End(TagEnd::CodeBlock) => { - self.cursor += 1; - break; - } - _ => { - break; - } - } - } - - code = code.strip_suffix('\n').unwrap_or(&code).to_string(); - - let highlights = if let Some(language) = &language { - if let Some(registry) = &self.language_registry { - let rope: language::Rope = code.as_str().into(); - registry - .language_for_name_or_extension(language) - .await - .map(|l| l.highlight_text(&rope, 0..code.len())) - .ok() - } else { - None - } - } else { - None - }; - - Some(ParsedMarkdownCodeBlock { - source_range, - contents: code.into(), - language, - highlights, - }) - } - - async fn parse_mermaid_diagram( - &mut self, - scale: Option, - ) -> Option { - let Some((_event, source_range)) = self.previous() else { - return None; - }; - - let source_range = source_range.clone(); - let mut code = String::new(); - - while !self.eof() { - let Some((current, _source_range)) = self.current() else { - break; - }; - - match current { - Event::Text(text) => { - code.push_str(text); - self.cursor += 1; - } - Event::End(TagEnd::CodeBlock) => { - self.cursor += 1; - break; - } - _ => { - break; - } - } - } - - code = code.strip_suffix('\n').unwrap_or(&code).to_string(); - - let scale = scale.unwrap_or(100).clamp(10, 500); - - Some(ParsedMarkdownMermaidDiagram { - source_range, - contents: ParsedMarkdownMermaidDiagramContents { - contents: code.into(), - scale, - }, - }) - } - - async fn parse_html_block(&mut self) -> Vec { - let mut elements = Vec::new(); - let Some((_event, _source_range)) = self.previous() else { - return elements; - }; - - let mut html_source_range_start = None; - let mut html_source_range_end = None; - let mut html_buffer = String::new(); - - while !self.eof() { - let Some((current, source_range)) = self.current() else { - break; - }; - let source_range = source_range.clone(); - match current { - Event::Html(html) => { - html_source_range_start.get_or_insert(source_range.start); - html_source_range_end = Some(source_range.end); - html_buffer.push_str(html); - self.cursor += 1; - } - Event::End(TagEnd::CodeBlock) => { - self.cursor += 1; - break; - } - _ => { - break; - } - } - } - - let bytes = cleanup_html(&html_buffer); - - let mut cursor = std::io::Cursor::new(bytes); - if let Ok(dom) = parse_document(RcDom::default(), ParseOpts::default()) - .from_utf8() - .read_from(&mut cursor) - && let Some((start, end)) = html_source_range_start.zip(html_source_range_end) - { - self.parse_html_node( - start..end, - &dom.document, - &mut elements, - &ParseHtmlNodeContext::default(), - ); - } - - elements - } - - #[stacksafe] - fn parse_html_node( - &self, - source_range: Range, - node: &Rc, - elements: &mut Vec, - context: &ParseHtmlNodeContext, - ) { - match &node.data { - markup5ever_rcdom::NodeData::Document => { - self.consume_children(source_range, node, elements, context); - } - markup5ever_rcdom::NodeData::Text { contents } => { - elements.push(ParsedMarkdownElement::Paragraph(vec![ - MarkdownParagraphChunk::Text(ParsedMarkdownText { - source_range, - regions: Vec::default(), - highlights: Vec::default(), - contents: contents.borrow().to_string().into(), - }), - ])); - } - markup5ever_rcdom::NodeData::Comment { .. } => {} - markup5ever_rcdom::NodeData::Element { name, attrs, .. } => { - let mut styles = if let Some(styles) = Self::markdown_style_from_html_styles( - Self::extract_styles_from_attributes(attrs), - ) { - vec![MarkdownHighlight::Style(styles)] - } else { - Vec::default() - }; - - if local_name!("img") == name.local { - if let Some(image) = self.extract_image(source_range, attrs) { - elements.push(ParsedMarkdownElement::Image(image)); - } - } else if local_name!("p") == name.local { - let mut paragraph = MarkdownParagraph::new(); - self.parse_paragraph( - source_range, - node, - &mut paragraph, - &mut styles, - &mut Vec::new(), - ); - - if !paragraph.is_empty() { - elements.push(ParsedMarkdownElement::Paragraph(paragraph)); - } - } else if matches!( - name.local, - local_name!("h1") - | local_name!("h2") - | local_name!("h3") - | local_name!("h4") - | local_name!("h5") - | local_name!("h6") - ) { - let mut paragraph = MarkdownParagraph::new(); - self.consume_paragraph( - source_range.clone(), - node, - &mut paragraph, - &mut styles, - &mut Vec::new(), - ); - - if !paragraph.is_empty() { - elements.push(ParsedMarkdownElement::Heading(ParsedMarkdownHeading { - source_range, - level: match name.local { - local_name!("h1") => HeadingLevel::H1, - local_name!("h2") => HeadingLevel::H2, - local_name!("h3") => HeadingLevel::H3, - local_name!("h4") => HeadingLevel::H4, - local_name!("h5") => HeadingLevel::H5, - local_name!("h6") => HeadingLevel::H6, - _ => unreachable!(), - }, - contents: paragraph, - })); - } - } else if local_name!("ul") == name.local || local_name!("ol") == name.local { - if let Some(list_items) = self.extract_html_list( - node, - local_name!("ol") == name.local, - context.list_item_depth, - source_range, - ) { - elements.extend(list_items); - } - } else if local_name!("blockquote") == name.local { - if let Some(blockquote) = self.extract_html_blockquote(node, source_range) { - elements.push(ParsedMarkdownElement::BlockQuote(blockquote)); - } - } else if local_name!("table") == name.local { - if let Some(table) = self.extract_html_table(node, source_range) { - elements.push(ParsedMarkdownElement::Table(table)); - } - } else { - self.consume_children(source_range, node, elements, context); - } - } - _ => {} - } - } - - #[stacksafe] - fn parse_paragraph( - &self, - source_range: Range, - node: &Rc, - paragraph: &mut MarkdownParagraph, - highlights: &mut Vec, - regions: &mut Vec<(Range, ParsedRegion)>, - ) { - fn items_with_range( - range: Range, - items: impl IntoIterator, - ) -> Vec<(Range, T)> { - items - .into_iter() - .map(|item| (range.clone(), item)) - .collect() - } - - match &node.data { - markup5ever_rcdom::NodeData::Text { contents } => { - // append the text to the last chunk, so we can have a hacky version - // of inline text with highlighting - if let Some(text) = paragraph.iter_mut().last().and_then(|p| match p { - MarkdownParagraphChunk::Text(text) => Some(text), - _ => None, - }) { - let mut new_text = text.contents.to_string(); - new_text.push_str(&contents.borrow()); - - text.highlights.extend(items_with_range( - text.contents.len()..new_text.len(), - std::mem::take(highlights), - )); - text.regions.extend(items_with_range( - text.contents.len()..new_text.len(), - std::mem::take(regions) - .into_iter() - .map(|(_, region)| region), - )); - text.contents = SharedString::from(new_text); - } else { - let contents = contents.borrow().to_string(); - paragraph.push(MarkdownParagraphChunk::Text(ParsedMarkdownText { - source_range, - highlights: items_with_range(0..contents.len(), std::mem::take(highlights)), - regions: items_with_range( - 0..contents.len(), - std::mem::take(regions) - .into_iter() - .map(|(_, region)| region), - ), - contents: contents.into(), - })); - } - } - markup5ever_rcdom::NodeData::Element { name, attrs, .. } => { - if local_name!("img") == name.local { - if let Some(image) = self.extract_image(source_range, attrs) { - paragraph.push(MarkdownParagraphChunk::Image(image)); - } - } else if local_name!("b") == name.local || local_name!("strong") == name.local { - highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle { - weight: FontWeight::BOLD, - ..Default::default() - })); - - self.consume_paragraph(source_range, node, paragraph, highlights, regions); - } else if local_name!("i") == name.local { - highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle { - italic: true, - ..Default::default() - })); - - self.consume_paragraph(source_range, node, paragraph, highlights, regions); - } else if local_name!("em") == name.local { - highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle { - oblique: true, - ..Default::default() - })); - - self.consume_paragraph(source_range, node, paragraph, highlights, regions); - } else if local_name!("del") == name.local { - highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle { - strikethrough: true, - ..Default::default() - })); - - self.consume_paragraph(source_range, node, paragraph, highlights, regions); - } else if local_name!("ins") == name.local { - highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle { - underline: true, - ..Default::default() - })); - - self.consume_paragraph(source_range, node, paragraph, highlights, regions); - } else if local_name!("a") == name.local { - if let Some(url) = Self::attr_value(attrs, local_name!("href")) - && let Some(link) = - Link::identify(self.file_location_directory.clone(), url) - { - highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle { - link: true, - ..Default::default() - })); - - regions.push(( - source_range.clone(), - ParsedRegion { - code: false, - link: Some(link), - }, - )); - } - - self.consume_paragraph(source_range, node, paragraph, highlights, regions); - } else { - self.consume_paragraph(source_range, node, paragraph, highlights, regions); - } - } - _ => {} - } - } - - fn consume_paragraph( - &self, - source_range: Range, - node: &Rc, - paragraph: &mut MarkdownParagraph, - highlights: &mut Vec, - regions: &mut Vec<(Range, ParsedRegion)>, - ) { - for node in node.children.borrow().iter() { - self.parse_paragraph(source_range.clone(), node, paragraph, highlights, regions); - } - } - - fn parse_table_row( - &self, - source_range: Range, - node: &Rc, - ) -> Option { - let mut columns = Vec::new(); - - match &node.data { - markup5ever_rcdom::NodeData::Element { name, .. } => { - if local_name!("tr") != name.local { - return None; - } - - for node in node.children.borrow().iter() { - if let Some(column) = self.parse_table_column(source_range.clone(), node) { - columns.push(column); - } - } - } - _ => {} - } - - if columns.is_empty() { - None - } else { - Some(ParsedMarkdownTableRow { columns }) - } - } - - fn parse_table_column( - &self, - source_range: Range, - node: &Rc, - ) -> Option { - match &node.data { - markup5ever_rcdom::NodeData::Element { name, attrs, .. } => { - if !matches!(name.local, local_name!("th") | local_name!("td")) { - return None; - } - - let mut children = MarkdownParagraph::new(); - self.consume_paragraph( - source_range, - node, - &mut children, - &mut Vec::new(), - &mut Vec::new(), - ); - - let is_header = matches!(name.local, local_name!("th")); - - Some(ParsedMarkdownTableColumn { - col_span: std::cmp::max( - Self::attr_value(attrs, local_name!("colspan")) - .and_then(|span| span.parse().ok()) - .unwrap_or(1), - 1, - ), - row_span: std::cmp::max( - Self::attr_value(attrs, local_name!("rowspan")) - .and_then(|span| span.parse().ok()) - .unwrap_or(1), - 1, - ), - is_header, - children, - alignment: Self::attr_value(attrs, local_name!("align")) - .and_then(|align| match align.as_str() { - "left" => Some(ParsedMarkdownTableAlignment::Left), - "center" => Some(ParsedMarkdownTableAlignment::Center), - "right" => Some(ParsedMarkdownTableAlignment::Right), - _ => None, - }) - .unwrap_or_else(|| { - if is_header { - ParsedMarkdownTableAlignment::Center - } else { - ParsedMarkdownTableAlignment::default() - } - }), - }) - } - _ => None, - } - } - - fn consume_children( - &self, - source_range: Range, - node: &Rc, - elements: &mut Vec, - context: &ParseHtmlNodeContext, - ) { - for node in node.children.borrow().iter() { - self.parse_html_node(source_range.clone(), node, elements, context); - } - } - - fn attr_value( - attrs: &RefCell>, - name: html5ever::LocalName, - ) -> Option { - attrs.borrow().iter().find_map(|attr| { - if attr.name.local == name { - Some(attr.value.to_string()) - } else { - None - } - }) - } - - fn markdown_style_from_html_styles( - styles: HashMap, - ) -> Option { - let mut markdown_style = MarkdownHighlightStyle::default(); - - if let Some(text_decoration) = styles.get("text-decoration") { - match text_decoration.to_lowercase().as_str() { - "underline" => { - markdown_style.underline = true; - } - "line-through" => { - markdown_style.strikethrough = true; - } - _ => {} - } - } - - if let Some(font_style) = styles.get("font-style") { - match font_style.to_lowercase().as_str() { - "italic" => { - markdown_style.italic = true; - } - "oblique" => { - markdown_style.oblique = true; - } - _ => {} - } - } - - if let Some(font_weight) = styles.get("font-weight") { - match font_weight.to_lowercase().as_str() { - "bold" => { - markdown_style.weight = FontWeight::BOLD; - } - "lighter" => { - markdown_style.weight = FontWeight::THIN; - } - _ => { - if let Some(weight) = font_weight.parse::().ok() { - markdown_style.weight = FontWeight(weight); - } - } - } - } - - if markdown_style != MarkdownHighlightStyle::default() { - Some(markdown_style) - } else { - None - } - } - - fn extract_styles_from_attributes( - attrs: &RefCell>, - ) -> HashMap { - let mut styles = HashMap::new(); - - if let Some(style) = Self::attr_value(attrs, local_name!("style")) { - for decl in style.split(';') { - let mut parts = decl.splitn(2, ':'); - if let Some((key, value)) = parts.next().zip(parts.next()) { - styles.insert( - key.trim().to_lowercase().to_string(), - value.trim().to_string(), - ); - } - } - } - - styles - } - - fn extract_image( - &self, - source_range: Range, - attrs: &RefCell>, - ) -> Option { - let src = Self::attr_value(attrs, local_name!("src"))?; - - let mut image = Image::identify(src, source_range, self.file_location_directory.clone())?; - - if let Some(alt) = Self::attr_value(attrs, local_name!("alt")) { - image.set_alt_text(alt.into()); - } - - let styles = Self::extract_styles_from_attributes(attrs); - - if let Some(width) = Self::attr_value(attrs, local_name!("width")) - .or_else(|| styles.get("width").cloned()) - .and_then(|width| Self::parse_html_element_dimension(&width)) - { - image.set_width(width); - } - - if let Some(height) = Self::attr_value(attrs, local_name!("height")) - .or_else(|| styles.get("height").cloned()) - .and_then(|height| Self::parse_html_element_dimension(&height)) - { - image.set_height(height); - } - - Some(image) - } - - fn extract_html_list( - &self, - node: &Rc, - ordered: bool, - depth: u16, - source_range: Range, - ) -> Option> { - let mut list_items = Vec::with_capacity(node.children.borrow().len()); - - for (index, node) in node.children.borrow().iter().enumerate() { - match &node.data { - markup5ever_rcdom::NodeData::Element { name, .. } => { - if local_name!("li") != name.local { - continue; - } - - let mut content = Vec::new(); - self.consume_children( - source_range.clone(), - node, - &mut content, - &ParseHtmlNodeContext { - list_item_depth: depth + 1, - }, - ); - - if !content.is_empty() { - list_items.push(ParsedMarkdownElement::ListItem(ParsedMarkdownListItem { - depth, - source_range: source_range.clone(), - item_type: if ordered { - ParsedMarkdownListItemType::Ordered(index as u64 + 1) - } else { - ParsedMarkdownListItemType::Unordered - }, - content, - nested: true, - })); - } - } - _ => {} - } - } - - if list_items.is_empty() { - None - } else { - Some(list_items) - } - } - - fn parse_html_element_dimension(value: &str) -> Option { - if value.ends_with("%") { - value - .trim_end_matches("%") - .parse::() - .ok() - .map(|value| relative(value / 100.)) - } else { - value - .trim_end_matches("px") - .parse() - .ok() - .map(|value| px(value).into()) - } - } - - fn extract_html_blockquote( - &self, - node: &Rc, - source_range: Range, - ) -> Option { - let mut children = Vec::new(); - self.consume_children( - source_range.clone(), - node, - &mut children, - &ParseHtmlNodeContext::default(), - ); - - if children.is_empty() { - None - } else { - Some(ParsedMarkdownBlockQuote { - children, - source_range, - }) - } - } - - fn extract_html_table( - &self, - node: &Rc, - source_range: Range, - ) -> Option { - let mut header_rows = Vec::new(); - let mut body_rows = Vec::new(); - let mut caption = None; - - // node should be a thead, tbody or caption element - for node in node.children.borrow().iter() { - match &node.data { - markup5ever_rcdom::NodeData::Element { name, .. } => { - if local_name!("caption") == name.local { - let mut paragraph = MarkdownParagraph::new(); - self.parse_paragraph( - source_range.clone(), - node, - &mut paragraph, - &mut Vec::new(), - &mut Vec::new(), - ); - caption = Some(paragraph); - } - if local_name!("thead") == name.local { - // node should be a tr element - for node in node.children.borrow().iter() { - if let Some(row) = self.parse_table_row(source_range.clone(), node) { - header_rows.push(row); - } - } - } else if local_name!("tbody") == name.local { - // node should be a tr element - for node in node.children.borrow().iter() { - if let Some(row) = self.parse_table_row(source_range.clone(), node) { - body_rows.push(row); - } - } - } - } - _ => {} - } - } - - if !header_rows.is_empty() || !body_rows.is_empty() { - Some(ParsedMarkdownTable { - source_range, - body: body_rows, - header: header_rows, - caption, - }) - } else { - None - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use ParsedMarkdownListItemType::*; - use core::panic; - use gpui::{AbsoluteLength, BackgroundExecutor, DefiniteLength}; - use language::{HighlightId, LanguageRegistry}; - use pretty_assertions::assert_eq; - - async fn parse(input: &str) -> ParsedMarkdown { - parse_markdown(input, None, None).await - } - - #[gpui::test] - async fn test_headings() { - let parsed = parse("# Heading one\n## Heading two\n### Heading three").await; - - assert_eq!( - parsed.children, - vec![ - h1(text("Heading one", 2..13), 0..14), - h2(text("Heading two", 17..28), 14..29), - h3(text("Heading three", 33..46), 29..46), - ] - ); - } - - #[gpui::test] - async fn test_newlines_dont_new_paragraphs() { - let parsed = parse("Some text **that is bolded**\n and *italicized*").await; - - assert_eq!( - parsed.children, - vec![p("Some text that is bolded and italicized", 0..46)] - ); - } - - #[gpui::test] - async fn test_heading_with_paragraph() { - let parsed = parse("# Zed\nThe editor").await; - - assert_eq!( - parsed.children, - vec![h1(text("Zed", 2..5), 0..6), p("The editor", 6..16),] - ); - } - - #[gpui::test] - async fn test_double_newlines_do_new_paragraphs() { - let parsed = parse("Some text **that is bolded**\n\n and *italicized*").await; - - assert_eq!( - parsed.children, - vec![ - p("Some text that is bolded", 0..29), - p("and italicized", 31..47), - ] - ); - } - - #[gpui::test] - async fn test_bold_italic_text() { - let parsed = parse("Some text **that is bolded** and *italicized*").await; - - assert_eq!( - parsed.children, - vec![p("Some text that is bolded and italicized", 0..45)] - ); - } - - #[gpui::test] - async fn test_nested_bold_strikethrough_text() { - let parsed = parse("Some **bo~~strikethrough~~ld** text").await; - - assert_eq!(parsed.children.len(), 1); - assert_eq!( - parsed.children[0], - ParsedMarkdownElement::Paragraph(vec![MarkdownParagraphChunk::Text( - ParsedMarkdownText { - source_range: 0..35, - contents: "Some bostrikethroughld text".into(), - highlights: Vec::new(), - regions: Vec::new(), - } - )]) - ); - - let new_text = if let ParsedMarkdownElement::Paragraph(text) = &parsed.children[0] { - text - } else { - panic!("Expected a paragraph"); - }; - - let paragraph = if let MarkdownParagraphChunk::Text(text) = &new_text[0] { - text - } else { - panic!("Expected a text"); - }; - - assert_eq!( - paragraph.highlights, - vec![ - ( - 5..7, - MarkdownHighlight::Style(MarkdownHighlightStyle { - weight: FontWeight::BOLD, - ..Default::default() - }), - ), - ( - 7..20, - MarkdownHighlight::Style(MarkdownHighlightStyle { - weight: FontWeight::BOLD, - strikethrough: true, - ..Default::default() - }), - ), - ( - 20..22, - MarkdownHighlight::Style(MarkdownHighlightStyle { - weight: FontWeight::BOLD, - ..Default::default() - }), - ), - ] - ); - } - - #[gpui::test] - async fn test_html_inline_style_elements() { - let parsed = - parse("

Some text strong text more text bold text more text italic text more text emphasized text more text deleted text more text inserted text

").await; - - assert_eq!(1, parsed.children.len()); - let chunks = if let ParsedMarkdownElement::Paragraph(chunks) = &parsed.children[0] { - chunks - } else { - panic!("Expected a paragraph"); - }; - - assert_eq!(1, chunks.len()); - let text = if let MarkdownParagraphChunk::Text(text) = &chunks[0] { - text - } else { - panic!("Expected a paragraph"); - }; - - assert_eq!(0..205, text.source_range); - assert_eq!( - "Some text strong text more text bold text more text italic text more text emphasized text more text deleted text more text inserted text", - text.contents.as_str(), - ); - assert_eq!( - vec![ - ( - 10..21, - MarkdownHighlight::Style(MarkdownHighlightStyle { - weight: FontWeight(700.0), - ..Default::default() - },), - ), - ( - 32..41, - MarkdownHighlight::Style(MarkdownHighlightStyle { - weight: FontWeight(700.0), - ..Default::default() - },), - ), - ( - 52..63, - MarkdownHighlight::Style(MarkdownHighlightStyle { - italic: true, - weight: FontWeight(400.0), - ..Default::default() - },), - ), - ( - 74..89, - MarkdownHighlight::Style(MarkdownHighlightStyle { - weight: FontWeight(400.0), - oblique: true, - ..Default::default() - },), - ), - ( - 100..112, - MarkdownHighlight::Style(MarkdownHighlightStyle { - strikethrough: true, - weight: FontWeight(400.0), - ..Default::default() - },), - ), - ( - 123..136, - MarkdownHighlight::Style(MarkdownHighlightStyle { - underline: true, - weight: FontWeight(400.0,), - ..Default::default() - },), - ), - ], - text.highlights - ); - } - - #[gpui::test] - async fn test_html_href_element() { - let parsed = - parse("

Some text link more text

").await; - - assert_eq!(1, parsed.children.len()); - let chunks = if let ParsedMarkdownElement::Paragraph(chunks) = &parsed.children[0] { - chunks - } else { - panic!("Expected a paragraph"); - }; - - assert_eq!(1, chunks.len()); - let text = if let MarkdownParagraphChunk::Text(text) = &chunks[0] { - text - } else { - panic!("Expected a paragraph"); - }; - - assert_eq!(0..65, text.source_range); - assert_eq!("Some text link more text", text.contents.as_str(),); - assert_eq!( - vec![( - 10..14, - MarkdownHighlight::Style(MarkdownHighlightStyle { - link: true, - ..Default::default() - },), - )], - text.highlights - ); - assert_eq!( - vec![( - 10..14, - ParsedRegion { - code: false, - link: Some(Link::Web { - url: "https://example.com".into() - }) - } - )], - text.regions - ) - } - - #[gpui::test] - async fn test_text_with_inline_html() { - let parsed = parse("This is a paragraph with an inline HTML tag.").await; - - assert_eq!( - parsed.children, - vec![p("This is a paragraph with an inline HTML tag.", 0..63),], - ); - } - - #[gpui::test] - async fn test_raw_links_detection() { - let parsed = parse("Checkout this https://zed.dev link").await; - - assert_eq!( - parsed.children, - vec![p("Checkout this https://zed.dev link", 0..34)] - ); - } - - #[gpui::test] - async fn test_empty_image() { - let parsed = parse("![]()").await; - - let paragraph = if let ParsedMarkdownElement::Paragraph(text) = &parsed.children[0] { - text - } else { - panic!("Expected a paragraph"); - }; - assert_eq!(paragraph.len(), 0); - } - - #[gpui::test] - async fn test_image_links_detection() { - let parsed = parse("![test](https://blog.logrocket.com/wp-content/uploads/2024/04/exploring-zed-open-source-code-editor-rust-2.png)").await; - - let paragraph = if let ParsedMarkdownElement::Paragraph(text) = &parsed.children[0] { - text - } else { - panic!("Expected a paragraph"); - }; - assert_eq!( - paragraph[0], - MarkdownParagraphChunk::Image(Image { - source_range: 0..111, - link: Link::Web { - url: "https://blog.logrocket.com/wp-content/uploads/2024/04/exploring-zed-open-source-code-editor-rust-2.png".to_string(), - }, - alt_text: Some("test".into()), - height: None, - width: None, - },) - ); - } - - #[gpui::test] - async fn test_image_alt_text() { - let parsed = parse("[![Zed](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/zed-industries/zed/main/assets/badge/v0.json)](https://zed.dev)\n ").await; - - let paragraph = if let ParsedMarkdownElement::Paragraph(text) = &parsed.children[0] { - text - } else { - panic!("Expected a paragraph"); - }; - assert_eq!( - paragraph[0], - MarkdownParagraphChunk::Image(Image { - source_range: 0..142, - link: Link::Web { - url: "https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/zed-industries/zed/main/assets/badge/v0.json".to_string(), - }, - alt_text: Some("Zed".into()), - height: None, - width: None, - },) - ); - } - - #[gpui::test] - async fn test_image_without_alt_text() { - let parsed = parse("![](http://example.com/foo.png)").await; - - let paragraph = if let ParsedMarkdownElement::Paragraph(text) = &parsed.children[0] { - text - } else { - panic!("Expected a paragraph"); - }; - assert_eq!( - paragraph[0], - MarkdownParagraphChunk::Image(Image { - source_range: 0..31, - link: Link::Web { - url: "http://example.com/foo.png".to_string(), - }, - alt_text: None, - height: None, - width: None, - },) - ); - } - - #[gpui::test] - async fn test_image_with_alt_text_containing_formatting() { - let parsed = parse("![foo *bar* baz](http://example.com/foo.png)").await; - - let ParsedMarkdownElement::Paragraph(chunks) = &parsed.children[0] else { - panic!("Expected a paragraph"); - }; - assert_eq!( - chunks, - &[MarkdownParagraphChunk::Image(Image { - source_range: 0..44, - link: Link::Web { - url: "http://example.com/foo.png".to_string(), - }, - alt_text: Some("foo bar baz".into()), - height: None, - width: None, - }),], - ); - } - - #[gpui::test] - async fn test_images_with_text_in_between() { - let parsed = parse( - "![foo](http://example.com/foo.png)\nLorem Ipsum\n![bar](http://example.com/bar.png)", - ) - .await; - - let chunks = if let ParsedMarkdownElement::Paragraph(text) = &parsed.children[0] { - text - } else { - panic!("Expected a paragraph"); - }; - assert_eq!( - chunks, - &vec![ - MarkdownParagraphChunk::Image(Image { - source_range: 0..81, - link: Link::Web { - url: "http://example.com/foo.png".to_string(), - }, - alt_text: Some("foo".into()), - height: None, - width: None, - }), - MarkdownParagraphChunk::Text(ParsedMarkdownText { - source_range: 0..81, - contents: " Lorem Ipsum ".into(), - highlights: Vec::new(), - regions: Vec::new(), - }), - MarkdownParagraphChunk::Image(Image { - source_range: 0..81, - link: Link::Web { - url: "http://example.com/bar.png".to_string(), - }, - alt_text: Some("bar".into()), - height: None, - width: None, - }) - ] - ); - } - - #[test] - fn test_parse_html_element_dimension() { - // Test percentage values - assert_eq!( - MarkdownParser::parse_html_element_dimension("50%"), - Some(DefiniteLength::Fraction(0.5)) - ); - assert_eq!( - MarkdownParser::parse_html_element_dimension("100%"), - Some(DefiniteLength::Fraction(1.0)) - ); - assert_eq!( - MarkdownParser::parse_html_element_dimension("25%"), - Some(DefiniteLength::Fraction(0.25)) - ); - assert_eq!( - MarkdownParser::parse_html_element_dimension("0%"), - Some(DefiniteLength::Fraction(0.0)) - ); - - // Test pixel values - assert_eq!( - MarkdownParser::parse_html_element_dimension("100px"), - Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.0)))) - ); - assert_eq!( - MarkdownParser::parse_html_element_dimension("50px"), - Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(50.0)))) - ); - assert_eq!( - MarkdownParser::parse_html_element_dimension("0px"), - Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(0.0)))) - ); - - // Test values without units (should be treated as pixels) - assert_eq!( - MarkdownParser::parse_html_element_dimension("100"), - Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.0)))) - ); - assert_eq!( - MarkdownParser::parse_html_element_dimension("42"), - Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(42.0)))) - ); - - // Test invalid values - assert_eq!( - MarkdownParser::parse_html_element_dimension("invalid"), - None - ); - assert_eq!(MarkdownParser::parse_html_element_dimension("px"), None); - assert_eq!(MarkdownParser::parse_html_element_dimension("%"), None); - assert_eq!(MarkdownParser::parse_html_element_dimension(""), None); - assert_eq!(MarkdownParser::parse_html_element_dimension("abc%"), None); - assert_eq!(MarkdownParser::parse_html_element_dimension("abcpx"), None); - - // Test decimal values - assert_eq!( - MarkdownParser::parse_html_element_dimension("50.5%"), - Some(DefiniteLength::Fraction(0.505)) - ); - assert_eq!( - MarkdownParser::parse_html_element_dimension("100.25px"), - Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.25)))) - ); - assert_eq!( - MarkdownParser::parse_html_element_dimension("42.0"), - Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(42.0)))) - ); - } - - #[gpui::test] - async fn test_html_unordered_list() { - let parsed = parse( - "
    -
  • Item 1
  • -
  • Item 2
  • -
", - ) - .await; - - assert_eq!( - ParsedMarkdown { - children: vec![ - nested_list_item( - 0..82, - 1, - ParsedMarkdownListItemType::Unordered, - vec![ParsedMarkdownElement::Paragraph(text("Item 1", 0..82))] - ), - nested_list_item( - 0..82, - 1, - ParsedMarkdownListItemType::Unordered, - vec![ParsedMarkdownElement::Paragraph(text("Item 2", 0..82))] - ), - ] - }, - parsed - ); - } - - #[gpui::test] - async fn test_html_ordered_list() { - let parsed = parse( - "
    -
  1. Item 1
  2. -
  3. Item 2
  4. -
", - ) - .await; - - assert_eq!( - ParsedMarkdown { - children: vec![ - nested_list_item( - 0..82, - 1, - ParsedMarkdownListItemType::Ordered(1), - vec![ParsedMarkdownElement::Paragraph(text("Item 1", 0..82))] - ), - nested_list_item( - 0..82, - 1, - ParsedMarkdownListItemType::Ordered(2), - vec![ParsedMarkdownElement::Paragraph(text("Item 2", 0..82))] - ), - ] - }, - parsed - ); - } - - #[gpui::test] - async fn test_html_nested_ordered_list() { - let parsed = parse( - "
    -
  1. Item 1
  2. -
  3. Item 2 -
      -
    1. Sub-Item 1
    2. -
    3. Sub-Item 2
    4. -
    -
  4. -
", - ) - .await; - - assert_eq!( - ParsedMarkdown { - children: vec![ - nested_list_item( - 0..216, - 1, - ParsedMarkdownListItemType::Ordered(1), - vec![ParsedMarkdownElement::Paragraph(text("Item 1", 0..216))] - ), - nested_list_item( - 0..216, - 1, - ParsedMarkdownListItemType::Ordered(2), - vec![ - ParsedMarkdownElement::Paragraph(text("Item 2", 0..216)), - nested_list_item( - 0..216, - 2, - ParsedMarkdownListItemType::Ordered(1), - vec![ParsedMarkdownElement::Paragraph(text("Sub-Item 1", 0..216))] - ), - nested_list_item( - 0..216, - 2, - ParsedMarkdownListItemType::Ordered(2), - vec![ParsedMarkdownElement::Paragraph(text("Sub-Item 2", 0..216))] - ), - ] - ), - ] - }, - parsed - ); - } - - #[gpui::test] - async fn test_html_nested_unordered_list() { - let parsed = parse( - "
    -
  • Item 1
  • -
  • Item 2 -
      -
    • Sub-Item 1
    • -
    • Sub-Item 2
    • -
    -
  • -
", - ) - .await; - - assert_eq!( - ParsedMarkdown { - children: vec![ - nested_list_item( - 0..216, - 1, - ParsedMarkdownListItemType::Unordered, - vec![ParsedMarkdownElement::Paragraph(text("Item 1", 0..216))] - ), - nested_list_item( - 0..216, - 1, - ParsedMarkdownListItemType::Unordered, - vec![ - ParsedMarkdownElement::Paragraph(text("Item 2", 0..216)), - nested_list_item( - 0..216, - 2, - ParsedMarkdownListItemType::Unordered, - vec![ParsedMarkdownElement::Paragraph(text("Sub-Item 1", 0..216))] - ), - nested_list_item( - 0..216, - 2, - ParsedMarkdownListItemType::Unordered, - vec![ParsedMarkdownElement::Paragraph(text("Sub-Item 2", 0..216))] - ), - ] - ), - ] - }, - parsed - ); - } - - #[gpui::test] - async fn test_inline_html_image_tag() { - let parsed = - parse("

Some text some more text

") - .await; - - assert_eq!( - ParsedMarkdown { - children: vec![ParsedMarkdownElement::Paragraph(vec![ - MarkdownParagraphChunk::Text(ParsedMarkdownText { - source_range: 0..71, - contents: "Some text".into(), - highlights: Default::default(), - regions: Default::default() - }), - MarkdownParagraphChunk::Image(Image { - source_range: 0..71, - link: Link::Web { - url: "http://example.com/foo.png".to_string(), - }, - alt_text: None, - height: None, - width: None, - }), - MarkdownParagraphChunk::Text(ParsedMarkdownText { - source_range: 0..71, - contents: " some more text".into(), - highlights: Default::default(), - regions: Default::default() - }), - ])] - }, - parsed - ); - } - - #[gpui::test] - async fn test_html_block_quote() { - let parsed = parse( - "
-

some description

-
", - ) - .await; - - assert_eq!( - ParsedMarkdown { - children: vec![block_quote( - vec![ParsedMarkdownElement::Paragraph(text( - "some description", - 0..78 - ))], - 0..78, - )] - }, - parsed - ); - } - - #[gpui::test] - async fn test_html_nested_block_quote() { - let parsed = parse( - "
-

some description

-
-

second description

-
-
", - ) - .await; - - assert_eq!( - ParsedMarkdown { - children: vec![block_quote( - vec![ - ParsedMarkdownElement::Paragraph(text("some description", 0..179)), - block_quote( - vec![ParsedMarkdownElement::Paragraph(text( - "second description", - 0..179 - ))], - 0..179, - ) - ], - 0..179, - )] - }, - parsed - ); - } - - #[gpui::test] - async fn test_html_table() { - let parsed = parse( - " - - - - - - - - - - - - - - - - -
IdName
1Chris
2Dennis
", - ) - .await; - - assert_eq!( - ParsedMarkdown { - children: vec![ParsedMarkdownElement::Table(table( - 0..366, - None, - vec![row(vec![ - column( - 1, - 1, - true, - text("Id", 0..366), - ParsedMarkdownTableAlignment::Center - ), - column( - 1, - 1, - true, - text("Name ", 0..366), - ParsedMarkdownTableAlignment::Center - ) - ])], - vec![ - row(vec![ - column( - 1, - 1, - false, - text("1", 0..366), - ParsedMarkdownTableAlignment::None - ), - column( - 1, - 1, - false, - text("Chris", 0..366), - ParsedMarkdownTableAlignment::None - ) - ]), - row(vec![ - column( - 1, - 1, - false, - text("2", 0..366), - ParsedMarkdownTableAlignment::None - ), - column( - 1, - 1, - false, - text("Dennis", 0..366), - ParsedMarkdownTableAlignment::None - ) - ]), - ], - ))], - }, - parsed - ); - } - - #[gpui::test] - async fn test_html_table_with_caption() { - let parsed = parse( - " - - - - - - - - - - - -
My Table
1Chris
2Dennis
", - ) - .await; - - assert_eq!( - ParsedMarkdown { - children: vec![ParsedMarkdownElement::Table(table( - 0..280, - Some(vec![MarkdownParagraphChunk::Text(ParsedMarkdownText { - source_range: 0..280, - contents: "My Table".into(), - highlights: Default::default(), - regions: Default::default() - })]), - vec![], - vec![ - row(vec![ - column( - 1, - 1, - false, - text("1", 0..280), - ParsedMarkdownTableAlignment::None - ), - column( - 1, - 1, - false, - text("Chris", 0..280), - ParsedMarkdownTableAlignment::None - ) - ]), - row(vec![ - column( - 1, - 1, - false, - text("2", 0..280), - ParsedMarkdownTableAlignment::None - ), - column( - 1, - 1, - false, - text("Dennis", 0..280), - ParsedMarkdownTableAlignment::None - ) - ]), - ], - ))], - }, - parsed - ); - } - - #[gpui::test] - async fn test_html_table_without_headings() { - let parsed = parse( - " - - - - - - - - - - -
1Chris
2Dennis
", - ) - .await; - - assert_eq!( - ParsedMarkdown { - children: vec![ParsedMarkdownElement::Table(table( - 0..240, - None, - vec![], - vec![ - row(vec![ - column( - 1, - 1, - false, - text("1", 0..240), - ParsedMarkdownTableAlignment::None - ), - column( - 1, - 1, - false, - text("Chris", 0..240), - ParsedMarkdownTableAlignment::None - ) - ]), - row(vec![ - column( - 1, - 1, - false, - text("2", 0..240), - ParsedMarkdownTableAlignment::None - ), - column( - 1, - 1, - false, - text("Dennis", 0..240), - ParsedMarkdownTableAlignment::None - ) - ]), - ], - ))], - }, - parsed - ); - } - - #[gpui::test] - async fn test_html_table_without_body() { - let parsed = parse( - " - - - - - - -
IdName
", - ) - .await; - - assert_eq!( - ParsedMarkdown { - children: vec![ParsedMarkdownElement::Table(table( - 0..150, - None, - vec![row(vec![ - column( - 1, - 1, - true, - text("Id", 0..150), - ParsedMarkdownTableAlignment::Center - ), - column( - 1, - 1, - true, - text("Name", 0..150), - ParsedMarkdownTableAlignment::Center - ) - ])], - vec![], - ))], - }, - parsed - ); - } - - #[gpui::test] - async fn test_html_heading_tags() { - let parsed = parse("

Heading

Heading

Heading

Heading

Heading
Heading
").await; - - assert_eq!( - ParsedMarkdown { - children: vec![ - ParsedMarkdownElement::Heading(ParsedMarkdownHeading { - level: HeadingLevel::H1, - source_range: 0..96, - contents: vec![MarkdownParagraphChunk::Text(ParsedMarkdownText { - source_range: 0..96, - contents: "Heading".into(), - highlights: Vec::default(), - regions: Vec::default() - })], - }), - ParsedMarkdownElement::Heading(ParsedMarkdownHeading { - level: HeadingLevel::H2, - source_range: 0..96, - contents: vec![MarkdownParagraphChunk::Text(ParsedMarkdownText { - source_range: 0..96, - contents: "Heading".into(), - highlights: Vec::default(), - regions: Vec::default() - })], - }), - ParsedMarkdownElement::Heading(ParsedMarkdownHeading { - level: HeadingLevel::H3, - source_range: 0..96, - contents: vec![MarkdownParagraphChunk::Text(ParsedMarkdownText { - source_range: 0..96, - contents: "Heading".into(), - highlights: Vec::default(), - regions: Vec::default() - })], - }), - ParsedMarkdownElement::Heading(ParsedMarkdownHeading { - level: HeadingLevel::H4, - source_range: 0..96, - contents: vec![MarkdownParagraphChunk::Text(ParsedMarkdownText { - source_range: 0..96, - contents: "Heading".into(), - highlights: Vec::default(), - regions: Vec::default() - })], - }), - ParsedMarkdownElement::Heading(ParsedMarkdownHeading { - level: HeadingLevel::H5, - source_range: 0..96, - contents: vec![MarkdownParagraphChunk::Text(ParsedMarkdownText { - source_range: 0..96, - contents: "Heading".into(), - highlights: Vec::default(), - regions: Vec::default() - })], - }), - ParsedMarkdownElement::Heading(ParsedMarkdownHeading { - level: HeadingLevel::H6, - source_range: 0..96, - contents: vec![MarkdownParagraphChunk::Text(ParsedMarkdownText { - source_range: 0..96, - contents: "Heading".into(), - highlights: Vec::default(), - regions: Vec::default() - })], - }), - ], - }, - parsed - ); - } - - #[gpui::test] - async fn test_html_image_tag() { - let parsed = parse("").await; - - assert_eq!( - ParsedMarkdown { - children: vec![ParsedMarkdownElement::Image(Image { - source_range: 0..40, - link: Link::Web { - url: "http://example.com/foo.png".to_string(), - }, - alt_text: None, - height: None, - width: None, - })] - }, - parsed - ); - } - - #[gpui::test] - async fn test_html_image_tag_with_alt_text() { - let parsed = parse("\"Foo\"").await; - - assert_eq!( - ParsedMarkdown { - children: vec![ParsedMarkdownElement::Image(Image { - source_range: 0..50, - link: Link::Web { - url: "http://example.com/foo.png".to_string(), - }, - alt_text: Some("Foo".into()), - height: None, - width: None, - })] - }, - parsed - ); - } - - #[gpui::test] - async fn test_html_image_tag_with_height_and_width() { - let parsed = - parse("").await; - - assert_eq!( - ParsedMarkdown { - children: vec![ParsedMarkdownElement::Image(Image { - source_range: 0..65, - link: Link::Web { - url: "http://example.com/foo.png".to_string(), - }, - alt_text: None, - height: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.)))), - width: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(200.)))), - })] - }, - parsed - ); - } - - #[gpui::test] - async fn test_html_image_style_tag_with_height_and_width() { - let parsed = parse( - "", - ) - .await; - - assert_eq!( - ParsedMarkdown { - children: vec![ParsedMarkdownElement::Image(Image { - source_range: 0..75, - link: Link::Web { - url: "http://example.com/foo.png".to_string(), - }, - alt_text: None, - height: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.)))), - width: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(200.)))), - })] - }, - parsed - ); - } - - #[gpui::test] - async fn test_header_only_table() { - let markdown = "\ -| Header 1 | Header 2 | -|----------|----------| - -Some other content -"; - - let expected_table = table( - 0..48, - None, - vec![row(vec![ - column( - 1, - 1, - true, - text("Header 1", 1..11), - ParsedMarkdownTableAlignment::None, - ), - column( - 1, - 1, - true, - text("Header 2", 12..22), - ParsedMarkdownTableAlignment::None, - ), - ])], - vec![], - ); - - assert_eq!( - parse(markdown).await.children[0], - ParsedMarkdownElement::Table(expected_table) - ); - } - - #[gpui::test] - async fn test_basic_table() { - let markdown = "\ -| Header 1 | Header 2 | -|----------|----------| -| Cell 1 | Cell 2 | -| Cell 3 | Cell 4 |"; - - let expected_table = table( - 0..95, - None, - vec![row(vec![ - column( - 1, - 1, - true, - text("Header 1", 1..11), - ParsedMarkdownTableAlignment::None, - ), - column( - 1, - 1, - true, - text("Header 2", 12..22), - ParsedMarkdownTableAlignment::None, - ), - ])], - vec![ - row(vec![ - column( - 1, - 1, - false, - text("Cell 1", 49..59), - ParsedMarkdownTableAlignment::None, - ), - column( - 1, - 1, - false, - text("Cell 2", 60..70), - ParsedMarkdownTableAlignment::None, - ), - ]), - row(vec![ - column( - 1, - 1, - false, - text("Cell 3", 73..83), - ParsedMarkdownTableAlignment::None, - ), - column( - 1, - 1, - false, - text("Cell 4", 84..94), - ParsedMarkdownTableAlignment::None, - ), - ]), - ], - ); - - assert_eq!( - parse(markdown).await.children[0], - ParsedMarkdownElement::Table(expected_table) - ); - } - - #[gpui::test] - async fn test_table_with_checkboxes() { - let markdown = "\ -| Done | Task | -|------|---------| -| [x] | Fix bug | -| [ ] | Add feature |"; - - let parsed = parse(markdown).await; - let table = match &parsed.children[0] { - ParsedMarkdownElement::Table(table) => table, - other => panic!("Expected table, got: {:?}", other), - }; - - let first_cell = &table.body[0].columns[0]; - let first_cell_text = match &first_cell.children[0] { - MarkdownParagraphChunk::Text(t) => t.contents.to_string(), - other => panic!("Expected text chunk, got: {:?}", other), - }; - assert_eq!(first_cell_text.trim(), "[x]"); - - let second_cell = &table.body[1].columns[0]; - let second_cell_text = match &second_cell.children[0] { - MarkdownParagraphChunk::Text(t) => t.contents.to_string(), - other => panic!("Expected text chunk, got: {:?}", other), - }; - assert_eq!(second_cell_text.trim(), "[ ]"); - } - - #[gpui::test] - async fn test_list_basic() { - let parsed = parse( - "\ -* Item 1 -* Item 2 -* Item 3 -", - ) - .await; - - assert_eq!( - parsed.children, - vec![ - list_item(0..8, 1, Unordered, vec![p("Item 1", 2..8)]), - list_item(9..17, 1, Unordered, vec![p("Item 2", 11..17)]), - list_item(18..26, 1, Unordered, vec![p("Item 3", 20..26)]), - ], - ); - } - - #[gpui::test] - async fn test_list_with_tasks() { - let parsed = parse( - "\ -- [ ] TODO -- [x] Checked -", - ) - .await; - - assert_eq!( - parsed.children, - vec![ - list_item(0..10, 1, Task(false, 2..5), vec![p("TODO", 6..10)]), - list_item(11..24, 1, Task(true, 13..16), vec![p("Checked", 17..24)]), - ], - ); - } - - #[gpui::test] - async fn test_list_with_indented_task() { - let parsed = parse( - "\ -- [ ] TODO - - [x] Checked - - Unordered - 1. Number 1 - 1. Number 2 -1. Number A -", - ) - .await; - - assert_eq!( - parsed.children, - vec![ - list_item(0..12, 1, Task(false, 2..5), vec![p("TODO", 6..10)]), - list_item(13..26, 2, Task(true, 15..18), vec![p("Checked", 19..26)]), - list_item(29..40, 2, Unordered, vec![p("Unordered", 31..40)]), - list_item(43..54, 2, Ordered(1), vec![p("Number 1", 46..54)]), - list_item(57..68, 2, Ordered(2), vec![p("Number 2", 60..68)]), - list_item(69..80, 1, Ordered(1), vec![p("Number A", 72..80)]), - ], - ); - } - - #[gpui::test] - async fn test_list_with_linebreak_is_handled_correctly() { - let parsed = parse( - "\ -- [ ] Task 1 - -- [x] Task 2 -", - ) - .await; - - assert_eq!( - parsed.children, - vec![ - list_item(0..13, 1, Task(false, 2..5), vec![p("Task 1", 6..12)]), - list_item(14..26, 1, Task(true, 16..19), vec![p("Task 2", 20..26)]), - ], - ); - } - - #[gpui::test] - async fn test_list_nested() { - let parsed = parse( - "\ -* Item 1 -* Item 2 -* Item 3 - -1. Hello -1. Two - 1. Three -2. Four -3. Five - -* First - 1. Hello - 1. Goodbyte - - Inner - - Inner - 2. Goodbyte - - Next item empty - - -* Last -", - ) - .await; - - assert_eq!( - parsed.children, - vec![ - list_item(0..8, 1, Unordered, vec![p("Item 1", 2..8)]), - list_item(9..17, 1, Unordered, vec![p("Item 2", 11..17)]), - list_item(18..27, 1, Unordered, vec![p("Item 3", 20..26)]), - list_item(28..36, 1, Ordered(1), vec![p("Hello", 31..36)]), - list_item(37..46, 1, Ordered(2), vec![p("Two", 40..43),]), - list_item(47..55, 2, Ordered(1), vec![p("Three", 50..55)]), - list_item(56..63, 1, Ordered(3), vec![p("Four", 59..63)]), - list_item(64..72, 1, Ordered(4), vec![p("Five", 67..71)]), - list_item(73..82, 1, Unordered, vec![p("First", 75..80)]), - list_item(83..96, 2, Ordered(1), vec![p("Hello", 86..91)]), - list_item(97..116, 3, Ordered(1), vec![p("Goodbyte", 100..108)]), - list_item(117..124, 4, Unordered, vec![p("Inner", 119..124)]), - list_item(133..140, 4, Unordered, vec![p("Inner", 135..140)]), - list_item(143..159, 2, Ordered(2), vec![p("Goodbyte", 146..154)]), - list_item(160..180, 3, Unordered, vec![p("Next item empty", 165..180)]), - list_item(186..190, 3, Unordered, vec![]), - list_item(191..197, 1, Unordered, vec![p("Last", 193..197)]), - ] - ); - } - - #[gpui::test] - async fn test_list_with_nested_content() { - let parsed = parse( - "\ -* This is a list item with two paragraphs. - - This is the second paragraph in the list item. -", - ) - .await; - - assert_eq!( - parsed.children, - vec![list_item( - 0..96, - 1, - Unordered, - vec![ - p("This is a list item with two paragraphs.", 4..44), - p("This is the second paragraph in the list item.", 50..97) - ], - ),], - ); - } - - #[gpui::test] - async fn test_list_item_with_inline_html() { - let parsed = parse( - "\ -* This is a list item with an inline HTML tag. -", - ) - .await; - - assert_eq!( - parsed.children, - vec![list_item( - 0..67, - 1, - Unordered, - vec![p("This is a list item with an inline HTML tag.", 4..44),], - ),], - ); - } - - #[gpui::test] - async fn test_nested_list_with_paragraph_inside() { - let parsed = parse( - "\ -1. a - 1. b - 1. c - - text - - 1. d -", - ) - .await; - - assert_eq!( - parsed.children, - vec![ - list_item(0..7, 1, Ordered(1), vec![p("a", 3..4)],), - list_item(8..20, 2, Ordered(1), vec![p("b", 12..13),],), - list_item(21..27, 3, Ordered(1), vec![p("c", 25..26),],), - p("text", 32..37), - list_item(41..46, 2, Ordered(1), vec![p("d", 45..46),],), - ], - ); - } - - #[gpui::test] - async fn test_list_with_leading_text() { - let parsed = parse( - "\ -* `code` -* **bold** -* [link](https://example.com) -", - ) - .await; - - assert_eq!( - parsed.children, - vec![ - list_item(0..8, 1, Unordered, vec![p("code", 2..8)]), - list_item(9..19, 1, Unordered, vec![p("bold", 11..19)]), - list_item(20..49, 1, Unordered, vec![p("link", 22..49)],), - ], - ); - } - - #[gpui::test] - async fn test_simple_block_quote() { - let parsed = parse("> Simple block quote with **styled text**").await; - - assert_eq!( - parsed.children, - vec![block_quote( - vec![p("Simple block quote with styled text", 2..41)], - 0..41 - )] - ); - } - - #[gpui::test] - async fn test_simple_block_quote_with_multiple_lines() { - let parsed = parse( - "\ -> # Heading -> More -> text -> -> More text -", - ) - .await; - - assert_eq!( - parsed.children, - vec![block_quote( - vec![ - h1(text("Heading", 4..11), 2..12), - p("More text", 14..26), - p("More text", 30..40) - ], - 0..40 - )] - ); - } - - #[gpui::test] - async fn test_nested_block_quote() { - let parsed = parse( - "\ -> A -> -> > # B -> -> C - -More text -", - ) - .await; - - assert_eq!( - parsed.children, - vec![ - block_quote( - vec![ - p("A", 2..4), - block_quote(vec![h1(text("B", 12..13), 10..14)], 8..14), - p("C", 18..20) - ], - 0..20 - ), - p("More text", 21..31) - ] - ); - } - - #[gpui::test] - async fn test_dollar_signs_are_plain_text() { - // Dollar signs should be preserved as plain text, not treated as math delimiters. - // Regression test for https://github.com/zed-industries/zed/issues/50170 - let parsed = parse("$100$ per unit").await; - assert_eq!(parsed.children, vec![p("$100$ per unit", 0..14)]); - } - - #[gpui::test] - async fn test_dollar_signs_in_list_items() { - let parsed = parse("- $18,000 budget\n- $20,000 budget\n").await; - assert_eq!( - parsed.children, - vec![ - list_item(0..16, 1, Unordered, vec![p("$18,000 budget", 2..16)]), - list_item(17..33, 1, Unordered, vec![p("$20,000 budget", 19..33)]), - ] - ); - } - - #[gpui::test] - async fn test_code_block() { - let parsed = parse( - "\ -``` -fn main() { - return 0; -} -``` -", - ) - .await; - - assert_eq!( - parsed.children, - vec![code_block( - None, - "fn main() {\n return 0;\n}", - 0..35, - None - )] - ); - } - - #[gpui::test] - async fn test_code_block_with_language(executor: BackgroundExecutor) { - let language_registry = Arc::new(LanguageRegistry::test(executor.clone())); - language_registry.add(language::rust_lang()); - - let parsed = parse_markdown( - "\ -```rust -fn main() { - return 0; -} -``` -", - None, - Some(language_registry), - ) - .await; - - assert_eq!( - parsed.children, - vec![code_block( - Some("rust".to_string()), - "fn main() {\n return 0;\n}", - 0..39, - Some(vec![]) - )] - ); - } - - fn h1(contents: MarkdownParagraph, source_range: Range) -> ParsedMarkdownElement { - ParsedMarkdownElement::Heading(ParsedMarkdownHeading { - source_range, - level: HeadingLevel::H1, - contents, - }) - } - - fn h2(contents: MarkdownParagraph, source_range: Range) -> ParsedMarkdownElement { - ParsedMarkdownElement::Heading(ParsedMarkdownHeading { - source_range, - level: HeadingLevel::H2, - contents, - }) - } - - fn h3(contents: MarkdownParagraph, source_range: Range) -> ParsedMarkdownElement { - ParsedMarkdownElement::Heading(ParsedMarkdownHeading { - source_range, - level: HeadingLevel::H3, - contents, - }) - } - - fn p(contents: &str, source_range: Range) -> ParsedMarkdownElement { - ParsedMarkdownElement::Paragraph(text(contents, source_range)) - } - - fn text(contents: &str, source_range: Range) -> MarkdownParagraph { - vec![MarkdownParagraphChunk::Text(ParsedMarkdownText { - highlights: Vec::new(), - regions: Vec::new(), - source_range, - contents: contents.to_string().into(), - })] - } - - fn block_quote( - children: Vec, - source_range: Range, - ) -> ParsedMarkdownElement { - ParsedMarkdownElement::BlockQuote(ParsedMarkdownBlockQuote { - source_range, - children, - }) - } - - fn code_block( - language: Option, - code: &str, - source_range: Range, - highlights: Option, HighlightId)>>, - ) -> ParsedMarkdownElement { - ParsedMarkdownElement::CodeBlock(ParsedMarkdownCodeBlock { - source_range, - language, - contents: code.to_string().into(), - highlights, - }) - } - - fn list_item( - source_range: Range, - depth: u16, - item_type: ParsedMarkdownListItemType, - content: Vec, - ) -> ParsedMarkdownElement { - ParsedMarkdownElement::ListItem(ParsedMarkdownListItem { - source_range, - item_type, - depth, - content, - nested: false, - }) - } - - fn nested_list_item( - source_range: Range, - depth: u16, - item_type: ParsedMarkdownListItemType, - content: Vec, - ) -> ParsedMarkdownElement { - ParsedMarkdownElement::ListItem(ParsedMarkdownListItem { - source_range, - item_type, - depth, - content, - nested: true, - }) - } - - fn table( - source_range: Range, - caption: Option, - header: Vec, - body: Vec, - ) -> ParsedMarkdownTable { - ParsedMarkdownTable { - source_range, - header, - body, - caption, - } - } - - fn row(columns: Vec) -> ParsedMarkdownTableRow { - ParsedMarkdownTableRow { columns } - } - - fn column( - col_span: usize, - row_span: usize, - is_header: bool, - children: MarkdownParagraph, - alignment: ParsedMarkdownTableAlignment, - ) -> ParsedMarkdownTableColumn { - ParsedMarkdownTableColumn { - col_span, - row_span, - is_header, - children, - alignment, - } - } - - impl PartialEq for ParsedMarkdownTable { - fn eq(&self, other: &Self) -> bool { - self.source_range == other.source_range - && self.header == other.header - && self.body == other.body - } - } - - impl PartialEq for ParsedMarkdownText { - fn eq(&self, other: &Self) -> bool { - self.source_range == other.source_range && self.contents == other.contents - } - } -} diff --git a/crates/markdown_preview/src/markdown_preview.rs b/crates/markdown_preview/src/markdown_preview.rs index 0a657d27bc1416995d3c4df7f6793c017356fa0d..982eff7c74513cb29b368d49ecd454162f2c3913 100644 --- a/crates/markdown_preview/src/markdown_preview.rs +++ b/crates/markdown_preview/src/markdown_preview.rs @@ -1,11 +1,7 @@ use gpui::{App, actions}; use workspace::Workspace; -pub mod markdown_elements; -mod markdown_minifier; -pub mod markdown_parser; pub mod markdown_preview_view; -pub mod markdown_renderer; pub use zed_actions::preview::markdown::{OpenPreview, OpenPreviewToTheSide}; diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs index b5213504e72a8e99c6405df85001fa615257dc0e..0b9c63c3b16f5686afcfdafdba119ede8c37fe3f 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/crates/markdown_preview/src/markdown_preview_view.rs @@ -1,46 +1,45 @@ use std::cmp::min; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; -use std::{ops::Range, path::PathBuf}; use anyhow::Result; use editor::scroll::Autoscroll; use editor::{Editor, EditorEvent, MultiBufferOffset, SelectionEffects}; use gpui::{ - App, ClickEvent, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, - IntoElement, IsZero, ListOffset, ListState, ParentElement, Render, RetainAllImageCache, Styled, - Subscription, Task, WeakEntity, Window, list, + App, Context, Entity, EventEmitter, FocusHandle, Focusable, ImageSource, InteractiveElement, + IntoElement, IsZero, Pixels, Render, Resource, RetainAllImageCache, ScrollHandle, SharedString, + SharedUri, Subscription, Task, WeakEntity, Window, point, }; use language::LanguageRegistry; +use markdown::{ + CodeBlockRenderer, Markdown, MarkdownElement, MarkdownFont, MarkdownOptions, MarkdownStyle, +}; use settings::Settings; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{WithScrollbar, prelude::*}; +use util::normalize_path; use workspace::item::{Item, ItemHandle}; -use workspace::{Pane, Workspace}; +use workspace::{OpenOptions, OpenVisible, Pane, Workspace}; -use crate::markdown_elements::ParsedMarkdownElement; -use crate::markdown_renderer::{CheckboxClickedEvent, MermaidState}; use crate::{ - OpenFollowingPreview, OpenPreview, OpenPreviewToTheSide, ScrollPageDown, ScrollPageUp, - markdown_elements::ParsedMarkdown, - markdown_parser::parse_markdown, - markdown_renderer::{RenderContext, render_markdown_block}, + OpenFollowingPreview, OpenPreview, OpenPreviewToTheSide, ScrollDown, ScrollDownByItem, }; -use crate::{ScrollDown, ScrollDownByItem, ScrollToBottom, ScrollToTop, ScrollUp, ScrollUpByItem}; +use crate::{ScrollPageDown, ScrollPageUp, ScrollToBottom, ScrollToTop, ScrollUp, ScrollUpByItem}; const REPARSE_DEBOUNCE: Duration = Duration::from_millis(200); pub struct MarkdownPreviewView { workspace: WeakEntity, - image_cache: Entity, active_editor: Option, focus_handle: FocusHandle, - contents: Option, - selected_block: usize, - list_state: ListState, - language_registry: Arc, - mermaid_state: MermaidState, - parsing_markdown_task: Option>>, + markdown: Entity, + _markdown_subscription: Subscription, + active_source_index: Option, + scroll_handle: ScrollHandle, + image_cache: Entity, + base_directory: Option, + pending_update_task: Option>>, mode: MarkdownPreviewMode, } @@ -205,19 +204,35 @@ impl MarkdownPreviewView { cx: &mut Context, ) -> Entity { cx.new(|cx| { - let list_state = ListState::new(0, gpui::ListAlignment::Top, px(1000.)); - + let markdown = cx.new(|cx| { + Markdown::new_with_options( + SharedString::default(), + Some(language_registry), + None, + MarkdownOptions { + parse_html: true, + render_mermaid_diagrams: true, + ..Default::default() + }, + cx, + ) + }); let mut this = Self { - selected_block: 0, active_editor: None, focus_handle: cx.focus_handle(), workspace: workspace.clone(), - contents: None, - list_state, - language_registry, - mermaid_state: Default::default(), - parsing_markdown_task: None, + _markdown_subscription: cx.observe( + &markdown, + |this: &mut Self, _: Entity, cx| { + this.sync_active_root_block(cx); + }, + ), + markdown, + active_source_index: None, + scroll_handle: ScrollHandle::new(), image_cache: RetainAllImageCache::new(cx), + base_directory: None, + pending_update_task: None, mode, }; @@ -280,17 +295,16 @@ impl MarkdownPreviewView { | EditorEvent::BufferEdited { .. } | EditorEvent::DirtyChanged | EditorEvent::ExcerptsEdited { .. } => { - this.parse_markdown_from_active_editor(true, window, cx); + this.update_markdown_from_active_editor(true, false, window, cx); } EditorEvent::SelectionsChanged { .. } => { - let selection_range = editor.update(cx, |editor, cx| { - editor - .selections - .last::(&editor.display_snapshot(cx)) - .range() - }); - this.selected_block = this.get_block_index_under_cursor(selection_range); - this.list_state.scroll_to_reveal_item(this.selected_block); + let (selection_start, editor_is_focused) = + editor.update(cx, |editor, cx| { + let index = Self::selected_source_index(editor, cx); + let focused = editor.focus_handle(cx).is_focused(window); + (index, focused) + }); + this.sync_preview_to_source_index(selection_start, editor_is_focused, cx); cx.notify(); } _ => {} @@ -298,27 +312,30 @@ impl MarkdownPreviewView { }, ); + self.base_directory = Self::get_folder_for_active_editor(editor.read(cx), cx); self.active_editor = Some(EditorState { editor, _subscription: subscription, }); - self.parse_markdown_from_active_editor(false, window, cx); + self.update_markdown_from_active_editor(false, true, window, cx); } - fn parse_markdown_from_active_editor( + fn update_markdown_from_active_editor( &mut self, wait_for_debounce: bool, + should_reveal: bool, window: &mut Window, cx: &mut Context, ) { if let Some(state) = &self.active_editor { // if there is already a task to update the ui and the current task is also debounced (not high priority), do nothing - if wait_for_debounce && self.parsing_markdown_task.is_some() { + if wait_for_debounce && self.pending_update_task.is_some() { return; } - self.parsing_markdown_task = Some(self.parse_markdown_in_background( + self.pending_update_task = Some(self.schedule_markdown_update( wait_for_debounce, + should_reveal, state.editor.clone(), window, cx, @@ -326,63 +343,97 @@ impl MarkdownPreviewView { } } - fn parse_markdown_in_background( + fn schedule_markdown_update( &mut self, wait_for_debounce: bool, + should_reveal_selection: bool, editor: Entity, window: &mut Window, cx: &mut Context, ) -> Task> { - let language_registry = self.language_registry.clone(); - cx.spawn_in(window, async move |view, cx| { if wait_for_debounce { // Wait for the user to stop typing cx.background_executor().timer(REPARSE_DEBOUNCE).await; } - let (contents, file_location) = view.update(cx, |_, cx| { - let editor = editor.read(cx); - let contents = editor.buffer().read(cx).snapshot(cx).text(); - let file_location = MarkdownPreviewView::get_folder_for_active_editor(editor, cx); - (contents, file_location) - })?; + let editor_clone = editor.clone(); + let update = view.update(cx, |view, cx| { + let is_active_editor = view + .active_editor + .as_ref() + .is_some_and(|active_editor| active_editor.editor == editor_clone); + if !is_active_editor { + return None; + } - let parsing_task = cx.background_spawn(async move { - parse_markdown(&contents, file_location, Some(language_registry)).await - }); - let contents = parsing_task.await; + let (contents, selection_start) = editor_clone.update(cx, |editor, cx| { + let contents = editor.buffer().read(cx).snapshot(cx).text(); + let selection_start = Self::selected_source_index(editor, cx); + (contents, selection_start) + }); + Some((SharedString::from(contents), selection_start)) + })?; view.update(cx, move |view, cx| { - view.mermaid_state.update(&contents, cx); - let markdown_blocks_count = contents.children.len(); - view.contents = Some(contents); - let scroll_top = view.list_state.logical_scroll_top(); - view.list_state.reset(markdown_blocks_count); - view.list_state.scroll_to(scroll_top); - view.parsing_markdown_task = None; + if let Some((contents, selection_start)) = update { + view.markdown.update(cx, |markdown, cx| { + markdown.reset(contents, cx); + }); + view.sync_preview_to_source_index(selection_start, should_reveal_selection, cx); + } + view.pending_update_task = None; cx.notify(); }) }) } - fn move_cursor_to_block( - &self, - window: &mut Window, + fn selected_source_index(editor: &Editor, cx: &mut App) -> usize { + editor + .selections + .last::(&editor.display_snapshot(cx)) + .range() + .start + .0 + } + + fn sync_preview_to_source_index( + &mut self, + source_index: usize, + reveal: bool, cx: &mut Context, - selection: Range, ) { - if let Some(state) = &self.active_editor { - state.editor.update(cx, |editor, cx| { - editor.change_selections( - SelectionEffects::scroll(Autoscroll::center()), - window, - cx, - |selections| selections.select_ranges(vec![selection]), - ); - window.focus(&editor.focus_handle(cx), cx); - }); - } + self.active_source_index = Some(source_index); + self.sync_active_root_block(cx); + self.markdown.update(cx, |markdown, cx| { + if reveal { + markdown.request_autoscroll_to_source_index(source_index, cx); + } + }); + } + + fn sync_active_root_block(&mut self, cx: &mut Context) { + self.markdown.update(cx, |markdown, cx| { + markdown.set_active_root_for_source_index(self.active_source_index, cx); + }); + } + + fn move_cursor_to_source_index( + editor: &Entity, + source_index: usize, + window: &mut Window, + cx: &mut App, + ) { + editor.update(cx, |editor, cx| { + let selection = MultiBufferOffset(source_index)..MultiBufferOffset(source_index); + editor.change_selections( + SelectionEffects::scroll(Autoscroll::center()), + window, + cx, + |selections| selections.select_ranges(vec![selection]), + ); + window.focus(&editor.focus_handle(cx), cx); + }); } /// The absolute path of the file that is currently being previewed. @@ -398,52 +449,24 @@ impl MarkdownPreviewView { } } - fn get_block_index_under_cursor(&self, selection_range: Range) -> usize { - let mut block_index = None; - let cursor = selection_range.start.0; - - let mut last_end = 0; - if let Some(content) = &self.contents { - for (i, block) in content.children.iter().enumerate() { - let Some(Range { start, end }) = block.source_range() else { - continue; - }; - - // Check if the cursor is between the last block and the current block - if last_end <= cursor && cursor < start { - block_index = Some(i.saturating_sub(1)); - break; - } - - if start <= cursor && end >= cursor { - block_index = Some(i); - break; - } - last_end = end; - } - - if block_index.is_none() && last_end < cursor { - block_index = Some(content.children.len().saturating_sub(1)); - } - } - - block_index.unwrap_or_default() + fn line_scroll_amount(&self, cx: &App) -> Pixels { + let settings = ThemeSettings::get_global(cx); + settings.buffer_font_size(cx) * settings.buffer_line_height.value() } - fn should_apply_padding_between( - current_block: &ParsedMarkdownElement, - next_block: Option<&ParsedMarkdownElement>, - ) -> bool { - !(current_block.is_list_item() && next_block.map(|b| b.is_list_item()).unwrap_or(false)) + fn scroll_by_amount(&self, distance: Pixels) { + let offset = self.scroll_handle.offset(); + self.scroll_handle + .set_offset(point(offset.x, offset.y - distance)); } fn scroll_page_up(&mut self, _: &ScrollPageUp, _window: &mut Window, cx: &mut Context) { - let viewport_height = self.list_state.viewport_bounds().size.height; + let viewport_height = self.scroll_handle.bounds().size.height; if viewport_height.is_zero() { return; } - self.list_state.scroll_by(-viewport_height); + self.scroll_by_amount(-viewport_height); cx.notify(); } @@ -453,35 +476,49 @@ impl MarkdownPreviewView { _window: &mut Window, cx: &mut Context, ) { - let viewport_height = self.list_state.viewport_bounds().size.height; + let viewport_height = self.scroll_handle.bounds().size.height; if viewport_height.is_zero() { return; } - self.list_state.scroll_by(viewport_height); + self.scroll_by_amount(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) { + if let Some(bounds) = self + .scroll_handle + .bounds_for_item(self.scroll_handle.top_item()) + { 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); + self.scroll_by_amount(-scroll_height); + } else { + let scroll_height = self.line_scroll_amount(cx); + if !scroll_height.is_zero() { + self.scroll_by_amount(-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) { + if let Some(bounds) = self + .scroll_handle + .bounds_for_item(self.scroll_handle.top_item()) + { 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); + self.scroll_by_amount(scroll_height); + } else { + let scroll_height = self.line_scroll_amount(cx); + if !scroll_height.is_zero() { + self.scroll_by_amount(scroll_height); + } } cx.notify(); } @@ -492,9 +529,11 @@ impl MarkdownPreviewView { _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); + if let Some(bounds) = self + .scroll_handle + .bounds_for_item(self.scroll_handle.top_item()) + { + self.scroll_by_amount(-bounds.size.height); } cx.notify(); } @@ -505,18 +544,17 @@ impl MarkdownPreviewView { _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); + if let Some(bounds) = self + .scroll_handle + .bounds_for_item(self.scroll_handle.top_item()) + { + self.scroll_by_amount(bounds.size.height); } cx.notify(); } fn scroll_to_top(&mut self, _: &ScrollToTop, _window: &mut Window, cx: &mut Context) { - self.list_state.scroll_to(ListOffset { - item_ix: 0, - offset_in_item: px(0.), - }); + self.scroll_handle.scroll_to_item(0); cx.notify(); } @@ -526,19 +564,157 @@ impl MarkdownPreviewView { _window: &mut Window, cx: &mut Context, ) { - let count = self.list_state.item_count(); - if count > 0 { - self.list_state.scroll_to(ListOffset { - item_ix: count - 1, - offset_in_item: px(0.), - }); - } + self.scroll_handle.scroll_to_bottom(); cx.notify(); } + + fn render_markdown_element( + &self, + window: &mut Window, + cx: &mut Context, + ) -> MarkdownElement { + let workspace = self.workspace.clone(); + let base_directory = self.base_directory.clone(); + let active_editor = self + .active_editor + .as_ref() + .map(|state| state.editor.clone()); + + let mut markdown_element = MarkdownElement::new( + self.markdown.clone(), + MarkdownStyle::themed(MarkdownFont::Editor, window, cx), + ) + .code_block_renderer(CodeBlockRenderer::Default { + copy_button: false, + copy_button_on_hover: true, + border: false, + }) + .scroll_handle(self.scroll_handle.clone()) + .show_root_block_markers() + .image_resolver({ + let base_directory = self.base_directory.clone(); + move |dest_url| resolve_preview_image(dest_url, base_directory.as_deref()) + }) + .on_url_click(move |url, window, cx| { + open_preview_url(url, base_directory.clone(), &workspace, window, cx); + }); + + if let Some(active_editor) = active_editor { + let editor_for_checkbox = active_editor.clone(); + let view_handle = cx.entity().downgrade(); + markdown_element = markdown_element + .on_source_click(move |source_index, click_count, window, cx| { + if click_count == 2 { + Self::move_cursor_to_source_index(&active_editor, source_index, window, cx); + true + } else { + false + } + }) + .on_checkbox_toggle(move |source_range, new_checked, window, cx| { + let task_marker = if new_checked { "[x]" } else { "[ ]" }; + editor_for_checkbox.update(cx, |editor, cx| { + editor.edit( + [( + MultiBufferOffset(source_range.start) + ..MultiBufferOffset(source_range.end), + task_marker, + )], + cx, + ); + }); + if let Some(view) = view_handle.upgrade() { + cx.update_entity(&view, |this, cx| { + this.update_markdown_from_active_editor(false, false, window, cx); + }); + } + }); + } + + markdown_element + } +} + +fn open_preview_url( + url: SharedString, + base_directory: Option, + workspace: &WeakEntity, + window: &mut Window, + cx: &mut App, +) { + if let Some(path) = resolve_preview_path(url.as_ref(), base_directory.as_deref()) + && let Some(workspace) = workspace.upgrade() + { + let _ = workspace.update(cx, |workspace, cx| { + workspace + .open_abs_path( + normalize_path(path.as_path()), + OpenOptions { + visible: Some(OpenVisible::None), + ..Default::default() + }, + window, + cx, + ) + .detach(); + }); + return; + } + + cx.open_url(url.as_ref()); +} + +fn resolve_preview_path(url: &str, base_directory: Option<&Path>) -> Option { + if url.starts_with("http://") || url.starts_with("https://") { + return None; + } + + let decoded_url = urlencoding::decode(url) + .map(|decoded| decoded.into_owned()) + .unwrap_or_else(|_| url.to_string()); + let candidate = PathBuf::from(&decoded_url); + + if candidate.is_absolute() && candidate.exists() { + return Some(candidate); + } + + let base_directory = base_directory?; + let resolved = base_directory.join(decoded_url); + if resolved.exists() { + Some(resolved) + } else { + None + } +} + +fn resolve_preview_image(dest_url: &str, base_directory: Option<&Path>) -> Option { + if dest_url.starts_with("data:") { + return None; + } + + if dest_url.starts_with("http://") || dest_url.starts_with("https://") { + return Some(ImageSource::Resource(Resource::Uri(SharedUri::from( + dest_url.to_string(), + )))); + } + + let decoded = urlencoding::decode(dest_url) + .map(|decoded| decoded.into_owned()) + .unwrap_or_else(|_| dest_url.to_string()); + + let path = if Path::new(&decoded).is_absolute() { + PathBuf::from(decoded) + } else { + base_directory?.join(decoded) + }; + + Some(ImageSource::Resource(Resource::Path(Arc::from( + path.as_path(), + )))) } impl Focusable for MarkdownPreviewView { - fn focus_handle(&self, _: &App) -> gpui::FocusHandle { + fn focus_handle(&self, _: &App) -> FocusHandle { self.focus_handle.clone() } } @@ -572,10 +748,7 @@ impl Item for MarkdownPreviewView { impl Render for MarkdownPreviewView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let buffer_size = ThemeSettings::get_global(cx).buffer_font_size(cx); - let buffer_line_height = ThemeSettings::get_global(cx).buffer_line_height; - - v_flex() + div() .image_cache(self.image_cache.clone()) .id("MarkdownPreview") .key_context("MarkdownPreview") @@ -590,113 +763,65 @@ impl Render for MarkdownPreviewView { .on_action(cx.listener(MarkdownPreviewView::scroll_to_bottom)) .size_full() .bg(cx.theme().colors().editor_background) - .p_4() - .text_size(buffer_size) - .line_height(relative(buffer_line_height.value())) - .child(div().flex_grow().map(|this| { - this.child( - list( - self.list_state.clone(), - cx.processor(|this, ix, window, cx| { - let Some(contents) = &this.contents else { - return div().into_any(); - }; - - let mut render_cx = RenderContext::new( - Some(this.workspace.clone()), - &this.mermaid_state, - window, - cx, - ) - .with_checkbox_clicked_callback(cx.listener( - move |this, e: &CheckboxClickedEvent, window, cx| { - if let Some(editor) = - this.active_editor.as_ref().map(|s| s.editor.clone()) - { - editor.update(cx, |editor, cx| { - let task_marker = - if e.checked() { "[x]" } else { "[ ]" }; - - editor.edit( - [( - MultiBufferOffset(e.source_range().start) - ..MultiBufferOffset(e.source_range().end), - task_marker, - )], - cx, - ); - }); - this.parse_markdown_from_active_editor(false, window, cx); - cx.notify(); - } - }, - )); - - let block = contents.children.get(ix).unwrap(); - let rendered_block = render_markdown_block(block, &mut render_cx); - - let should_apply_padding = Self::should_apply_padding_between( - block, - contents.children.get(ix + 1), - ); - - let selected_block = this.selected_block; - let scaled_rems = render_cx.scaled_rems(1.0); - div() - .id(ix) - .when(should_apply_padding, |this| { - this.pb(render_cx.scaled_rems(0.75)) - }) - .group("markdown-block") - .on_click(cx.listener( - move |this, event: &ClickEvent, window, cx| { - if event.click_count() == 2 - && let Some(source_range) = this - .contents - .as_ref() - .and_then(|c| c.children.get(ix)) - .and_then(|block: &ParsedMarkdownElement| { - block.source_range() - }) - { - this.move_cursor_to_block( - window, - cx, - MultiBufferOffset(source_range.start) - ..MultiBufferOffset(source_range.start), - ); - } - }, - )) - .map(move |container| { - let indicator = div() - .h_full() - .w(px(4.0)) - .when(ix == selected_block, |this| { - this.bg(cx.theme().colors().border) - }) - .group_hover("markdown-block", |s| { - if ix == selected_block { - s - } else { - s.bg(cx.theme().colors().border_variant) - } - }) - .rounded_xs(); - - container.child( - div() - .relative() - .child(div().pl(scaled_rems).child(rendered_block)) - .child(indicator.absolute().left_0().top_0()), - ) - }) - .into_any() - }), - ) - .size_full(), - ) - })) - .vertical_scrollbar_for(&self.list_state, window, cx) + .child( + div() + .id("markdown-preview-scroll-container") + .size_full() + .overflow_y_scroll() + .track_scroll(&self.scroll_handle) + .p_4() + .child(self.render_markdown_element(window, cx)), + ) + .vertical_scrollbar_for(&self.scroll_handle, window, cx) + } +} + +#[cfg(test)] +mod tests { + use anyhow::Result; + use std::fs; + use tempfile::TempDir; + + use super::resolve_preview_path; + + #[test] + fn resolves_relative_preview_paths() -> Result<()> { + let temp_dir = TempDir::new()?; + let base_directory = temp_dir.path(); + let file = base_directory.join("notes.md"); + fs::write(&file, "# Notes")?; + + assert_eq!( + resolve_preview_path("notes.md", Some(base_directory)), + Some(file) + ); + assert_eq!( + resolve_preview_path("nonexistent.md", Some(base_directory)), + None + ); + assert_eq!(resolve_preview_path("notes.md", None), None); + + Ok(()) + } + + #[test] + fn resolves_urlencoded_preview_paths() -> Result<()> { + let temp_dir = TempDir::new()?; + let base_directory = temp_dir.path(); + let file = base_directory.join("release notes.md"); + fs::write(&file, "# Release Notes")?; + + assert_eq!( + resolve_preview_path("release%20notes.md", Some(base_directory)), + Some(file) + ); + + Ok(()) + } + + #[test] + fn does_not_treat_web_links_as_preview_paths() { + assert_eq!(resolve_preview_path("https://zed.dev", None), None); + assert_eq!(resolve_preview_path("http://example.com", None), None); } } diff --git a/crates/markdown_preview/src/markdown_renderer.rs b/crates/markdown_preview/src/markdown_renderer.rs deleted file mode 100644 index 59837621a6827f7cbc5840ac9b8f150dd4b59513..0000000000000000000000000000000000000000 --- a/crates/markdown_preview/src/markdown_renderer.rs +++ /dev/null @@ -1,1515 +0,0 @@ -use crate::{ - markdown_elements::{ - HeadingLevel, Image, Link, MarkdownParagraph, MarkdownParagraphChunk, ParsedMarkdown, - ParsedMarkdownBlockQuote, ParsedMarkdownCodeBlock, ParsedMarkdownElement, - ParsedMarkdownHeading, ParsedMarkdownListItem, ParsedMarkdownListItemType, - ParsedMarkdownMermaidDiagram, ParsedMarkdownMermaidDiagramContents, ParsedMarkdownTable, - ParsedMarkdownTableAlignment, ParsedMarkdownTableRow, - }, - markdown_preview_view::MarkdownPreviewView, -}; -use collections::HashMap; -use gpui::{ - AbsoluteLength, Animation, AnimationExt, AnyElement, App, AppContext as _, Context, Div, - Element, ElementId, Entity, HighlightStyle, Hsla, ImageSource, InteractiveText, IntoElement, - Keystroke, Modifiers, ParentElement, Render, RenderImage, Resource, SharedString, Styled, - StyledText, Task, TextStyle, WeakEntity, Window, div, img, pulsating_between, rems, -}; -use settings::Settings; -use std::{ - ops::{Mul, Range}, - sync::{Arc, OnceLock}, - time::Duration, - vec, -}; -use theme::{ActiveTheme, SyntaxTheme, ThemeSettings}; -use ui::{CopyButton, LinkPreview, ToggleState, prelude::*, tooltip_container}; -use util::normalize_path; -use workspace::{OpenOptions, OpenVisible, Workspace}; - -pub struct CheckboxClickedEvent { - pub checked: bool, - pub source_range: Range, -} - -impl CheckboxClickedEvent { - pub fn source_range(&self) -> Range { - self.source_range.clone() - } - - pub fn checked(&self) -> bool { - self.checked - } -} - -type CheckboxClickedCallback = Arc>; - -type MermaidDiagramCache = HashMap; - -#[derive(Default)] -pub(crate) struct MermaidState { - cache: MermaidDiagramCache, - order: Vec, -} - -impl MermaidState { - fn get_fallback_image( - idx: usize, - old_order: &[ParsedMarkdownMermaidDiagramContents], - new_order_len: usize, - cache: &MermaidDiagramCache, - ) -> Option> { - // When the diagram count changes e.g. addition or removal, positional matching - // is unreliable since a new diagram at index i likely doesn't correspond to the - // old diagram at index i. We only allow fallbacks when counts match, which covers - // the common case of editing a diagram in-place. - // - // Swapping two diagrams would briefly show the stale fallback, but that's an edge - // case we don't handle. - if old_order.len() != new_order_len { - return None; - } - old_order.get(idx).and_then(|old_content| { - cache.get(old_content).and_then(|old_cached| { - old_cached - .render_image - .get() - .and_then(|result| result.as_ref().ok().cloned()) - // Chain fallbacks for rapid edits. - .or_else(|| old_cached.fallback_image.clone()) - }) - }) - } - - pub(crate) fn update( - &mut self, - parsed: &ParsedMarkdown, - cx: &mut Context, - ) { - use crate::markdown_elements::ParsedMarkdownElement; - use std::collections::HashSet; - - let mut new_order = Vec::new(); - for element in parsed.children.iter() { - if let ParsedMarkdownElement::MermaidDiagram(mermaid_diagram) = element { - new_order.push(mermaid_diagram.contents.clone()); - } - } - - for (idx, new_content) in new_order.iter().enumerate() { - if !self.cache.contains_key(new_content) { - let fallback = - Self::get_fallback_image(idx, &self.order, new_order.len(), &self.cache); - self.cache.insert( - new_content.clone(), - CachedMermaidDiagram::new(new_content.clone(), fallback, cx), - ); - } - } - - let new_order_set: HashSet<_> = new_order.iter().cloned().collect(); - self.cache - .retain(|content, _| new_order_set.contains(content)); - self.order = new_order; - } -} - -pub(crate) struct CachedMermaidDiagram { - pub(crate) render_image: Arc>>>, - pub(crate) fallback_image: Option>, - _task: Task<()>, -} - -impl CachedMermaidDiagram { - pub(crate) fn new( - contents: ParsedMarkdownMermaidDiagramContents, - fallback_image: Option>, - cx: &mut Context, - ) -> Self { - let result = Arc::new(OnceLock::>>::new()); - let result_clone = result.clone(); - let svg_renderer = cx.svg_renderer(); - - let _task = cx.spawn(async move |this, cx| { - let value = cx - .background_spawn(async move { - let svg_string = mermaid_rs_renderer::render(&contents.contents)?; - let scale = contents.scale as f32 / 100.0; - svg_renderer - .render_single_frame(svg_string.as_bytes(), scale, true) - .map_err(|e| anyhow::anyhow!("{}", e)) - }) - .await; - let _ = result_clone.set(value); - this.update(cx, |_, cx| { - cx.notify(); - }) - .ok(); - }); - - Self { - render_image: result, - fallback_image, - _task, - } - } - - #[cfg(test)] - fn new_for_test( - render_image: Option>, - fallback_image: Option>, - ) -> Self { - let result = Arc::new(OnceLock::new()); - if let Some(img) = render_image { - let _ = result.set(Ok(img)); - } - Self { - render_image: result, - fallback_image, - _task: Task::ready(()), - } - } -} -#[derive(Clone)] -pub struct RenderContext<'a> { - workspace: Option>, - next_id: usize, - buffer_font_family: SharedString, - buffer_text_style: TextStyle, - text_style: TextStyle, - border_color: Hsla, - title_bar_background_color: Hsla, - panel_background_color: Hsla, - text_color: Hsla, - link_color: Hsla, - window_rem_size: Pixels, - text_muted_color: Hsla, - code_block_background_color: Hsla, - code_span_background_color: Hsla, - syntax_theme: Arc, - indent: usize, - checkbox_clicked_callback: Option, - is_last_child: bool, - mermaid_state: &'a MermaidState, -} - -impl<'a> RenderContext<'a> { - pub(crate) fn new( - workspace: Option>, - mermaid_state: &'a MermaidState, - window: &mut Window, - cx: &mut App, - ) -> Self { - let theme = cx.theme().clone(); - - 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 { - workspace, - next_id: 0, - indent: 0, - buffer_font_family, - buffer_text_style, - text_style: window.text_style(), - syntax_theme: theme.syntax().clone(), - border_color: theme.colors().border, - title_bar_background_color: theme.colors().title_bar_background, - panel_background_color: theme.colors().panel_background, - text_color: theme.colors().text, - link_color: theme.colors().text_accent, - window_rem_size: window.rem_size(), - text_muted_color: theme.colors().text_muted, - code_block_background_color: theme.colors().surface_background, - code_span_background_color: theme.colors().editor_document_highlight_read_background, - checkbox_clicked_callback: None, - is_last_child: false, - mermaid_state, - } - } - - pub fn with_checkbox_clicked_callback( - mut self, - callback: impl Fn(&CheckboxClickedEvent, &mut Window, &mut App) + 'static, - ) -> Self { - self.checkbox_clicked_callback = Some(Arc::new(Box::new(callback))); - self - } - - fn next_id(&mut self, span: &Range) -> ElementId { - let id = format!("markdown-{}-{}-{}", self.next_id, span.start, span.end); - self.next_id += 1; - ElementId::from(SharedString::from(id)) - } - - /// HACK: used to have rems relative to buffer font size, so that things scale appropriately as - /// buffer font size changes. The callees of this function should be reimplemented to use real - /// relative sizing once that is implemented in GPUI - pub fn scaled_rems(&self, rems: f32) -> Rems { - self.buffer_text_style - .font_size - .to_rems(self.window_rem_size) - .mul(rems) - } - - /// This ensures that children inside of block quotes - /// have padding between them. - /// - /// For example, for this markdown: - /// - /// ```markdown - /// > This is a block quote. - /// > - /// > And this is the next paragraph. - /// ``` - /// - /// We give padding between "This is a block quote." - /// and "And this is the next paragraph." - fn with_common_p(&self, element: Div) -> Div { - if self.indent > 0 && !self.is_last_child { - element.pb(self.scaled_rems(0.75)) - } else { - element - } - } - - /// The is used to indicate that the current element is the last child or not of its parent. - /// - /// Then we can avoid adding padding to the bottom of the last child. - fn with_last_child(&mut self, is_last: bool, render: R) -> AnyElement - where - R: FnOnce(&mut Self) -> AnyElement, - { - self.is_last_child = is_last; - let element = render(self); - self.is_last_child = false; - element - } -} - -pub fn render_parsed_markdown( - parsed: &ParsedMarkdown, - workspace: Option>, - window: &mut Window, - cx: &mut App, -) -> Div { - let cache = Default::default(); - let mut cx = RenderContext::new(workspace, &cache, window, cx); - - v_flex().gap_3().children( - parsed - .children - .iter() - .map(|block| render_markdown_block(block, &mut cx)), - ) -} -pub fn render_markdown_block(block: &ParsedMarkdownElement, cx: &mut RenderContext) -> AnyElement { - use ParsedMarkdownElement::*; - match block { - Paragraph(text) => render_markdown_paragraph(text, cx), - Heading(heading) => render_markdown_heading(heading, cx), - ListItem(list_item) => render_markdown_list_item(list_item, cx), - Table(table) => render_markdown_table(table, cx), - BlockQuote(block_quote) => render_markdown_block_quote(block_quote, cx), - CodeBlock(code_block) => render_markdown_code_block(code_block, cx), - MermaidDiagram(mermaid) => render_mermaid_diagram(mermaid, cx), - HorizontalRule(_) => render_markdown_rule(cx), - Image(image) => render_markdown_image(image, cx), - } -} - -fn render_markdown_heading(parsed: &ParsedMarkdownHeading, cx: &mut RenderContext) -> AnyElement { - let size = match parsed.level { - HeadingLevel::H1 => 2., - HeadingLevel::H2 => 1.5, - HeadingLevel::H3 => 1.25, - HeadingLevel::H4 => 1., - HeadingLevel::H5 => 0.875, - HeadingLevel::H6 => 0.85, - }; - - let text_size = cx.scaled_rems(size); - - // was `DefiniteLength::from(text_size.mul(1.25))` - // let line_height = DefiniteLength::from(text_size.mul(1.25)); - let line_height = text_size * 1.25; - - // was `rems(0.15)` - // let padding_top = cx.scaled_rems(0.15); - let padding_top = rems(0.15); - - // was `.pb_1()` = `rems(0.25)` - // let padding_bottom = cx.scaled_rems(0.25); - let padding_bottom = rems(0.25); - - let color = match parsed.level { - HeadingLevel::H6 => cx.text_muted_color, - _ => cx.text_color, - }; - div() - .line_height(line_height) - .text_size(text_size) - .text_color(color) - .pt(padding_top) - .pb(padding_bottom) - .children(render_markdown_text(&parsed.contents, cx)) - .whitespace_normal() - .into_any() -} - -fn render_markdown_list_item( - parsed: &ParsedMarkdownListItem, - cx: &mut RenderContext, -) -> AnyElement { - use ParsedMarkdownListItemType::*; - let depth = parsed.depth.saturating_sub(1) as usize; - - let bullet = match &parsed.item_type { - Ordered(order) => list_item_prefix(*order as usize, true, depth).into_any_element(), - Unordered => list_item_prefix(1, false, depth).into_any_element(), - Task(checked, range) => div() - .id(cx.next_id(range)) - .mt(cx.scaled_rems(3.0 / 16.0)) - .child( - MarkdownCheckbox::new( - "checkbox", - if *checked { - ToggleState::Selected - } else { - ToggleState::Unselected - }, - cx.clone(), - ) - .when_some( - cx.checkbox_clicked_callback.clone(), - |this, callback| { - this.on_click({ - let range = range.clone(); - move |selection, window, cx| { - let checked = match selection { - ToggleState::Selected => true, - ToggleState::Unselected => false, - _ => return, - }; - - if window.modifiers().secondary() { - callback( - &CheckboxClickedEvent { - checked, - source_range: range.clone(), - }, - window, - cx, - ); - } - } - }) - }, - ), - ) - .hover(|s| s.cursor_pointer()) - .tooltip(|_, cx| { - InteractiveMarkdownElementTooltip::new(None, "toggle checkbox", cx).into() - }) - .into_any_element(), - }; - let bullet = div().mr(cx.scaled_rems(0.5)).child(bullet); - - let contents: Vec = parsed - .content - .iter() - .map(|c| render_markdown_block(c, cx)) - .collect(); - - let item = h_flex() - .when(!parsed.nested, |this| this.pl(cx.scaled_rems(depth as f32))) - .when(parsed.nested && depth > 0, |this| this.ml_neg_1p5()) - .items_start() - .children(vec![ - bullet, - v_flex() - .children(contents) - .when(!parsed.nested, |this| this.gap(cx.scaled_rems(1.0))) - .pr(cx.scaled_rems(1.0)) - .w_full(), - ]); - - cx.with_common_p(item).into_any() -} - -/// # MarkdownCheckbox /// -/// HACK: Copied from `ui/src/components/toggle.rs` to deal with scaling issues in markdown preview -/// changes should be integrated into `Checkbox` in `toggle.rs` while making sure checkboxes elsewhere in the -/// app are not visually affected -#[derive(gpui::IntoElement)] -struct MarkdownCheckbox { - id: ElementId, - toggle_state: ToggleState, - disabled: bool, - placeholder: bool, - on_click: Option>, - filled: bool, - style: ui::ToggleStyle, - tooltip: Option gpui::AnyView>>, - label: Option, - base_rem: Rems, -} - -impl MarkdownCheckbox { - /// Creates a new [`Checkbox`]. - fn new(id: impl Into, checked: ToggleState, render_cx: RenderContext) -> Self { - Self { - id: id.into(), - toggle_state: checked, - disabled: false, - on_click: None, - filled: false, - style: ui::ToggleStyle::default(), - tooltip: None, - label: None, - placeholder: false, - base_rem: render_cx.scaled_rems(1.0), - } - } - - /// Binds a handler to the [`Checkbox`] that will be called when clicked. - fn on_click(mut self, handler: impl Fn(&ToggleState, &mut Window, &mut App) + 'static) -> Self { - self.on_click = Some(Box::new(handler)); - self - } - - fn bg_color(&self, cx: &App) -> Hsla { - let style = self.style.clone(); - match (style, self.filled) { - (ui::ToggleStyle::Ghost, false) => cx.theme().colors().ghost_element_background, - (ui::ToggleStyle::Ghost, true) => cx.theme().colors().element_background, - (ui::ToggleStyle::ElevationBased(_), false) => gpui::transparent_black(), - (ui::ToggleStyle::ElevationBased(elevation), true) => elevation.darker_bg(cx), - (ui::ToggleStyle::Custom(_), false) => gpui::transparent_black(), - (ui::ToggleStyle::Custom(color), true) => color.opacity(0.2), - } - } - - fn border_color(&self, cx: &App) -> Hsla { - if self.disabled { - return cx.theme().colors().border_variant; - } - - match self.style.clone() { - ui::ToggleStyle::Ghost => cx.theme().colors().border, - ui::ToggleStyle::ElevationBased(_) => cx.theme().colors().border, - ui::ToggleStyle::Custom(color) => color.opacity(0.3), - } - } -} - -impl gpui::RenderOnce for MarkdownCheckbox { - fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { - let group_id = format!("checkbox_group_{:?}", self.id); - let color = if self.disabled { - Color::Disabled - } else { - Color::Selected - }; - let icon_size_small = IconSize::Custom(self.base_rem.mul(14. / 16.)); // was IconSize::Small - let icon = match self.toggle_state { - ToggleState::Selected => { - if self.placeholder { - None - } else { - Some( - ui::Icon::new(IconName::Check) - .size(icon_size_small) - .color(color), - ) - } - } - ToggleState::Indeterminate => Some( - ui::Icon::new(IconName::Dash) - .size(icon_size_small) - .color(color), - ), - ToggleState::Unselected => None, - }; - - let bg_color = self.bg_color(cx); - let border_color = self.border_color(cx); - let hover_border_color = border_color.alpha(0.7); - - let size = self.base_rem.mul(1.25); // was Self::container_size(); (20px) - - let checkbox = h_flex() - .id(self.id.clone()) - .justify_center() - .items_center() - .size(size) - .group(group_id.clone()) - .child( - div() - .flex() - .flex_none() - .justify_center() - .items_center() - .m(self.base_rem.mul(0.25)) // was .m_1 - .size(self.base_rem.mul(1.0)) // was .size_4 - .rounded(self.base_rem.mul(0.125)) // was .rounded_xs - .border_1() - .bg(bg_color) - .border_color(border_color) - .when(self.disabled, |this| this.cursor_not_allowed()) - .when(self.disabled, |this| { - this.bg(cx.theme().colors().element_disabled.opacity(0.6)) - }) - .when(!self.disabled, |this| { - this.group_hover(group_id.clone(), |el| el.border_color(hover_border_color)) - }) - .when(self.placeholder, |this| { - this.child( - div() - .flex_none() - .rounded_full() - .bg(color.color(cx).alpha(0.5)) - .size(self.base_rem.mul(0.25)), // was .size_1 - ) - }) - .children(icon), - ); - - h_flex() - .id(self.id) - .gap(ui::DynamicSpacing::Base06.rems(cx)) - .child(checkbox) - .when_some( - self.on_click.filter(|_| !self.disabled), - |this, on_click| { - this.on_click(move |_, window, cx| { - on_click(&self.toggle_state.inverse(), window, cx) - }) - }, - ) - // TODO: Allow label size to be different from default. - // TODO: Allow label color to be different from muted. - .when_some(self.label, |this, label| { - this.child(Label::new(label).color(Color::Muted)) - }) - .when_some(self.tooltip, |this, tooltip| { - this.tooltip(move |window, cx| tooltip(window, cx)) - }) - } -} - -fn calculate_table_columns_count(rows: &Vec) -> usize { - let mut actual_column_count = 0; - for row in rows { - actual_column_count = actual_column_count.max( - row.columns - .iter() - .map(|column| column.col_span) - .sum::(), - ); - } - actual_column_count -} - -fn render_markdown_table(parsed: &ParsedMarkdownTable, cx: &mut RenderContext) -> AnyElement { - let actual_header_column_count = calculate_table_columns_count(&parsed.header); - let actual_body_column_count = calculate_table_columns_count(&parsed.body); - let max_column_count = std::cmp::max(actual_header_column_count, actual_body_column_count); - - let total_rows = parsed.header.len() + parsed.body.len(); - - // Track which grid cells are occupied by spanning cells - let mut grid_occupied = vec![vec![false; max_column_count]; total_rows]; - - let mut cells = Vec::with_capacity(total_rows * max_column_count); - - for (row_idx, row) in parsed.header.iter().chain(parsed.body.iter()).enumerate() { - let mut col_idx = 0; - - for cell in row.columns.iter() { - // Skip columns occupied by row-spanning cells from previous rows - while col_idx < max_column_count && grid_occupied[row_idx][col_idx] { - col_idx += 1; - } - - if col_idx >= max_column_count { - break; - } - - let container = match cell.alignment { - ParsedMarkdownTableAlignment::Left | ParsedMarkdownTableAlignment::None => div(), - ParsedMarkdownTableAlignment::Center => v_flex().items_center(), - ParsedMarkdownTableAlignment::Right => v_flex().items_end(), - }; - - let cell_element = container - .col_span(cell.col_span.min(max_column_count - col_idx) as u16) - .row_span(cell.row_span.min(total_rows - row_idx) as u16) - .children(render_markdown_text(&cell.children, cx)) - .px_2() - .py_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) - }) - .when(cell.row_span > 1, |this| this.justify_center()) - .when(row_idx % 2 == 1, |this| this.bg(cx.panel_background_color)); - - cells.push(cell_element); - - // Mark grid positions as occupied for row-spanning cells - for r in 0..cell.row_span { - for c in 0..cell.col_span { - if row_idx + r < total_rows && col_idx + c < max_column_count { - grid_occupied[row_idx + r][col_idx + c] = true; - } - } - } - - col_idx += cell.col_span; - } - - // Fill remaining columns with empty cells if needed - while col_idx < max_column_count { - if grid_occupied[row_idx][col_idx] { - col_idx += 1; - continue; - } - - let empty_cell = div() - .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)); - - cells.push(empty_cell); - col_idx += 1; - } - } - - cx.with_common_p(v_flex().items_start()) - .when_some(parsed.caption.as_ref(), |this, caption| { - this.children(render_markdown_text(caption, cx)) - }) - .child( - div() - .rounded_sm() - .overflow_hidden() - .border_1() - .border_color(cx.border_color) - .min_w_0() - .grid() - .grid_cols_max_content(max_column_count as u16) - .children(cells), - ) - .into_any() -} - -fn render_markdown_block_quote( - parsed: &ParsedMarkdownBlockQuote, - cx: &mut RenderContext, -) -> AnyElement { - cx.indent += 1; - - let children: Vec = parsed - .children - .iter() - .enumerate() - .map(|(ix, child)| { - cx.with_last_child(ix + 1 == parsed.children.len(), |cx| { - render_markdown_block(child, cx) - }) - }) - .collect(); - - cx.indent -= 1; - - cx.with_common_p(div()) - .child( - div() - .border_l_4() - .border_color(cx.border_color) - .pl_3() - .children(children), - ) - .into_any() -} - -fn render_markdown_code_block( - parsed: &ParsedMarkdownCodeBlock, - cx: &mut RenderContext, -) -> AnyElement { - let body = if let Some(highlights) = parsed.highlights.as_ref() { - StyledText::new(parsed.contents.clone()).with_default_highlights( - &cx.buffer_text_style, - highlights.iter().filter_map(|(range, highlight_id)| { - highlight_id - .style(cx.syntax_theme.as_ref()) - .map(|style| (range.clone(), style)) - }), - ) - } else { - StyledText::new(parsed.contents.clone()) - }; - - let copy_block_button = CopyButton::new("copy-codeblock", parsed.contents.clone()) - .tooltip_label("Copy Codeblock") - .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(font) - .px_3() - .py_3() - .bg(cx.code_block_background_color) - .rounded_sm() - .child(body) - .child( - div() - .h_flex() - .absolute() - .right_1() - .top_1() - .child(copy_block_button), - ) - .into_any() -} - -fn render_mermaid_diagram( - parsed: &ParsedMarkdownMermaidDiagram, - cx: &mut RenderContext, -) -> AnyElement { - let cached = cx.mermaid_state.cache.get(&parsed.contents); - - if let Some(result) = cached.and_then(|c| c.render_image.get()) { - match result { - Ok(render_image) => cx - .with_common_p(div()) - .px_3() - .py_3() - .bg(cx.code_block_background_color) - .rounded_sm() - .child( - div().w_full().child( - img(ImageSource::Render(render_image.clone())) - .max_w_full() - .with_fallback(|| { - div() - .child(Label::new("Failed to load mermaid diagram")) - .into_any_element() - }), - ), - ) - .into_any(), - Err(_) => cx - .with_common_p(div()) - .px_3() - .py_3() - .bg(cx.code_block_background_color) - .rounded_sm() - .child(StyledText::new(parsed.contents.contents.clone())) - .into_any(), - } - } else if let Some(fallback) = cached.and_then(|c| c.fallback_image.as_ref()) { - cx.with_common_p(div()) - .px_3() - .py_3() - .bg(cx.code_block_background_color) - .rounded_sm() - .child( - div() - .w_full() - .child( - img(ImageSource::Render(fallback.clone())) - .max_w_full() - .with_fallback(|| { - div() - .child(Label::new("Failed to load mermaid diagram")) - .into_any_element() - }), - ) - .with_animation( - "mermaid-fallback-pulse", - Animation::new(Duration::from_secs(2)) - .repeat() - .with_easing(pulsating_between(0.6, 1.0)), - |el, delta| el.opacity(delta), - ), - ) - .into_any() - } else { - cx.with_common_p(div()) - .px_3() - .py_3() - .bg(cx.code_block_background_color) - .rounded_sm() - .child( - Label::new("Rendering mermaid diagram...") - .color(Color::Muted) - .with_animation( - "mermaid-loading-pulse", - Animation::new(Duration::from_secs(2)) - .repeat() - .with_easing(pulsating_between(0.4, 0.8)), - |label, delta| label.alpha(delta), - ), - ) - .into_any() - } -} - -fn render_markdown_paragraph(parsed: &MarkdownParagraph, cx: &mut RenderContext) -> AnyElement { - cx.with_common_p(div()) - .children(render_markdown_text(parsed, cx)) - .flex() - .flex_col() - .into_any_element() -} - -fn render_markdown_text(parsed_new: &MarkdownParagraph, cx: &mut RenderContext) -> Vec { - let mut any_element = Vec::with_capacity(parsed_new.len()); - // these values are cloned in-order satisfy borrow checker - let syntax_theme = cx.syntax_theme.clone(); - let workspace_clone = cx.workspace.clone(); - let code_span_bg_color = cx.code_span_background_color; - let text_style = cx.text_style.clone(); - let link_color = cx.link_color; - - for parsed_region in parsed_new { - match parsed_region { - MarkdownParagraphChunk::Text(parsed) => { - let trimmed = parsed.contents.trim(); - if trimmed == "[x]" || trimmed == "[X]" || trimmed == "[ ]" { - let checked = trimmed != "[ ]"; - let element = div() - .child(MarkdownCheckbox::new( - cx.next_id(&parsed.source_range), - if checked { - ToggleState::Selected - } else { - ToggleState::Unselected - }, - cx.clone(), - )) - .into_any(); - any_element.push(element); - continue; - } - - let element_id = cx.next_id(&parsed.source_range); - - let highlights = gpui::combine_highlights( - parsed.highlights.iter().filter_map(|(range, highlight)| { - highlight - .to_highlight_style(&syntax_theme) - .map(|style| (range.clone(), style)) - }), - parsed.regions.iter().filter_map(|(range, region)| { - if region.code { - Some(( - range.clone(), - HighlightStyle { - background_color: Some(code_span_bg_color), - ..Default::default() - }, - )) - } else if region.link.is_some() { - Some(( - range.clone(), - HighlightStyle { - color: Some(link_color), - ..Default::default() - }, - )) - } else { - None - } - }), - ); - let mut links = Vec::new(); - let mut link_ranges = Vec::new(); - for (range, region) in parsed.regions.iter() { - if let Some(link) = region.link.clone() { - links.push(link); - link_ranges.push(range.clone()); - } - } - let workspace = workspace_clone.clone(); - let element = div() - .child( - InteractiveText::new( - element_id, - StyledText::new(parsed.contents.clone()) - .with_default_highlights(&text_style, highlights), - ) - .tooltip({ - let links = links.clone(); - let link_ranges = link_ranges.clone(); - move |idx, _, cx| { - for (ix, range) in link_ranges.iter().enumerate() { - if range.contains(&idx) { - return Some(LinkPreview::new(&links[ix].to_string(), cx)); - } - } - None - } - }) - .on_click( - link_ranges, - move |clicked_range_ix, window, cx| match &links[clicked_range_ix] { - Link::Web { url } => cx.open_url(url), - Link::Path { path, .. } => { - if let Some(workspace) = &workspace { - _ = workspace.update(cx, |workspace, cx| { - workspace - .open_abs_path( - normalize_path(path.clone().as_path()), - OpenOptions { - visible: Some(OpenVisible::None), - ..Default::default() - }, - window, - cx, - ) - .detach(); - }); - } - } - }, - ), - ) - .into_any(); - any_element.push(element); - } - - MarkdownParagraphChunk::Image(image) => { - any_element.push(render_markdown_image(image, cx)); - } - } - } - - any_element -} - -fn render_markdown_rule(cx: &mut RenderContext) -> AnyElement { - let rule = div().w_full().h(cx.scaled_rems(0.125)).bg(cx.border_color); - div().py(cx.scaled_rems(0.5)).child(rule).into_any() -} - -fn render_markdown_image(image: &Image, cx: &mut RenderContext) -> AnyElement { - let image_resource = match image.link.clone() { - Link::Web { url } => Resource::Uri(url.into()), - Link::Path { path, .. } => Resource::Path(Arc::from(path)), - }; - - let element_id = cx.next_id(&image.source_range); - let workspace = cx.workspace.clone(); - - div() - .id(element_id) - .cursor_pointer() - .child( - img(ImageSource::Resource(image_resource)) - .max_w_full() - .with_fallback({ - let alt_text = image.alt_text.clone(); - move || div().children(alt_text.clone()).into_any_element() - }) - .when_some(image.height, |this, height| this.h(height)) - .when_some(image.width, |this, width| this.w(width)), - ) - .tooltip({ - let link = image.link.clone(); - let alt_text = image.alt_text.clone(); - move |_, cx| { - InteractiveMarkdownElementTooltip::new( - Some(alt_text.clone().unwrap_or(link.to_string().into())), - "open image", - cx, - ) - .into() - } - }) - .on_click({ - let link = image.link.clone(); - move |_, window, cx| { - if window.modifiers().secondary() { - match &link { - Link::Web { url } => cx.open_url(url), - Link::Path { path, .. } => { - if let Some(workspace) = &workspace { - _ = workspace.update(cx, |workspace, cx| { - workspace - .open_abs_path( - path.clone(), - OpenOptions { - visible: Some(OpenVisible::None), - ..Default::default() - }, - window, - cx, - ) - .detach(); - }); - } - } - } - } - } - }) - .into_any() -} - -struct InteractiveMarkdownElementTooltip { - tooltip_text: Option, - action_text: SharedString, -} - -impl InteractiveMarkdownElementTooltip { - pub fn new( - tooltip_text: Option, - action_text: impl Into, - cx: &mut App, - ) -> Entity { - let tooltip_text = tooltip_text.map(|t| util::truncate_and_trailoff(&t, 50).into()); - - cx.new(|_cx| Self { - tooltip_text, - action_text: action_text.into(), - }) - } -} - -impl Render for InteractiveMarkdownElementTooltip { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - tooltip_container(cx, |el, _| { - let secondary_modifier = Keystroke { - modifiers: Modifiers::secondary_key(), - ..Default::default() - }; - - el.child( - v_flex() - .gap_1() - .when_some(self.tooltip_text.clone(), |this, text| { - this.child(Label::new(text).size(LabelSize::Small)) - }) - .child( - Label::new(format!( - "{}-click to {}", - secondary_modifier, self.action_text - )) - .size(LabelSize::Small) - .color(Color::Muted), - ), - ) - }) - } -} - -/// Returns the prefix for a list item. -fn list_item_prefix(order: usize, ordered: bool, depth: usize) -> String { - let ix = order.saturating_sub(1); - const NUMBERED_PREFIXES_1: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; - const NUMBERED_PREFIXES_2: &str = "abcdefghijklmnopqrstuvwxyz"; - const BULLETS: [&str; 5] = ["•", "◦", "▪", "‣", "⁃"]; - - if ordered { - match depth { - 0 => format!("{}. ", order), - 1 => format!( - "{}. ", - NUMBERED_PREFIXES_1 - .chars() - .nth(ix % NUMBERED_PREFIXES_1.len()) - .unwrap() - ), - _ => format!( - "{}. ", - NUMBERED_PREFIXES_2 - .chars() - .nth(ix % NUMBERED_PREFIXES_2.len()) - .unwrap() - ), - } - } else { - let depth = depth.min(BULLETS.len() - 1); - let bullet = BULLETS[depth]; - return format!("{} ", bullet); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::markdown_elements::ParsedMarkdownMermaidDiagramContents; - use crate::markdown_elements::ParsedMarkdownTableColumn; - use crate::markdown_elements::ParsedMarkdownText; - - fn text(text: &str) -> MarkdownParagraphChunk { - MarkdownParagraphChunk::Text(ParsedMarkdownText { - source_range: 0..text.len(), - contents: SharedString::new(text), - highlights: Default::default(), - regions: Default::default(), - }) - } - - fn column( - col_span: usize, - row_span: usize, - children: Vec, - ) -> ParsedMarkdownTableColumn { - ParsedMarkdownTableColumn { - col_span, - row_span, - is_header: false, - children, - alignment: ParsedMarkdownTableAlignment::None, - } - } - - fn column_with_row_span( - col_span: usize, - row_span: usize, - children: Vec, - ) -> ParsedMarkdownTableColumn { - ParsedMarkdownTableColumn { - col_span, - row_span, - is_header: false, - children, - alignment: ParsedMarkdownTableAlignment::None, - } - } - - #[test] - fn test_calculate_table_columns_count() { - assert_eq!(0, calculate_table_columns_count(&vec![])); - - assert_eq!( - 1, - calculate_table_columns_count(&vec![ParsedMarkdownTableRow::with_columns(vec![ - column(1, 1, vec![text("column1")]) - ])]) - ); - - assert_eq!( - 2, - calculate_table_columns_count(&vec![ParsedMarkdownTableRow::with_columns(vec![ - column(1, 1, vec![text("column1")]), - column(1, 1, vec![text("column2")]), - ])]) - ); - - assert_eq!( - 2, - calculate_table_columns_count(&vec![ParsedMarkdownTableRow::with_columns(vec![ - column(2, 1, vec![text("column1")]) - ])]) - ); - - assert_eq!( - 3, - calculate_table_columns_count(&vec![ParsedMarkdownTableRow::with_columns(vec![ - column(1, 1, vec![text("column1")]), - column(2, 1, vec![text("column2")]), - ])]) - ); - - assert_eq!( - 2, - calculate_table_columns_count(&vec![ - ParsedMarkdownTableRow::with_columns(vec![ - column(1, 1, vec![text("column1")]), - column(1, 1, vec![text("column2")]), - ]), - ParsedMarkdownTableRow::with_columns(vec![column(1, 1, vec![text("column1")]),]) - ]) - ); - - assert_eq!( - 3, - calculate_table_columns_count(&vec![ - ParsedMarkdownTableRow::with_columns(vec![ - column(1, 1, vec![text("column1")]), - column(1, 1, vec![text("column2")]), - ]), - ParsedMarkdownTableRow::with_columns(vec![column(3, 3, vec![text("column1")]),]) - ]) - ); - } - - #[test] - fn test_row_span_support() { - assert_eq!( - 3, - calculate_table_columns_count(&vec![ - ParsedMarkdownTableRow::with_columns(vec![ - column_with_row_span(1, 2, vec![text("spans 2 rows")]), - column(1, 1, vec![text("column2")]), - column(1, 1, vec![text("column3")]), - ]), - ParsedMarkdownTableRow::with_columns(vec![ - // First column is covered by row span from above - column(1, 1, vec![text("column2 row2")]), - column(1, 1, vec![text("column3 row2")]), - ]) - ]) - ); - - assert_eq!( - 4, - calculate_table_columns_count(&vec![ - ParsedMarkdownTableRow::with_columns(vec![ - column_with_row_span(1, 3, vec![text("spans 3 rows")]), - column_with_row_span(2, 1, vec![text("spans 2 cols")]), - column(1, 1, vec![text("column4")]), - ]), - ParsedMarkdownTableRow::with_columns(vec![ - // First column covered by row span - column(1, 1, vec![text("column2")]), - column(1, 1, vec![text("column3")]), - column(1, 1, vec![text("column4")]), - ]), - ParsedMarkdownTableRow::with_columns(vec![ - // First column still covered by row span - column(3, 1, vec![text("spans 3 cols")]), - ]) - ]) - ); - } - - #[test] - fn test_list_item_prefix() { - assert_eq!(list_item_prefix(1, true, 0), "1. "); - assert_eq!(list_item_prefix(2, true, 0), "2. "); - assert_eq!(list_item_prefix(3, true, 0), "3. "); - assert_eq!(list_item_prefix(11, true, 0), "11. "); - assert_eq!(list_item_prefix(1, true, 1), "A. "); - assert_eq!(list_item_prefix(2, true, 1), "B. "); - assert_eq!(list_item_prefix(3, true, 1), "C. "); - assert_eq!(list_item_prefix(1, true, 2), "a. "); - assert_eq!(list_item_prefix(2, true, 2), "b. "); - assert_eq!(list_item_prefix(7, true, 2), "g. "); - assert_eq!(list_item_prefix(1, true, 1), "A. "); - assert_eq!(list_item_prefix(1, true, 2), "a. "); - assert_eq!(list_item_prefix(1, false, 0), "• "); - assert_eq!(list_item_prefix(1, false, 1), "◦ "); - assert_eq!(list_item_prefix(1, false, 2), "▪ "); - assert_eq!(list_item_prefix(1, false, 3), "‣ "); - assert_eq!(list_item_prefix(1, false, 4), "⁃ "); - } - - fn mermaid_contents(s: &str) -> ParsedMarkdownMermaidDiagramContents { - ParsedMarkdownMermaidDiagramContents { - contents: SharedString::from(s.to_string()), - scale: 1, - } - } - - fn mermaid_sequence(diagrams: &[&str]) -> Vec { - diagrams - .iter() - .map(|diagram| mermaid_contents(diagram)) - .collect() - } - - fn mermaid_fallback( - new_diagram: &str, - new_full_order: &[ParsedMarkdownMermaidDiagramContents], - old_full_order: &[ParsedMarkdownMermaidDiagramContents], - cache: &MermaidDiagramCache, - ) -> Option> { - let new_content = mermaid_contents(new_diagram); - let idx = new_full_order - .iter() - .position(|content| content == &new_content)?; - MermaidState::get_fallback_image(idx, old_full_order, new_full_order.len(), cache) - } - - fn mock_render_image() -> Arc { - Arc::new(RenderImage::new(Vec::new())) - } - - #[test] - fn test_mermaid_fallback_on_edit() { - let old_full_order = mermaid_sequence(&["graph A", "graph B", "graph C"]); - let new_full_order = mermaid_sequence(&["graph A", "graph B modified", "graph C"]); - - let svg_b = mock_render_image(); - let mut cache: MermaidDiagramCache = HashMap::default(); - cache.insert( - mermaid_contents("graph A"), - CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None), - ); - cache.insert( - mermaid_contents("graph B"), - CachedMermaidDiagram::new_for_test(Some(svg_b.clone()), None), - ); - cache.insert( - mermaid_contents("graph C"), - CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None), - ); - - let fallback = - mermaid_fallback("graph B modified", &new_full_order, &old_full_order, &cache); - - assert!( - fallback.is_some(), - "Should use old diagram as fallback when editing" - ); - assert!( - Arc::ptr_eq(&fallback.unwrap(), &svg_b), - "Fallback should be the old diagram's SVG" - ); - } - - #[test] - fn test_mermaid_no_fallback_on_add_in_middle() { - let old_full_order = mermaid_sequence(&["graph A", "graph C"]); - let new_full_order = mermaid_sequence(&["graph A", "graph NEW", "graph C"]); - - let mut cache: MermaidDiagramCache = HashMap::default(); - cache.insert( - mermaid_contents("graph A"), - CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None), - ); - cache.insert( - mermaid_contents("graph C"), - CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None), - ); - - let fallback = mermaid_fallback("graph NEW", &new_full_order, &old_full_order, &cache); - - assert!( - fallback.is_none(), - "Should NOT use fallback when adding new diagram" - ); - } - - #[test] - fn test_mermaid_fallback_chains_on_rapid_edits() { - let old_full_order = mermaid_sequence(&["graph A", "graph B modified", "graph C"]); - let new_full_order = mermaid_sequence(&["graph A", "graph B modified again", "graph C"]); - - let original_svg = mock_render_image(); - let mut cache: MermaidDiagramCache = HashMap::default(); - cache.insert( - mermaid_contents("graph A"), - CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None), - ); - cache.insert( - mermaid_contents("graph B modified"), - // Still rendering, but has fallback from original "graph B" - CachedMermaidDiagram::new_for_test(None, Some(original_svg.clone())), - ); - cache.insert( - mermaid_contents("graph C"), - CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None), - ); - - let fallback = mermaid_fallback( - "graph B modified again", - &new_full_order, - &old_full_order, - &cache, - ); - - assert!( - fallback.is_some(), - "Should chain fallback when previous render not complete" - ); - assert!( - Arc::ptr_eq(&fallback.unwrap(), &original_svg), - "Fallback should chain through to the original SVG" - ); - } - - #[test] - fn test_mermaid_no_fallback_when_no_old_diagram_at_index() { - let old_full_order = mermaid_sequence(&["graph A"]); - let new_full_order = mermaid_sequence(&["graph A", "graph B"]); - - let mut cache: MermaidDiagramCache = HashMap::default(); - cache.insert( - mermaid_contents("graph A"), - CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None), - ); - - let fallback = mermaid_fallback("graph B", &new_full_order, &old_full_order, &cache); - - assert!( - fallback.is_none(), - "Should NOT have fallback when adding diagram at end" - ); - } - - #[test] - fn test_mermaid_fallback_with_duplicate_blocks_edit_first() { - let old_full_order = mermaid_sequence(&["graph A", "graph A", "graph B"]); - let new_full_order = mermaid_sequence(&["graph A edited", "graph A", "graph B"]); - - let svg_a = mock_render_image(); - let mut cache: MermaidDiagramCache = HashMap::default(); - cache.insert( - mermaid_contents("graph A"), - CachedMermaidDiagram::new_for_test(Some(svg_a.clone()), None), - ); - cache.insert( - mermaid_contents("graph B"), - CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None), - ); - - let fallback = mermaid_fallback("graph A edited", &new_full_order, &old_full_order, &cache); - - assert!( - fallback.is_some(), - "Should use old diagram as fallback when editing one of duplicate blocks" - ); - assert!( - Arc::ptr_eq(&fallback.unwrap(), &svg_a), - "Fallback should be the old duplicate diagram's image" - ); - } - - #[test] - fn test_mermaid_fallback_with_duplicate_blocks_edit_second() { - let old_full_order = mermaid_sequence(&["graph A", "graph A", "graph B"]); - let new_full_order = mermaid_sequence(&["graph A", "graph A edited", "graph B"]); - - let svg_a = mock_render_image(); - let mut cache: MermaidDiagramCache = HashMap::default(); - cache.insert( - mermaid_contents("graph A"), - CachedMermaidDiagram::new_for_test(Some(svg_a.clone()), None), - ); - cache.insert( - mermaid_contents("graph B"), - CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None), - ); - - let fallback = mermaid_fallback("graph A edited", &new_full_order, &old_full_order, &cache); - - assert!( - fallback.is_some(), - "Should use old diagram as fallback when editing the second duplicate block" - ); - assert!( - Arc::ptr_eq(&fallback.unwrap(), &svg_a), - "Fallback should be the old duplicate diagram's image" - ); - } -} diff --git a/crates/migrator/src/migrations.rs b/crates/migrator/src/migrations.rs index ec33b6a53b3c598842aa29b6e2c31c08c7b11558..d554ee1dd887d6048f55a584ed2534db944b3c08 100644 --- a/crates/migrator/src/migrations.rs +++ b/crates/migrator/src/migrations.rs @@ -304,3 +304,15 @@ pub(crate) mod m_2026_02_25 { pub(crate) use settings::migrate_builtin_agent_servers_to_registry; } + +pub(crate) mod m_2026_03_16 { + mod settings; + + pub(crate) use settings::SETTINGS_PATTERNS; +} + +pub(crate) mod m_2026_03_23 { + mod keymap; + + pub(crate) use keymap::KEYMAP_PATTERNS; +} diff --git a/crates/migrator/src/migrations/m_2026_03_16/settings.rs b/crates/migrator/src/migrations/m_2026_03_16/settings.rs new file mode 100644 index 0000000000000000000000000000000000000000..203d29df904d110d8c7b5ffdb257bb28d3eee601 --- /dev/null +++ b/crates/migrator/src/migrations/m_2026_03_16/settings.rs @@ -0,0 +1,50 @@ +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_heex_settings)]; + +fn rename_heex_settings( + contents: &str, + mat: &QueryMatch, + query: &Query, +) -> Option<(Range, String)> { + if !is_heex_settings(contents, mat, query) { + return None; + } + + let setting_name_ix = query.capture_index_for_name("setting_name")?; + let setting_name_range = mat + .nodes_for_capture_index(setting_name_ix) + .next()? + .byte_range(); + + Some((setting_name_range, "HEEx".to_string())) +} + +fn is_heex_settings(contents: &str, mat: &QueryMatch, query: &Query) -> bool { + let parent_key_ix = match query.capture_index_for_name("parent_key") { + Some(ix) => ix, + None => return false, + }; + let parent_range = match mat.nodes_for_capture_index(parent_key_ix).next() { + Some(node) => node.byte_range(), + None => return false, + }; + if contents.get(parent_range) != Some("languages") { + return false; + } + + 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("HEEX") +} diff --git a/crates/migrator/src/migrations/m_2026_03_23/keymap.rs b/crates/migrator/src/migrations/m_2026_03_23/keymap.rs new file mode 100644 index 0000000000000000000000000000000000000000..8fadc8201a0d2846e66ddf0de80275732d701acd --- /dev/null +++ b/crates/migrator/src/migrations/m_2026_03_23/keymap.rs @@ -0,0 +1,47 @@ +use std::ops::Range; + +use tree_sitter::{Query, QueryMatch}; + +use crate::MigrationPatterns; + +pub const KEYMAP_PATTERNS: MigrationPatterns = + &[(crate::patterns::KEYMAP_CONTEXT_PATTERN, rename_context_key)]; + +fn rename_context_key( + contents: &str, + mat: &QueryMatch, + query: &Query, +) -> Option<(Range, String)> { + let context_predicate_ix = query.capture_index_for_name("context_predicate")?; + let context_predicate_range = mat + .nodes_for_capture_index(context_predicate_ix) + .next()? + .byte_range(); + let old_predicate = contents.get(context_predicate_range.clone())?.to_string(); + let mut new_predicate = old_predicate.clone(); + + const REPLACEMENTS: &[(&str, &str)] = &[ + ( + "edit_prediction_conflict && !showing_completions", + "(edit_prediction && in_leading_whitespace)", + ), + ( + "edit_prediction_conflict && showing_completions", + "(edit_prediction && showing_completions)", + ), + ( + "edit_prediction_conflict", + "(edit_prediction && (showing_completions || in_leading_whitespace))", + ), + ]; + + for (old, new) in REPLACEMENTS { + new_predicate = new_predicate.replace(old, new); + } + + if new_predicate != old_predicate { + Some((context_predicate_range, new_predicate)) + } else { + None + } +} diff --git a/crates/migrator/src/migrator.rs b/crates/migrator/src/migrator.rs index f208faf163aaf425127791f781d4569a737870ff..ceb6ec2e0e35f0dd3bbd23174637bba00baab6b3 100644 --- a/crates/migrator/src/migrator.rs +++ b/crates/migrator/src/migrator.rs @@ -143,6 +143,10 @@ pub fn migrate_keymap(text: &str) -> Result> { migrations::m_2025_12_08::KEYMAP_PATTERNS, &KEYMAP_QUERY_2025_12_08, ), + MigrationType::TreeSitter( + migrations::m_2026_03_23::KEYMAP_PATTERNS, + &KEYMAP_QUERY_2026_03_23, + ), ]; run_migrations(text, migrations) } @@ -239,6 +243,10 @@ pub fn migrate_settings(text: &str) -> Result> { MigrationType::Json(migrations::m_2026_02_03::migrate_experimental_sweep_mercury), MigrationType::Json(migrations::m_2026_02_04::migrate_tool_permission_defaults), MigrationType::Json(migrations::m_2026_02_25::migrate_builtin_agent_servers_to_registry), + MigrationType::TreeSitter( + migrations::m_2026_03_16::SETTINGS_PATTERNS, + &SETTINGS_QUERY_2026_03_16, + ), ]; run_migrations(text, migrations) } @@ -373,6 +381,14 @@ define_query!( SETTINGS_QUERY_2025_12_15, migrations::m_2025_12_15::SETTINGS_PATTERNS ); +define_query!( + SETTINGS_QUERY_2026_03_16, + migrations::m_2026_03_16::SETTINGS_PATTERNS +); +define_query!( + KEYMAP_QUERY_2026_03_23, + migrations::m_2026_03_23::KEYMAP_PATTERNS +); // custom query static EDIT_PREDICTION_SETTINGS_MIGRATION_QUERY: LazyLock = LazyLock::new(|| { @@ -400,6 +416,7 @@ mod tests { } } + #[track_caller] fn assert_migrate_keymap(input: &str, output: Option<&str>) { let migrated = migrate_keymap(input).unwrap(); pretty_assertions::assert_eq!(migrated.as_deref(), output); @@ -418,7 +435,7 @@ mod tests { } #[track_caller] - fn assert_migrate_settings_with_migrations( + fn assert_migrate_with_migrations( migrations: &[MigrationType], input: &str, output: Option<&str>, @@ -966,7 +983,7 @@ mod tests { #[test] fn test_mcp_settings_migration() { - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::TreeSitter( migrations::m_2025_06_16::SETTINGS_PATTERNS, &SETTINGS_QUERY_2025_06_16, @@ -1155,7 +1172,7 @@ mod tests { } } }"#; - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::TreeSitter( migrations::m_2025_06_16::SETTINGS_PATTERNS, &SETTINGS_QUERY_2025_06_16, @@ -1167,7 +1184,7 @@ mod tests { #[test] fn test_custom_agent_server_settings_migration() { - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::TreeSitter( migrations::m_2025_11_20::SETTINGS_PATTERNS, &SETTINGS_QUERY_2025_11_20, @@ -1383,7 +1400,7 @@ mod tests { #[test] fn test_flatten_code_action_formatters_basic_array() { - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2025_10_01::flatten_code_actions_formatters, )], @@ -1417,7 +1434,7 @@ mod tests { #[test] fn test_flatten_code_action_formatters_basic_object() { - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2025_10_01::flatten_code_actions_formatters, )], @@ -1574,7 +1591,7 @@ mod tests { #[test] fn test_flatten_code_action_formatters_array_with_multiple_action_blocks_in_defaults_and_multiple_languages() { - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2025_10_01::flatten_code_actions_formatters, )], @@ -1700,7 +1717,7 @@ mod tests { #[test] fn test_flatten_code_action_formatters_array_with_format_on_save_and_multiple_languages() { - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2025_10_01::flatten_code_actions_formatters, )], @@ -1887,7 +1904,7 @@ mod tests { #[test] fn test_format_on_save_formatter_migration_basic() { - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2025_10_02::remove_formatters_on_save, )], @@ -1907,7 +1924,7 @@ mod tests { #[test] fn test_format_on_save_formatter_migration_array() { - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2025_10_02::remove_formatters_on_save, )], @@ -1932,7 +1949,7 @@ mod tests { #[test] fn test_format_on_save_on_off_unchanged() { - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2025_10_02::remove_formatters_on_save, )], @@ -1943,7 +1960,7 @@ mod tests { None, ); - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2025_10_02::remove_formatters_on_save, )], @@ -1957,7 +1974,7 @@ mod tests { #[test] fn test_format_on_save_formatter_migration_in_languages() { - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2025_10_02::remove_formatters_on_save, )], @@ -1995,7 +2012,7 @@ mod tests { #[test] fn test_format_on_save_formatter_migration_mixed_global_and_languages() { - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2025_10_02::remove_formatters_on_save, )], @@ -2032,7 +2049,7 @@ mod tests { #[test] fn test_format_on_save_no_migration_when_no_format_on_save() { - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2025_10_02::remove_formatters_on_save, )], @@ -2046,7 +2063,7 @@ mod tests { #[test] fn test_restore_code_actions_on_format() { - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2025_10_16::restore_code_actions_on_format, )], @@ -2067,7 +2084,7 @@ mod tests { ), ); - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2025_10_16::restore_code_actions_on_format, )], @@ -2081,7 +2098,7 @@ mod tests { None, ); - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2025_10_16::restore_code_actions_on_format, )], @@ -2108,7 +2125,7 @@ mod tests { ), ); - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2025_10_16::restore_code_actions_on_format, )], @@ -2137,7 +2154,7 @@ mod tests { ), ); - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2025_10_16::restore_code_actions_on_format, )], @@ -2155,7 +2172,7 @@ mod tests { #[test] fn test_make_file_finder_include_ignored_an_enum() { - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2025_10_17::make_file_finder_include_ignored_an_enum, )], @@ -2163,7 +2180,7 @@ mod tests { None, ); - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2025_10_17::make_file_finder_include_ignored_an_enum, )], @@ -2183,7 +2200,7 @@ mod tests { ), ); - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2025_10_17::make_file_finder_include_ignored_an_enum, )], @@ -2203,7 +2220,7 @@ mod tests { ), ); - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2025_10_17::make_file_finder_include_ignored_an_enum, )], @@ -2224,7 +2241,7 @@ mod tests { ); // Platform key: settings nested inside "linux" should be migrated - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2025_10_17::make_file_finder_include_ignored_an_enum, )], @@ -2253,7 +2270,7 @@ mod tests { ); // Profile: settings nested inside profiles should be migrated - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2025_10_17::make_file_finder_include_ignored_an_enum, )], @@ -2288,7 +2305,7 @@ mod tests { #[test] fn test_make_relative_line_numbers_an_enum() { - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2025_10_21::make_relative_line_numbers_an_enum, )], @@ -2296,7 +2313,7 @@ mod tests { None, ); - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2025_10_21::make_relative_line_numbers_an_enum, )], @@ -2312,7 +2329,7 @@ mod tests { ), ); - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2025_10_21::make_relative_line_numbers_an_enum, )], @@ -2329,7 +2346,7 @@ mod tests { ); // Platform key: settings nested inside "macos" should be migrated - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2025_10_21::make_relative_line_numbers_an_enum, )], @@ -2354,7 +2371,7 @@ mod tests { ); // Profile: settings nested inside profiles should be migrated - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2025_10_21::make_relative_line_numbers_an_enum, )], @@ -2431,7 +2448,7 @@ mod tests { ); // Platform key: settings nested inside "linux" should be migrated - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2025_11_25::remove_context_server_source, )], @@ -2469,7 +2486,7 @@ mod tests { ); // Profile: settings nested inside profiles should be migrated - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2025_11_25::remove_context_server_source, )], @@ -2610,7 +2627,7 @@ mod tests { #[test] fn test_make_auto_indent_an_enum() { // Empty settings should not change - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2025_01_27::make_auto_indent_an_enum, )], @@ -2619,7 +2636,7 @@ mod tests { ); // true should become "syntax_aware" - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2025_01_27::make_auto_indent_an_enum, )], @@ -2636,7 +2653,7 @@ mod tests { ); // false should become "none" - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2025_01_27::make_auto_indent_an_enum, )], @@ -2653,7 +2670,7 @@ mod tests { ); // Already valid enum values should not change - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2025_01_27::make_auto_indent_an_enum, )], @@ -2665,7 +2682,7 @@ mod tests { ); // Should also work inside languages - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2025_01_27::make_auto_indent_an_enum, )], @@ -2694,7 +2711,7 @@ mod tests { #[test] fn test_move_edit_prediction_provider_to_edit_predictions() { - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_02::move_edit_prediction_provider_to_edit_predictions, )], @@ -2702,7 +2719,7 @@ mod tests { None, ); - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_02::move_edit_prediction_provider_to_edit_predictions, )], @@ -2726,7 +2743,7 @@ mod tests { ), ); - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_02::move_edit_prediction_provider_to_edit_predictions, )], @@ -2754,7 +2771,7 @@ mod tests { ), ); - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_02::move_edit_prediction_provider_to_edit_predictions, )], @@ -2781,7 +2798,7 @@ mod tests { ), ); - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_02::move_edit_prediction_provider_to_edit_predictions, )], @@ -2798,7 +2815,7 @@ mod tests { // Non-object edit_predictions (e.g. true) should gracefully skip // instead of bail!-ing and aborting the entire migration chain. - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_02::move_edit_prediction_provider_to_edit_predictions, )], @@ -2822,7 +2839,7 @@ mod tests { ); // Platform key: settings nested inside "macos" should be migrated - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_02::move_edit_prediction_provider_to_edit_predictions, )], @@ -2851,7 +2868,7 @@ mod tests { ); // Profile: settings nested inside profiles should be migrated - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_02::move_edit_prediction_provider_to_edit_predictions, )], @@ -2884,7 +2901,7 @@ mod tests { ); // Combined: root + platform + profile should all be migrated simultaneously - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_02::move_edit_prediction_provider_to_edit_predictions, )], @@ -2935,7 +2952,7 @@ mod tests { #[test] fn test_migrate_experimental_sweep_mercury() { - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_03::migrate_experimental_sweep_mercury, )], @@ -2943,7 +2960,7 @@ mod tests { None, ); - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_03::migrate_experimental_sweep_mercury, )], @@ -2969,7 +2986,7 @@ mod tests { ), ); - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_03::migrate_experimental_sweep_mercury, )], @@ -2995,7 +3012,7 @@ mod tests { ), ); - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_03::migrate_experimental_sweep_mercury, )], @@ -3021,7 +3038,7 @@ mod tests { ), ); - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_03::migrate_experimental_sweep_mercury, )], @@ -3036,7 +3053,7 @@ mod tests { None, ); - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_03::migrate_experimental_sweep_mercury, )], @@ -3054,7 +3071,7 @@ mod tests { ); // Platform key: settings nested inside "linux" should be migrated - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_03::migrate_experimental_sweep_mercury, )], @@ -3085,7 +3102,7 @@ mod tests { ); // Profile: settings nested inside profiles should be migrated - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_03::migrate_experimental_sweep_mercury, )], @@ -3120,7 +3137,7 @@ mod tests { ); // Combined: root + platform + profile should all be migrated simultaneously - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_03::migrate_experimental_sweep_mercury, )], @@ -3178,7 +3195,7 @@ mod tests { #[test] fn test_migrate_always_allow_tool_actions_to_default() { // No agent settings - no change - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], @@ -3187,7 +3204,7 @@ mod tests { ); // always_allow_tool_actions: true -> tool_permissions.default: "allow" - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], @@ -3214,7 +3231,7 @@ mod tests { ); // always_allow_tool_actions: false -> just remove it - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], @@ -3233,7 +3250,7 @@ mod tests { ); // Preserve existing tool_permissions.tools when migrating - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], @@ -3272,7 +3289,7 @@ mod tests { ); // Don't override existing default (and migrate default_mode to default) - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], @@ -3302,7 +3319,7 @@ mod tests { ); // Migrate existing default_mode to default (no always_allow_tool_actions) - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], @@ -3331,7 +3348,7 @@ mod tests { ); // No migration needed if already using new format with "default" - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], @@ -3349,7 +3366,7 @@ mod tests { ); // Migrate default_mode to default in tool-specific rules - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], @@ -3388,7 +3405,7 @@ mod tests { ); // When tool_permissions is null, replace it so always_allow is preserved - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], @@ -3416,7 +3433,7 @@ mod tests { ); // Platform-specific agent migration - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], @@ -3447,7 +3464,7 @@ mod tests { ); // Channel-specific agent migration - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], @@ -3488,7 +3505,7 @@ mod tests { ); // Profile-level migration - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], @@ -3526,7 +3543,7 @@ mod tests { ); // Platform-specific agent with profiles - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], @@ -3571,7 +3588,7 @@ mod tests { ); // Root-level profile with always_allow_tool_actions - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], @@ -3606,7 +3623,7 @@ mod tests { ); // Root-level profile with default_mode - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], @@ -3643,7 +3660,7 @@ mod tests { ); // Root-level profile + root-level agent both migrated - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], @@ -3689,7 +3706,7 @@ mod tests { // Non-boolean always_allow_tool_actions (string "true") is left in place // so the schema validator can report it, rather than silently dropping user data. - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], @@ -3705,7 +3722,7 @@ mod tests { ); // null always_allow_tool_actions is removed (treated as false) - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], @@ -3722,7 +3739,7 @@ mod tests { // Project-local settings (.zed/settings.json) with always_allow_tool_actions // These files have no platform/channel overrides or root-level profiles. - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], @@ -3763,7 +3780,7 @@ mod tests { ); // Project-local settings with only default_mode (no always_allow_tool_actions) - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], @@ -3792,7 +3809,7 @@ mod tests { ); // Project-local settings with no agent section at all - no change - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], @@ -3807,7 +3824,7 @@ mod tests { ); // Existing agent_servers are left untouched - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], @@ -3850,7 +3867,7 @@ mod tests { ); // Existing agent_servers are left untouched even with partial entries - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], @@ -3887,7 +3904,7 @@ mod tests { ); // always_allow_tool_actions: false leaves agent_servers untouched - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], @@ -3910,7 +3927,7 @@ mod tests { #[test] fn test_migrate_builtin_agent_servers_to_registry_simple() { - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_25::migrate_builtin_agent_servers_to_registry, )], @@ -3950,7 +3967,7 @@ mod tests { #[test] fn test_migrate_builtin_agent_servers_empty_entries() { - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_25::migrate_builtin_agent_servers_to_registry, )], @@ -3981,7 +3998,7 @@ mod tests { #[test] fn test_migrate_builtin_agent_servers_with_command() { - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_25::migrate_builtin_agent_servers_to_registry, )], @@ -4019,7 +4036,7 @@ mod tests { #[test] fn test_migrate_builtin_agent_servers_gemini_with_command() { - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_25::migrate_builtin_agent_servers_to_registry, )], @@ -4047,7 +4064,7 @@ mod tests { #[test] fn test_migrate_builtin_agent_servers_gemini_ignore_system_version_false() { - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_25::migrate_builtin_agent_servers_to_registry, )], @@ -4075,7 +4092,7 @@ mod tests { #[test] fn test_migrate_builtin_agent_servers_gemini_ignore_system_version_true() { - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_25::migrate_builtin_agent_servers_to_registry, )], @@ -4102,7 +4119,7 @@ mod tests { #[test] fn test_migrate_builtin_agent_servers_already_typed_unchanged() { - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_25::migrate_builtin_agent_servers_to_registry, )], @@ -4124,7 +4141,7 @@ mod tests { #[test] fn test_migrate_builtin_agent_servers_preserves_custom_entries() { - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_25::migrate_builtin_agent_servers_to_registry, )], @@ -4158,7 +4175,7 @@ mod tests { #[test] fn test_migrate_builtin_agent_servers_target_already_exists() { - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_25::migrate_builtin_agent_servers_to_registry, )], @@ -4188,7 +4205,7 @@ mod tests { #[test] fn test_migrate_builtin_agent_servers_no_agent_servers_key() { - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_25::migrate_builtin_agent_servers_to_registry, )], @@ -4203,7 +4220,7 @@ mod tests { #[test] fn test_migrate_builtin_agent_servers_all_fields() { - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_25::migrate_builtin_agent_servers_to_registry, )], @@ -4251,7 +4268,7 @@ mod tests { #[test] fn test_migrate_builtin_agent_servers_codex_with_command() { - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_25::migrate_builtin_agent_servers_to_registry, )], @@ -4283,7 +4300,7 @@ mod tests { #[test] fn test_migrate_builtin_agent_servers_mixed_migrated_and_not() { - assert_migrate_settings_with_migrations( + assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_25::migrate_builtin_agent_servers_to_registry, )], @@ -4318,4 +4335,149 @@ mod tests { ), ); } + + #[test] + fn test_migrate_edit_prediction_conflict_context() { + assert_migrate_with_migrations( + &[MigrationType::TreeSitter( + migrations::m_2026_03_23::KEYMAP_PATTERNS, + &KEYMAP_QUERY_2026_03_23, + )], + &r#" + [ + { + "context": "Editor && edit_prediction_conflict", + "bindings": { + "ctrl-enter": "editor::AcceptEditPrediction" // Example of a modified keybinding + } + } + ] + "#.unindent(), + Some( + &r#" + [ + { + "context": "Editor && (edit_prediction && (showing_completions || in_leading_whitespace))", + "bindings": { + "ctrl-enter": "editor::AcceptEditPrediction" // Example of a modified keybinding + } + } + ] + "#.unindent(), + ), + ); + + assert_migrate_with_migrations( + &[MigrationType::TreeSitter( + migrations::m_2026_03_23::KEYMAP_PATTERNS, + &KEYMAP_QUERY_2026_03_23, + )], + &r#" + [ + { + "context": "Editor && edit_prediction_conflict && !showing_completions", + "bindings": { + // Here we don't require a modifier unless there's a language server completion + "tab": "editor::AcceptEditPrediction" + } + } + ] + "#.unindent(), + Some( + &r#" + [ + { + "context": "Editor && (edit_prediction && in_leading_whitespace)", + "bindings": { + // Here we don't require a modifier unless there's a language server completion + "tab": "editor::AcceptEditPrediction" + } + } + ] + "#.unindent(), + ), + ); + + assert_migrate_with_migrations( + &[MigrationType::TreeSitter( + migrations::m_2026_03_23::KEYMAP_PATTERNS, + &KEYMAP_QUERY_2026_03_23, + )], + &r#" + [ + { + "context": "Editor && edit_prediction_conflict && showing_completions", + "bindings": { + "tab": "editor::AcceptEditPrediction" + } + } + ] + "# + .unindent(), + Some( + &r#" + [ + { + "context": "Editor && (edit_prediction && showing_completions)", + "bindings": { + "tab": "editor::AcceptEditPrediction" + } + } + ] + "# + .unindent(), + ), + ); + + assert_migrate_with_migrations( + &[MigrationType::TreeSitter( + migrations::m_2026_03_23::KEYMAP_PATTERNS, + &KEYMAP_QUERY_2026_03_23, + )], + &r#" + [ + { + "context": "Editor && edit_prediction", + "bindings": { + "tab": "editor::AcceptEditPrediction", + // Optional: This makes the default `alt-l` binding do nothing. + "alt-l": null + } + }, + { + "context": "Editor && edit_prediction_conflict", + "bindings": { + "alt-tab": "editor::AcceptEditPrediction", + // Optional: This makes the default `alt-l` binding do nothing. + "alt-l": null + } + }, + ] + "# + .unindent(), + Some( + &r#" + [ + { + "context": "Editor && edit_prediction", + "bindings": { + "tab": "editor::AcceptEditPrediction", + // Optional: This makes the default `alt-l` binding do nothing. + "alt-l": null + } + }, + { + "context": "Editor && (edit_prediction && (showing_completions || in_leading_whitespace))", + "bindings": { + "alt-tab": "editor::AcceptEditPrediction", + // Optional: This makes the default `alt-l` binding do nothing. + "alt-l": null + } + }, + ] + "# + .unindent(), + ), + ); + } } diff --git a/crates/miniprofiler_ui/Cargo.toml b/crates/miniprofiler_ui/Cargo.toml index 3f48bdfe486da52fc0edb2a1b540b10375d4f995..a8041b8b37cb57148e6dcdcdb8df7f436e52701b 100644 --- a/crates/miniprofiler_ui/Cargo.toml +++ b/crates/miniprofiler_ui/Cargo.toml @@ -14,7 +14,7 @@ path = "src/miniprofiler_ui.rs" [dependencies] gpui.workspace = true rpc.workspace = true -theme.workspace = true +theme_settings.workspace = true zed_actions.workspace = true workspace.workspace = true util.workspace = true diff --git a/crates/miniprofiler_ui/src/miniprofiler_ui.rs b/crates/miniprofiler_ui/src/miniprofiler_ui.rs index 9ae0a33471d31f32852b4b376bbc71ff0911c60b..351d0a68e2660870923a561ac8989559dc9abd7a 100644 --- a/crates/miniprofiler_ui/src/miniprofiler_ui.rs +++ b/crates/miniprofiler_ui/src/miniprofiler_ui.rs @@ -456,7 +456,7 @@ impl Render for ProfilerWindow { window: &mut gpui::Window, cx: &mut gpui::Context, ) -> impl gpui::IntoElement { - let ui_font = theme::setup_ui_font(window, cx); + let ui_font = theme_settings::setup_ui_font(window, cx); if !self.paused { self.poll_timings(cx); window.request_animation_frame(); diff --git a/crates/multi_buffer/Cargo.toml b/crates/multi_buffer/Cargo.toml index 66c23101ab26ac6be58d482c752f366522bb9305..a06599999c8147dc464128ad8ab5e6bf5ad5755b 100644 --- a/crates/multi_buffer/Cargo.toml +++ b/crates/multi_buffer/Cargo.toml @@ -45,6 +45,7 @@ tree-sitter.workspace = true ztracing.workspace = true tracing.workspace = true util.workspace = true +unicode-segmentation.workspace = true [dev-dependencies] buffer_diff = { workspace = true, features = ["test-support"] } diff --git a/crates/multi_buffer/src/anchor.rs b/crates/multi_buffer/src/anchor.rs index 8ae154148379f7cb7d806196a03b354e2f6130c5..cf4df9f53ccd2ca86fc6c064d51b7557404dd251 100644 --- a/crates/multi_buffer/src/anchor.rs +++ b/crates/multi_buffer/src/anchor.rs @@ -119,8 +119,7 @@ impl Anchor { } if (self.diff_base_anchor.is_some() || other.diff_base_anchor.is_some()) && let Some(base_text) = snapshot - .diffs - .get(&excerpt.buffer_id) + .diff_state(excerpt.buffer_id) .map(|diff| diff.base_text()) { let self_anchor = self.diff_base_anchor.filter(|a| a.is_valid(base_text)); @@ -155,8 +154,7 @@ impl Anchor { text_anchor: self.text_anchor.bias_left(&excerpt.buffer), diff_base_anchor: self.diff_base_anchor.map(|a| { if let Some(base_text) = snapshot - .diffs - .get(&excerpt.buffer_id) + .diff_state(excerpt.buffer_id) .map(|diff| diff.base_text()) && a.is_valid(&base_text) { @@ -178,8 +176,7 @@ impl Anchor { text_anchor: self.text_anchor.bias_right(&excerpt.buffer), diff_base_anchor: self.diff_base_anchor.map(|a| { if let Some(base_text) = snapshot - .diffs - .get(&excerpt.buffer_id) + .diff_state(excerpt.buffer_id) .map(|diff| diff.base_text()) && a.is_valid(&base_text) { diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index 3b1177874cac0f71d4652aa0948005397e362b58..21b4d0e1a6c84189a9926d2d181f097c2bdf4ea7 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -23,13 +23,14 @@ use language::{ IndentGuideSettings, IndentSize, Language, LanguageScope, OffsetRangeExt, OffsetUtf16, Outline, OutlineItem, Point, PointUtf16, Selection, TextDimension, TextObject, ToOffset as _, ToPoint as _, TransactionId, TreeSitterOptions, Unclipped, - language_settings::{LanguageSettings, language_settings}, + language_settings::{AllLanguageSettings, LanguageSettings}, }; #[cfg(any(test, feature = "test-support"))] use gpui::AppContext as _; use rope::DimensionPair; +use settings::Settings; use smallvec::SmallVec; use smol::future::yield_now; use std::{ @@ -54,6 +55,7 @@ use text::{ subscription::{Subscription, Topic}, }; use theme::SyntaxTheme; +use unicode_segmentation::UnicodeSegmentation; use util::post_inc; use ztracing::instrument; @@ -530,8 +532,9 @@ struct DiffState { } impl DiffState { - fn snapshot(&self, cx: &App) -> DiffStateSnapshot { + fn snapshot(&self, buffer_id: BufferId, cx: &App) -> DiffStateSnapshot { DiffStateSnapshot { + buffer_id, diff: self.diff.read(cx).snapshot(cx), main_buffer: self.main_buffer.as_ref().map(|b| b.read(cx).snapshot()), } @@ -540,6 +543,7 @@ impl DiffState { #[derive(Clone)] struct DiffStateSnapshot { + buffer_id: BufferId, diff: BufferDiffSnapshot, main_buffer: Option, } @@ -552,6 +556,77 @@ impl std::ops::Deref for DiffStateSnapshot { } } +#[derive(Clone, Debug, Default)] +struct DiffStateSummary { + max_buffer_id: Option, + added_rows: u32, + removed_rows: u32, +} + +impl sum_tree::ContextLessSummary for DiffStateSummary { + fn zero() -> Self { + Self::default() + } + + fn add_summary(&mut self, other: &Self) { + self.max_buffer_id = std::cmp::max(self.max_buffer_id, other.max_buffer_id); + self.added_rows += other.added_rows; + self.removed_rows += other.removed_rows; + } +} + +impl sum_tree::Item for DiffStateSnapshot { + type Summary = DiffStateSummary; + + fn summary(&self, _cx: ()) -> DiffStateSummary { + let (added_rows, removed_rows) = self.diff.changed_row_counts(); + DiffStateSummary { + max_buffer_id: Some(self.buffer_id), + added_rows, + removed_rows, + } + } +} + +impl sum_tree::KeyedItem for DiffStateSnapshot { + type Key = Option; + + fn key(&self) -> Option { + Some(self.buffer_id) + } +} + +impl<'a> Dimension<'a, DiffStateSummary> for Option { + fn zero(_cx: ()) -> Self { + None + } + + fn add_summary(&mut self, summary: &DiffStateSummary, _cx: ()) { + *self = std::cmp::max(*self, summary.max_buffer_id); + } +} + +fn find_diff_state( + diffs: &SumTree, + buffer_id: BufferId, +) -> Option<&DiffStateSnapshot> { + let key = Some(buffer_id); + let (.., item) = diffs.find::, _>((), &key, Bias::Left); + item.filter(|entry| entry.buffer_id == buffer_id) +} + +fn remove_diff_state(diffs: &mut SumTree, buffer_id: BufferId) { + let key = Some(buffer_id); + let mut cursor = diffs.cursor::>(()); + let mut new_tree = cursor.slice(&key, Bias::Left); + if key == cursor.end() { + cursor.next(); + } + new_tree.append(cursor.suffix(), ()); + drop(cursor); + *diffs = new_tree; +} + impl DiffState { fn new(diff: Entity, cx: &mut Context) -> Self { DiffState { @@ -624,7 +699,7 @@ impl DiffState { pub struct MultiBufferSnapshot { excerpts: SumTree, buffer_locators: TreeMap>, - diffs: TreeMap, + diffs: SumTree, diff_transforms: SumTree, excerpt_ids: SumTree, replaced_excerpts: Arc>, @@ -993,7 +1068,7 @@ pub struct MultiBufferChunks<'a> { excerpts: Cursor<'a, 'static, Excerpt, ExcerptOffset>, diff_transforms: Cursor<'a, 'static, DiffTransform, Dimensions>, - diffs: &'a TreeMap, + diffs: &'a SumTree, diff_base_chunks: Option<(BufferId, BufferChunks<'a>)>, buffer_chunk: Option>, range: Range, @@ -1053,7 +1128,7 @@ impl<'a, MBD: MultiBufferDimension> Dimension<'a, DiffTransformSummary> for Diff struct MultiBufferCursor<'a, MBD, BD> { excerpts: Cursor<'a, 'static, Excerpt, ExcerptDimension>, diff_transforms: Cursor<'a, 'static, DiffTransform, DiffTransforms>, - diffs: &'a TreeMap, + diffs: &'a SumTree, cached_region: OnceCell>>, } @@ -1232,8 +1307,15 @@ impl MultiBuffer { } } - pub fn set_group_interval(&mut self, group_interval: Duration) { + pub fn set_group_interval(&mut self, group_interval: Duration, cx: &mut Context) { self.history.set_group_interval(group_interval); + if self.singleton { + for BufferState { buffer, .. } in self.buffers.values() { + buffer.update(cx, |buffer, _| { + buffer.set_group_interval(group_interval); + }); + } + } } pub fn with_title(mut self, title: String) -> Self { @@ -2312,15 +2394,12 @@ impl MultiBuffer { snapshot.excerpts = new_excerpts; for buffer_id in &removed_buffer_ids { self.diffs.remove(buffer_id); - snapshot.diffs.remove(buffer_id); + remove_diff_state(&mut snapshot.diffs, *buffer_id); } - // Recalculate has_inverted_diff after removing diffs if !removed_buffer_ids.is_empty() { - snapshot.has_inverted_diff = snapshot - .diffs - .iter() - .any(|(_, diff)| diff.main_buffer.is_some()); + snapshot.has_inverted_diff = + snapshot.diffs.iter().any(|diff| diff.main_buffer.is_some()); } if changed_trailing_excerpt { @@ -2423,10 +2502,11 @@ impl MultiBuffer { let diff = diff.read(cx); let buffer_id = diff.buffer_id; let diff = DiffStateSnapshot { + buffer_id, diff: diff.snapshot(cx), main_buffer: None, }; - self.snapshot.get_mut().diffs.insert(buffer_id, diff); + self.snapshot.get_mut().diffs.insert_or_replace(diff, ()); } fn inverted_buffer_diff_language_changed( @@ -2439,13 +2519,11 @@ impl MultiBuffer { let main_buffer_snapshot = main_buffer.read(cx).snapshot(); let diff = diff.read(cx); let diff = DiffStateSnapshot { + buffer_id: base_text_buffer_id, diff: diff.snapshot(cx), main_buffer: Some(main_buffer_snapshot), }; - self.snapshot - .get_mut() - .diffs - .insert(base_text_buffer_id, diff); + self.snapshot.get_mut().diffs.insert_or_replace(diff, ()); } fn buffer_diff_changed( @@ -2463,15 +2541,14 @@ impl MultiBuffer { return; }; let new_diff = DiffStateSnapshot { + buffer_id, diff: diff.snapshot(cx), main_buffer: None, }; let mut snapshot = self.snapshot.get_mut(); - let base_text_changed = snapshot - .diffs - .get(&buffer_id) + let base_text_changed = find_diff_state(&snapshot.diffs, buffer_id) .is_none_or(|old_diff| !new_diff.base_texts_definitely_eq(old_diff)); - snapshot.diffs.insert_or_replace(buffer_id, new_diff); + snapshot.diffs.insert_or_replace(new_diff, ()); let buffer = buffer_state.buffer.read(cx); let diff_change_range = range.to_offset(buffer); @@ -2510,13 +2587,12 @@ impl MultiBuffer { let main_buffer_snapshot = main_buffer.read(cx).snapshot(); let diff = diff.read(cx); let new_diff = DiffStateSnapshot { + buffer_id: base_text_buffer_id, diff: diff.snapshot(cx), main_buffer: Some(main_buffer_snapshot), }; let mut snapshot = self.snapshot.get_mut(); - snapshot - .diffs - .insert_or_replace(base_text_buffer_id, new_diff); + snapshot.diffs.insert_or_replace(new_diff, ()); let Some(diff_change_range) = diff_change_range else { return; @@ -2576,10 +2652,7 @@ impl MultiBuffer { .map(|excerpt| excerpt.buffer.remote_id()); buffer_id .and_then(|buffer_id| self.buffer(buffer_id)) - .map(|buffer| { - let buffer = buffer.read(cx); - language_settings(buffer.language().map(|l| l.name()), buffer.file(), cx) - }) + .map(|buffer| LanguageSettings::for_buffer(&buffer.read(cx), cx)) .unwrap_or_else(move || self.language_settings_at(MultiBufferOffset::default(), cx)) } @@ -2588,14 +2661,11 @@ impl MultiBuffer { point: T, cx: &'a App, ) -> Cow<'a, LanguageSettings> { - let mut language = None; - let mut file = None; if let Some((buffer, offset)) = self.point_to_buffer_offset(point, cx) { - let buffer = buffer.read(cx); - language = buffer.language_at(offset); - file = buffer.file(); + LanguageSettings::for_buffer_at(buffer.read(cx), offset, cx) + } else { + Cow::Borrowed(&AllLanguageSettings::get_global(cx).defaults) } - language_settings(language.map(|l| l.name()), file, cx) } pub fn for_each_buffer(&self, f: &mut dyn FnMut(&Entity)) { @@ -3138,11 +3208,7 @@ impl MultiBuffer { if !diffs.is_empty() { let mut diffs_to_add = Vec::new(); for (id, diff) in diffs { - // For inverted diffs, we excerpt the diff base texts in the multibuffer - // and use the diff hunk base text ranges to compute diff transforms. - // Those base text ranges are usize, so make sure if the base text changed - // we also update the diff snapshot so that we don't use stale offsets - if buffer_diff.get(id).is_none_or(|existing_diff| { + if find_diff_state(buffer_diff, *id).is_none_or(|existing_diff| { if existing_diff.main_buffer.is_none() { return false; } @@ -3153,14 +3219,12 @@ impl MultiBuffer { .changed_since(existing_diff.base_text().version()) }) { if diffs_to_add.capacity() == 0 { - // we'd rather overallocate than reallocate as buffer diffs are quite big - // meaning re-allocations will be fairly expensive diffs_to_add.reserve(diffs.len()); } - diffs_to_add.push((*id, diff.snapshot(cx))); + diffs_to_add.push(sum_tree::Edit::Insert(diff.snapshot(*id, cx))); } } - buffer_diff.extend(diffs_to_add); + buffer_diff.edit(diffs_to_add, ()); } let mut excerpts_to_edit = Vec::new(); @@ -3347,7 +3411,7 @@ impl MultiBuffer { inserted_hunk_info: Some(hunk), .. }) => excerpts.item().is_some_and(|excerpt| { - if let Some(diff) = snapshot.diffs.get(&excerpt.buffer_id) + if let Some(diff) = find_diff_state(&snapshot.diffs, excerpt.buffer_id) && diff.main_buffer.is_some() { return true; @@ -3448,7 +3512,7 @@ impl MultiBuffer { while let Some(excerpt) = excerpts.item() { // Recompute the expanded hunks in the portion of the excerpt that // intersects the edit. - if let Some(diff) = snapshot.diffs.get(&excerpt.buffer_id) { + if let Some(diff) = find_diff_state(&snapshot.diffs, excerpt.buffer_id) { let buffer = &excerpt.buffer; let excerpt_start = *excerpts.start(); let excerpt_end = excerpt_start + excerpt.text_summary.len; @@ -4028,7 +4092,7 @@ impl MultiBufferSnapshot { ) -> impl Iterator + '_ { let query_range = range.start.to_point(self)..range.end.to_point(self); self.lift_buffer_metadata(query_range.clone(), move |buffer, buffer_range| { - let diff = self.diffs.get(&buffer.remote_id())?; + let diff = self.diff_state(buffer.remote_id())?; let iter = if let Some(main_buffer) = &diff.main_buffer { let buffer_start = buffer.point_to_offset(buffer_range.start); let buffer_end = buffer.point_to_offset(buffer_range.end); @@ -4611,7 +4675,7 @@ impl MultiBufferSnapshot { .text_anchor .to_offset(&excerpt.buffer); - if let Some(diff) = self.diffs.get(&excerpt.buffer_id) { + if let Some(diff) = self.diff_state(excerpt.buffer_id) { if let Some(main_buffer) = &diff.main_buffer { for hunk in diff .hunks_intersecting_base_text_range_rev(excerpt_start..excerpt_end, main_buffer) @@ -4646,7 +4710,7 @@ impl MultiBufferSnapshot { cursor.prev_excerpt(); let excerpt = cursor.excerpt()?; - let Some(diff) = self.diffs.get(&excerpt.buffer_id) else { + let Some(diff) = self.diff_state(excerpt.buffer_id) else { continue; }; if let Some(main_buffer) = &diff.main_buffer { @@ -4676,7 +4740,7 @@ impl MultiBufferSnapshot { } pub fn has_diff_hunks(&self) -> bool { - self.diffs.values().any(|diff| !diff.is_empty()) + self.diffs.iter().any(|diff| !diff.is_empty()) } pub fn is_inside_word( @@ -5259,7 +5323,8 @@ impl MultiBufferSnapshot { } => { let buffer_start = base_text_byte_range.start + start_overshoot; let mut buffer_end = base_text_byte_range.start + end_overshoot; - let Some(base_text) = self.diffs.get(buffer_id).map(|diff| diff.base_text()) else { + let Some(base_text) = self.diff_state(*buffer_id).map(|diff| diff.base_text()) + else { panic!("{:?} is in non-existent deleted hunk", range.start) }; @@ -5311,7 +5376,8 @@ impl MultiBufferSnapshot { .. } => { let buffer_end = base_text_byte_range.start + overshoot; - let Some(base_text) = self.diffs.get(buffer_id).map(|diff| diff.base_text()) else { + let Some(base_text) = self.diff_state(*buffer_id).map(|diff| diff.base_text()) + else { panic!("{:?} is in non-existent deleted hunk", range.end) }; @@ -5531,7 +5597,7 @@ impl MultiBufferSnapshot { }) => { if let Some(diff_base_anchor) = &anchor.diff_base_anchor && let Some(base_text) = - self.diffs.get(buffer_id).map(|diff| diff.base_text()) + self.diff_state(*buffer_id).map(|diff| diff.base_text()) && diff_base_anchor.is_valid(&base_text) { // The anchor carries a diff-base position — resolve it @@ -5910,7 +5976,7 @@ impl MultiBufferSnapshot { .. }) = diff_transforms.item() { - let diff = self.diffs.get(buffer_id).expect("missing diff"); + let diff = self.diff_state(*buffer_id).expect("missing diff"); if offset_in_transform > base_text_byte_range.len() { debug_assert!(*has_trailing_newline); bias = Bias::Right; @@ -6603,8 +6669,7 @@ impl MultiBufferSnapshot { let end_row = MultiBufferRow(range.end.row); let mut row_indents = self.line_indents(start_row, |buffer| { - let settings = - language_settings(buffer.language().map(|l| l.name()), buffer.file(), cx); + let settings = LanguageSettings::for_buffer_snapshot(buffer, None, cx); settings.indent_guides.enabled || ignore_disabled_for_language }); @@ -6628,7 +6693,7 @@ impl MultiBufferSnapshot { .get_or_insert_with(|| { ( buffer.remote_id(), - language_settings(buffer.language().map(|l| l.name()), buffer.file(), cx), + LanguageSettings::for_buffer_snapshot(buffer, None, cx), ) }) .1; @@ -6724,13 +6789,7 @@ impl MultiBufferSnapshot { self.excerpts .first() .map(|excerpt| &excerpt.buffer) - .map(|buffer| { - language_settings( - buffer.language().map(|language| language.name()), - buffer.file(), - cx, - ) - }) + .map(|buffer| LanguageSettings::for_buffer_snapshot(buffer, None, cx)) .unwrap_or_else(move || self.language_settings_at(MultiBufferOffset::ZERO, cx)) } @@ -6739,13 +6798,11 @@ impl MultiBufferSnapshot { point: T, cx: &'a App, ) -> Cow<'a, LanguageSettings> { - let mut language = None; - let mut file = None; if let Some((buffer, offset)) = self.point_to_buffer_offset(point) { - language = buffer.language_at(offset); - file = buffer.file(); + buffer.settings_at(offset, cx) + } else { + Cow::Borrowed(&AllLanguageSettings::get_global(cx).defaults) } - language_settings(language.map(|l| l.name()), file, cx) } pub fn language_scope_at(&self, point: T) -> Option { @@ -7173,7 +7230,16 @@ impl MultiBufferSnapshot { } pub fn diff_for_buffer_id(&self, buffer_id: BufferId) -> Option<&BufferDiffSnapshot> { - self.diffs.get(&buffer_id).map(|diff| &diff.diff) + self.diff_state(buffer_id).map(|diff| &diff.diff) + } + + fn diff_state(&self, buffer_id: BufferId) -> Option<&DiffStateSnapshot> { + find_diff_state(&self.diffs, buffer_id) + } + + pub fn total_changed_lines(&self) -> (u32, u32) { + let summary = self.diffs.summary(); + (summary.added_rows, summary.removed_rows) } pub fn all_diff_hunks_expanded(&self) -> bool { @@ -7257,6 +7323,16 @@ impl MultiBufferSnapshot { } excerpt_edits } + + /// Returns the number of graphemes in `range`. + /// + /// This counts user-visible characters like `e\u{301}` as one. + pub fn grapheme_count_for_range(&self, range: &Range) -> usize { + self.text_for_range(range.clone()) + .collect::() + .graphemes(true) + .count() + } } #[cfg(any(test, feature = "test-support"))] @@ -7532,7 +7608,7 @@ where hunk_info, .. } => { - let diff = self.diffs.get(buffer_id)?; + let diff = find_diff_state(self.diffs, *buffer_id)?; let buffer = diff.base_text(); let mut rope_cursor = buffer.as_rope().cursor(0); let buffer_start = rope_cursor.summary::(base_text_byte_range.start); @@ -8560,7 +8636,7 @@ impl<'a> Iterator for MultiBufferChunks<'a> { } chunks } else { - let base_buffer = &self.diffs.get(buffer_id)?.base_text(); + let base_buffer = &find_diff_state(self.diffs, *buffer_id)?.base_text(); base_buffer.chunks(base_text_start..base_text_end, self.language_aware) }; diff --git a/crates/multi_buffer/src/multi_buffer_tests.rs b/crates/multi_buffer/src/multi_buffer_tests.rs index 8b708968f21b103ee3c7882c01cd1edf6884af03..e44a38e4abed8438bcdcbf1f2c8c55c465d98e2d 100644 --- a/crates/multi_buffer/src/multi_buffer_tests.rs +++ b/crates/multi_buffer/src/multi_buffer_tests.rs @@ -3513,8 +3513,8 @@ fn test_history(cx: &mut App) { buf }); let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite)); - multibuffer.update(cx, |this, _| { - this.set_group_interval(group_interval); + multibuffer.update(cx, |this, cx| { + this.set_group_interval(group_interval, cx); }); multibuffer.update(cx, |multibuffer, cx| { multibuffer.set_excerpts_for_path( diff --git a/crates/onboarding/Cargo.toml b/crates/onboarding/Cargo.toml index e5e5b5cac93aa4021f8933bd38f8711d53b89902..545a4b614160054186d4acf7bce17e36ac1cd4f1 100644 --- a/crates/onboarding/Cargo.toml +++ b/crates/onboarding/Cargo.toml @@ -32,6 +32,7 @@ serde.workspace = true settings.workspace = true telemetry.workspace = true theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true vim_mode_setting.workspace = true diff --git a/crates/onboarding/src/basics_page.rs b/crates/onboarding/src/basics_page.rs index 7221d8104cbff2e1e0a8ebe265b419b1c725472d..b2e595b28a33ed4ee7f066c4d969baffdb2a081b 100644 --- a/crates/onboarding/src/basics_page.rs +++ b/crates/onboarding/src/basics_page.rs @@ -5,10 +5,8 @@ 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, - ThemeSettings, -}; +use theme::{Appearance, SystemAppearance, ThemeRegistry}; +use theme_settings::{ThemeAppearanceMode, ThemeName, ThemeSelection, ThemeSettings}; use ui::{ Divider, StatefulInteractiveElement, SwitchField, TintColor, ToggleButtonGroup, ToggleButtonGroupSize, ToggleButtonSimple, ToggleButtonWithIcon, Tooltip, prelude::*, @@ -197,7 +195,7 @@ fn render_theme_section(tab_index: &mut isize, cx: &mut App) -> impl IntoElement fn write_mode_change(mode: ThemeAppearanceMode, cx: &mut App) { let fs = ::global(cx); update_settings_file(fs, cx, move |settings, _cx| { - theme::set_mode(settings, mode); + theme_settings::set_mode(settings, mode); }); } @@ -219,13 +217,13 @@ fn render_theme_section(tab_index: &mut isize, cx: &mut App) -> impl IntoElement dark: ThemeName(dark_theme.into()), }); } - ThemeAppearanceMode::Light => theme::set_theme( + ThemeAppearanceMode::Light => theme_settings::set_theme( settings, theme, Appearance::Light, *SystemAppearance::global(cx), ), - ThemeAppearanceMode::Dark => theme::set_theme( + ThemeAppearanceMode::Dark => theme_settings::set_theme( settings, theme, Appearance::Dark, diff --git a/crates/onboarding/src/theme_preview.rs b/crates/onboarding/src/theme_preview.rs index 8bd65d8a2707acdc53333071486f41741398a82a..602695cca6a643d4eb4d3476286bba7fcfe74c40 100644 --- a/crates/onboarding/src/theme_preview.rs +++ b/crates/onboarding/src/theme_preview.rs @@ -87,13 +87,13 @@ impl ThemePreviewTile { let colors = theme.colors(); let syntax = theme.syntax(); - let keyword_color = syntax.get("keyword").color; - let function_color = syntax.get("function").color; - let string_color = syntax.get("string").color; - let comment_color = syntax.get("comment").color; - let variable_color = syntax.get("variable").color; - let type_color = syntax.get("type").color; - let punctuation_color = syntax.get("punctuation").color; + let keyword_color = syntax.style_for_name("keyword").and_then(|s| s.color); + let function_color = syntax.style_for_name("function").and_then(|s| s.color); + let string_color = syntax.style_for_name("string").and_then(|s| s.color); + let comment_color = syntax.style_for_name("comment").and_then(|s| s.color); + let variable_color = syntax.style_for_name("variable").and_then(|s| s.color); + let type_color = syntax.style_for_name("type").and_then(|s| s.color); + let punctuation_color = syntax.style_for_name("punctuation").and_then(|s| s.color); let syntax_colors = [ keyword_color, diff --git a/crates/open_path_prompt/Cargo.toml b/crates/open_path_prompt/Cargo.toml index 3418712abf9656cacd670882c3002cf50b3737d7..e635797cfbe042c327066494a36c3552f6736be1 100644 --- a/crates/open_path_prompt/Cargo.toml +++ b/crates/open_path_prompt/Cargo.toml @@ -24,6 +24,7 @@ editor = {workspace = true, features = ["test-support"]} gpui = {workspace = true, features = ["test-support"]} serde_json.workspace = true theme = {workspace = true, features = ["test-support"]} +theme_settings.workspace = true workspace = {workspace = true, features = ["test-support"]} [lints] diff --git a/crates/open_path_prompt/src/open_path_prompt_tests.rs b/crates/open_path_prompt/src/open_path_prompt_tests.rs index eba3a3e03be55c210f7b4ebd4fad5abc3842e74b..3acd74bdc456b8616229d30ea1da343073685e30 100644 --- a/crates/open_path_prompt/src/open_path_prompt_tests.rs +++ b/crates/open_path_prompt/src/open_path_prompt_tests.rs @@ -410,7 +410,7 @@ async fn test_open_path_prompt_with_show_hidden(cx: &mut TestAppContext) { fn init_test(cx: &mut TestAppContext) -> Arc { cx.update(|cx| { let state = AppState::test(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); state diff --git a/crates/outline/Cargo.toml b/crates/outline/Cargo.toml index 79559e03e8b2339fd8b4473d9e06ca6ff47b2b8c..2ce031bd4605e6c5dfc32e7f76be7493924af825 100644 --- a/crates/outline/Cargo.toml +++ b/crates/outline/Cargo.toml @@ -22,6 +22,7 @@ picker.workspace = true settings.workspace = true smol.workspace = true theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true diff --git a/crates/outline/src/outline.rs b/crates/outline/src/outline.rs index 4fb30cec9898534c8c72a83eb7634588ab78f73f..a03c87d9f68e41dd29d9d614f714db47083831ef 100644 --- a/crates/outline/src/outline.rs +++ b/crates/outline/src/outline.rs @@ -14,7 +14,8 @@ use language::{Outline, OutlineItem}; use ordered_float::OrderedFloat; use picker::{Picker, PickerDelegate}; use settings::Settings; -use theme::{ActiveTheme, ThemeSettings}; +use theme::ActiveTheme; +use theme_settings::ThemeSettings; use ui::{ListItem, ListItemSpacing, prelude::*}; use util::ResultExt; use workspace::{DismissDecision, ModalView}; diff --git a/crates/outline_panel/Cargo.toml b/crates/outline_panel/Cargo.toml index fbcbd7ba74f42fc86976bb090102b86802cd4a1b..e88a0262907fcb22d1b954f25a5e74d8307bd174 100644 --- a/crates/outline_panel/Cargo.toml +++ b/crates/outline_panel/Cargo.toml @@ -33,6 +33,7 @@ settings.workspace = true smallvec.workspace = true smol.workspace = true theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index c4d491db923b4151855d2c45461e370d061537ab..aa6f89cb8c11c40d4121ab12720069ee7fe66844 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -23,8 +23,9 @@ use gpui::{ uniform_list, }; use itertools::Itertools; -use language::language_settings::language_settings; +use language::language_settings::LanguageSettings; use language::{Anchor, BufferId, BufferSnapshot, OffsetRangeExt, OutlineItem}; + use menu::{Cancel, SelectFirst, SelectLast, SelectNext, SelectPrevious}; use std::{ cmp, @@ -46,7 +47,8 @@ use search::{BufferSearchBar, ProjectSearchView}; use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsStore}; use smol::channel; -use theme::{SyntaxTheme, ThemeSettings}; +use theme::SyntaxTheme; +use theme_settings::ThemeSettings; use ui::{ ContextMenu, FluentBuilder, HighlightedLabel, IconButton, IconButtonShape, IndentGuideColors, IndentGuideLayout, ListItem, ScrollAxes, Scrollbars, Tab, Tooltip, WithScrollbar, prelude::*, @@ -60,6 +62,8 @@ use workspace::{ }; use worktree::{Entry, ProjectEntryId, WorktreeId}; +use crate::outline_panel_settings::OutlinePanelSettingsScrollbarProxy; + actions!( outline_panel, [ @@ -108,7 +112,6 @@ type HighlightStyleData = Arc, HighlightStyle)>>>; pub struct OutlinePanel { fs: Arc, - width: Option, project: Entity, workspace: WeakEntity, active: bool, @@ -237,7 +240,8 @@ impl SearchState { } let style = chunk .syntax_highlight_id - .and_then(|highlight| highlight.style(&theme)); + .and_then(|highlight| theme.get(highlight).cloned()); + if let Some(style) = style { let start = context_text.len(); let end = start + chunk.text.len(); @@ -663,7 +667,6 @@ pub enum Event { #[derive(Serialize, Deserialize)] struct SerializedOutlinePanel { - width: Option, active: Option, } @@ -710,12 +713,7 @@ impl OutlinePanel { workspace.update_in(&mut cx, |workspace, window, cx| { let panel = Self::new(workspace, serialized_panel.as_ref(), window, cx); - if let Some(serialized_panel) = serialized_panel { - panel.update(cx, |panel, cx| { - panel.width = serialized_panel.width.map(|px| px.round()); - cx.notify(); - }); - } + panel.update(cx, |_, cx| cx.notify()); panel }) } @@ -862,12 +860,8 @@ impl OutlinePanel { .read(cx) .buffer_for_id(*buffer_id, cx)?; let buffer = buffer.read(cx); - let doc_symbols = language_settings( - buffer.language().map(|l| l.name()), - buffer.file(), - cx, - ) - .document_symbols; + let doc_symbols = + LanguageSettings::for_buffer(buffer, cx).document_symbols; Some((*buffer_id, doc_symbols)) }) .collect(); @@ -911,7 +905,6 @@ impl OutlinePanel { unfolded_dirs: HashMap::default(), selected_entry: SelectedEntry::None, context_menu: None, - width: None, active_item: None, pending_serialization: Task::ready(None), new_entries_for_fs_update: HashSet::default(), @@ -958,14 +951,13 @@ impl OutlinePanel { else { return; }; - let width = self.width; - let active = Some(self.active); + let active = self.active.then_some(true); let kvp = KeyValueStore::global(cx); self.pending_serialization = cx.background_spawn( async move { kvp.write_kvp( serialization_key, - serde_json::to_string(&SerializedOutlinePanel { width, active })?, + serde_json::to_string(&SerializedOutlinePanel { active })?, ) .await?; anyhow::Ok(()) @@ -3403,9 +3395,16 @@ impl OutlinePanel { selection_display_point - outline_range.end }; + // An outline item's range can extend to the same row the next + // item starts on, so when the cursor is at the start of that + // row, prefer the item that starts there over any item whose + // range merely overlaps that row. + let cursor_not_at_outline_start = outline_range.start != selection_display_point; ( + cursor_not_at_outline_start, cmp::Reverse(outline.depth), - distance_from_start + distance_from_end, + distance_from_start, + distance_from_end, ) }) .map(|(_, (_, outline))| *outline) @@ -4808,7 +4807,7 @@ impl OutlinePanel { .size_full() .child(list_contents.size_full().flex_shrink()) .custom_scrollbars( - Scrollbars::for_settings::() + Scrollbars::for_settings::() .tracked_scroll_handle(&self.scroll_handle.clone()) .with_track_along( ScrollAxes::Horizontal, @@ -5000,17 +4999,8 @@ impl Panel for OutlinePanel { }); } - fn size(&self, _: &Window, cx: &App) -> Pixels { - self.width - .unwrap_or_else(|| OutlinePanelSettings::get_global(cx).default_width) - } - - fn set_size(&mut self, size: Option, window: &mut Window, cx: &mut Context) { - self.width = size; - cx.notify(); - cx.defer_in(window, |this, _, cx| { - this.serialize(cx); - }); + fn default_size(&self, _: &Window, cx: &App) -> Pixels { + OutlinePanelSettings::get_global(cx).default_width } fn icon(&self, _: &Window, cx: &App) -> Option { @@ -5069,7 +5059,7 @@ impl Panel for OutlinePanel { } fn activation_priority(&self) -> u32 { - 5 + 6 } } @@ -5380,7 +5370,7 @@ impl GenerationState { mod tests { use db::indoc; use gpui::{TestAppContext, UpdateGlobal, VisualTestContext, WindowHandle}; - use language::{self, FakeLspAdapter, rust_lang}; + use language::{self, FakeLspAdapter, markdown_lang, rust_lang}; use pretty_assertions::assert_eq; use project::FakeFs; use search::{ @@ -6919,7 +6909,7 @@ outline: struct OutlineEntryExcerpt let settings = SettingsStore::test(cx); cx.set_global(settings); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); project_search::init(cx); @@ -8108,4 +8098,110 @@ outline: struct Foo <==== selected ); }); } + + #[gpui::test] + async fn test_markdown_outline_selection_at_heading_boundaries(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + "/test", + json!({ + "doc.md": indoc!(" + # Section A + + ## Sub Section A + + ## Sub Section B + + # Section B + + ") + }), + ) + .await; + + let project = Project::test(fs.clone(), [Path::new("/test")], cx).await; + project.read_with(cx, |project, _| project.languages().add(markdown_lang())); + let (window, workspace) = add_outline_panel(&project, cx).await; + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let outline_panel = outline_panel(&workspace, cx); + outline_panel.update_in(cx, |outline_panel, window, cx| { + outline_panel.set_active(true, window, cx) + }); + + let editor = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_abs_path( + PathBuf::from("/test/doc.md"), + OpenOptions { + visible: Some(OpenVisible::All), + ..Default::default() + }, + window, + cx, + ) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + cx.run_until_parked(); + + outline_panel.update_in(cx, |panel, window, cx| { + panel.update_non_fs_items(window, cx); + panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); + }); + + // Helper function to move the cursor to the first column of a given row + // and return the selected outline entry's text. + let move_cursor_and_get_selection = + |row: u32, cx: &mut VisualTestContext| -> Option { + cx.update(|window, cx| { + editor.update(cx, |editor, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges(Some( + language::Point::new(row, 0)..language::Point::new(row, 0), + )) + }); + }); + }); + + cx.run_until_parked(); + + outline_panel.read_with(cx, |panel, _cx| { + panel.selected_entry().and_then(|entry| match entry { + PanelEntry::Outline(OutlineEntry::Outline(outline)) => { + Some(outline.outline.text.clone()) + } + _ => None, + }) + }) + }; + + assert_eq!( + move_cursor_and_get_selection(0, cx).as_deref(), + Some("# Section A"), + "Cursor at row 0 should select '# Section A'" + ); + + assert_eq!( + move_cursor_and_get_selection(2, cx).as_deref(), + Some("## Sub Section A"), + "Cursor at row 2 should select '## Sub Section A'" + ); + + assert_eq!( + move_cursor_and_get_selection(4, cx).as_deref(), + Some("## Sub Section B"), + "Cursor at row 4 should select '## Sub Section B'" + ); + + assert_eq!( + move_cursor_and_get_selection(6, cx).as_deref(), + Some("# Section B"), + "Cursor at row 6 should select '# Section B'" + ); + } } diff --git a/crates/outline_panel/src/outline_panel_settings.rs b/crates/outline_panel/src/outline_panel_settings.rs index b744ca6399dd16ad216d1cb4c6dda5e1d93baa4b..18f52e512da24ee986e9fe3f49ff5e3cd08c8b23 100644 --- a/crates/outline_panel/src/outline_panel_settings.rs +++ b/crates/outline_panel/src/outline_panel_settings.rs @@ -1,4 +1,4 @@ -use editor::EditorSettings; +use editor::{EditorSettings, ui_scrollbar_settings_from_raw}; use gpui::{App, Pixels}; use settings::RegisterSetting; pub use settings::{DockSide, Settings, ShowIndentGuides}; @@ -33,9 +33,13 @@ pub struct IndentGuidesSettings { pub show: ShowIndentGuides, } -impl ScrollbarVisibility for OutlinePanelSettings { +#[derive(Default)] +pub(crate) struct OutlinePanelSettingsScrollbarProxy; + +impl ScrollbarVisibility for OutlinePanelSettingsScrollbarProxy { fn visibility(&self, cx: &App) -> ShowScrollbar { - self.scrollbar + OutlinePanelSettings::get_global(cx) + .scrollbar .show .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show) } @@ -65,7 +69,11 @@ impl Settings for OutlinePanelSettings { auto_reveal_entries: panel.auto_reveal_entries.unwrap(), auto_fold_dirs: panel.auto_fold_dirs.unwrap(), scrollbar: ScrollbarSettings { - show: panel.scrollbar.unwrap().show.map(Into::into), + show: panel + .scrollbar + .unwrap() + .show + .map(ui_scrollbar_settings_from_raw), }, expand_outlines_with_depth: panel.expand_outlines_with_depth.unwrap(), } diff --git a/crates/picker/Cargo.toml b/crates/picker/Cargo.toml index 8c76aa746453866755be322df576a519ba147b24..7c01e8bfaa13447eccb42f42f69a09b332193676 100644 --- a/crates/picker/Cargo.toml +++ b/crates/picker/Cargo.toml @@ -22,6 +22,7 @@ menu.workspace = true schemars.workspace = true serde.workspace = true theme.workspace = true +theme_settings.workspace = true ui.workspace = true ui_input.workspace = true workspace.workspace = true diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index 2eb8d71bd4aa14960f8388859c974214f3e85c72..1e529cd53f2d2527af8525886d11dbcddbf33a34 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -16,7 +16,7 @@ use serde::Deserialize; use std::{ cell::Cell, cell::RefCell, collections::HashMap, ops::Range, rc::Rc, sync::Arc, time::Duration, }; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ Color, Divider, DocumentationAside, DocumentationSide, Label, ListItem, ListItemSpacing, ScrollAxes, Scrollbars, WithScrollbar, prelude::*, utils::WithRemSize, v_flex, @@ -955,7 +955,7 @@ mod tests { cx.update(|cx| { let store = settings::SettingsStore::test(cx); cx.set_global(store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); }); } diff --git a/crates/platform_title_bar/Cargo.toml b/crates/platform_title_bar/Cargo.toml index 43ad6166929bc463edbea878941ba19ffe2ea3a9..7ecc624a3224025749b65d631031e3e8bf639052 100644 --- a/crates/platform_title_bar/Cargo.toml +++ b/crates/platform_title_bar/Cargo.toml @@ -19,6 +19,7 @@ project.workspace = true settings.workspace = true smallvec.workspace = true theme.workspace = true +theme_settings.workspace = true ui.workspace = true workspace.workspace = true zed_actions.workspace = true diff --git a/crates/platform_title_bar/src/platform_title_bar.rs b/crates/platform_title_bar/src/platform_title_bar.rs index f315aa411896c5fd80e83da5602000a2b24c2719..c009d146403b21e592457d0c9a3f24819e80d642 100644 --- a/crates/platform_title_bar/src/platform_title_bar.rs +++ b/crates/platform_title_bar/src/platform_title_bar.rs @@ -1,11 +1,11 @@ -mod platforms; +pub mod platforms; mod system_window_tabs; use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt}; use gpui::{ - AnyElement, App, Context, Decorations, Entity, Hsla, InteractiveElement, IntoElement, - MouseButton, ParentElement, StatefulInteractiveElement, Styled, Window, WindowControlArea, div, - px, + Action, AnyElement, App, Context, Decorations, Entity, Hsla, InteractiveElement, IntoElement, + MouseButton, ParentElement, StatefulInteractiveElement, Styled, WeakEntity, Window, + WindowButtonLayout, WindowControlArea, div, px, }; use project::DisableAiSettings; use settings::Settings; @@ -15,6 +15,7 @@ use ui::{ prelude::*, utils::{TRAFFIC_LIGHT_PADDING, platform_title_bar_height}, }; +use workspace::{MultiWorkspace, SidebarRenderState, SidebarSide}; use crate::{ platforms::{platform_linux, platform_windows}, @@ -31,7 +32,8 @@ pub struct PlatformTitleBar { children: SmallVec<[AnyElement; 2]>, should_move: bool, system_window_tabs: Entity, - workspace_sidebar_open: bool, + button_layout: Option, + multi_workspace: Option>, } impl PlatformTitleBar { @@ -45,10 +47,20 @@ impl PlatformTitleBar { children: SmallVec::new(), should_move: false, system_window_tabs, - workspace_sidebar_open: false, + button_layout: None, + multi_workspace: None, } } + pub fn with_multi_workspace(mut self, multi_workspace: WeakEntity) -> Self { + self.multi_workspace = Some(multi_workspace); + self + } + + pub fn set_multi_workspace(&mut self, multi_workspace: WeakEntity) { + self.multi_workspace = Some(multi_workspace); + } + pub fn title_bar_color(&self, window: &mut Window, cx: &mut Context) -> Hsla { if cfg!(any(target_os = "linux", target_os = "freebsd")) { if window.is_window_active() && !self.should_move { @@ -68,17 +80,34 @@ impl PlatformTitleBar { self.children = children.into_iter().collect(); } - pub fn init(cx: &mut App) { - SystemWindowTabs::init(cx); + pub fn set_button_layout(&mut self, button_layout: Option) { + self.button_layout = button_layout; } - pub fn is_workspace_sidebar_open(&self) -> bool { - self.workspace_sidebar_open + fn effective_button_layout( + &self, + decorations: &Decorations, + cx: &App, + ) -> Option { + if self.platform_style == PlatformStyle::Linux + && matches!(decorations, Decorations::Client { .. }) + { + self.button_layout.or_else(|| cx.button_layout()) + } else { + None + } + } + + pub fn init(cx: &mut App) { + SystemWindowTabs::init(cx); } - pub fn set_workspace_sidebar_open(&mut self, open: bool, cx: &mut Context) { - self.workspace_sidebar_open = open; - cx.notify(); + fn sidebar_render_state(&self, cx: &App) -> SidebarRenderState { + self.multi_workspace + .as_ref() + .and_then(|mw| mw.upgrade()) + .map(|mw| mw.read(cx).sidebar_render_state(cx)) + .unwrap_or_default() } pub fn is_multi_workspace_enabled(cx: &App) -> bool { @@ -86,6 +115,72 @@ impl PlatformTitleBar { } } +/// Renders the platform-appropriate left-side window controls (e.g. Ubuntu/GNOME close button). +/// +/// Only relevant on Linux with client-side decorations when the window manager +/// places controls on the left. +pub fn render_left_window_controls( + button_layout: Option, + close_action: Box, + window: &Window, +) -> Option { + if PlatformStyle::platform() != PlatformStyle::Linux { + return None; + } + if !matches!(window.window_decorations(), Decorations::Client { .. }) { + return None; + } + let button_layout = button_layout?; + if button_layout.left[0].is_none() { + return None; + } + Some( + platform_linux::LinuxWindowControls::new( + "left-window-controls", + button_layout.left, + close_action, + ) + .into_any_element(), + ) +} + +/// Renders the platform-appropriate right-side window controls (close, minimize, maximize). +/// +/// Returns `None` on Mac or when the platform doesn't need custom controls +/// (e.g. Linux with server-side decorations). +pub fn render_right_window_controls( + button_layout: Option, + close_action: Box, + window: &Window, +) -> Option { + let decorations = window.window_decorations(); + let height = platform_title_bar_height(window); + + match PlatformStyle::platform() { + PlatformStyle::Linux => { + if !matches!(decorations, Decorations::Client { .. }) { + return None; + } + let button_layout = button_layout?; + if button_layout.right[0].is_none() { + return None; + } + Some( + platform_linux::LinuxWindowControls::new( + "right-window-controls", + button_layout.right, + close_action, + ) + .into_any_element(), + ) + } + PlatformStyle::Windows => { + Some(platform_windows::WindowsWindowControls::new(height).into_any_element()) + } + PlatformStyle::Mac => None, + } +} + impl Render for PlatformTitleBar { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let supported_controls = window.window_controls(); @@ -95,8 +190,8 @@ impl Render for PlatformTitleBar { let close_action = Box::new(workspace::CloseWindow); let children = mem::take(&mut self.children); - let is_multiworkspace_sidebar_open = - PlatformTitleBar::is_multi_workspace_enabled(cx) && self.is_workspace_sidebar_open(); + let button_layout = self.effective_button_layout(&decorations, cx); + let sidebar = self.sidebar_render_state(cx); let title_bar = h_flex() .window_control_area(WindowControlArea::Drag) @@ -144,12 +239,23 @@ impl Render for PlatformTitleBar { }) }) .map(|this| { + let show_left_controls = !(sidebar.open && sidebar.side == SidebarSide::Left); + if window.is_fullscreen() { this.pl_2() - } else if self.platform_style == PlatformStyle::Mac - && !is_multiworkspace_sidebar_open - { + } else if self.platform_style == PlatformStyle::Mac && show_left_controls { this.pl(px(TRAFFIC_LIGHT_PADDING)) + } else if let Some(controls) = show_left_controls + .then(|| { + render_left_window_controls( + button_layout, + close_action.as_ref().boxed_clone(), + window, + ) + }) + .flatten() + { + this.child(controls) } else { this.pl_2() } @@ -157,11 +263,14 @@ impl Render for PlatformTitleBar { .map(|el| match decorations { Decorations::Server => el, Decorations::Client { tiling, .. } => el - .when(!(tiling.top || tiling.right), |el| { - el.rounded_tr(theme::CLIENT_SIDE_DECORATION_ROUNDING) - }) .when( - !(tiling.top || tiling.left) && !is_multiworkspace_sidebar_open, + !(tiling.top || tiling.right) + && !(sidebar.open && sidebar.side == SidebarSide::Right), + |el| el.rounded_tr(theme::CLIENT_SIDE_DECORATION_ROUNDING), + ) + .when( + !(tiling.top || tiling.left) + && !(sidebar.open && sidebar.side == SidebarSide::Left), |el| el.rounded_tl(theme::CLIENT_SIDE_DECORATION_ROUNDING), ) // this border is to avoid a transparent gap in the rounded corners @@ -184,25 +293,30 @@ impl Render for PlatformTitleBar { .children(children), ) .when(!window.is_fullscreen(), |title_bar| { - match self.platform_style { - PlatformStyle::Mac => title_bar, - PlatformStyle::Linux => { - if matches!(decorations, Decorations::Client { .. }) { - title_bar - .child(platform_linux::LinuxWindowControls::new(close_action)) - .when(supported_controls.window_menu, |titlebar| { - titlebar - .on_mouse_down(MouseButton::Right, move |ev, window, _| { - window.show_window_menu(ev.position) - }) - }) - } else { - title_bar - } - } - PlatformStyle::Windows => { - title_bar.child(platform_windows::WindowsWindowControls::new(height)) - } + let show_right_controls = !(sidebar.open && sidebar.side == SidebarSide::Right); + + let title_bar = title_bar.children( + show_right_controls + .then(|| { + render_right_window_controls( + button_layout, + close_action.as_ref().boxed_clone(), + window, + ) + }) + .flatten(), + ); + + if self.platform_style == PlatformStyle::Linux + && matches!(decorations, Decorations::Client { .. }) + { + title_bar.when(supported_controls.window_menu, |titlebar| { + titlebar.on_mouse_down(MouseButton::Right, move |ev, window, _| { + window.show_window_menu(ev.position) + }) + }) + } else { + title_bar } }); diff --git a/crates/platform_title_bar/src/platforms/platform_linux.rs b/crates/platform_title_bar/src/platforms/platform_linux.rs index 0e7af80f80e8dcbea03a3b3375f1e4dfd7ca2f37..8dd6c6f6787ddab703963188beaaae1288ca6d6f 100644 --- a/crates/platform_title_bar/src/platforms/platform_linux.rs +++ b/crates/platform_title_bar/src/platforms/platform_linux.rs @@ -1,46 +1,83 @@ -use gpui::{Action, Hsla, MouseButton, prelude::*, svg}; +use gpui::{ + Action, AnyElement, Hsla, MAX_BUTTONS_PER_SIDE, MouseButton, WindowButton, prelude::*, svg, +}; use ui::prelude::*; #[derive(IntoElement)] pub struct LinuxWindowControls { - close_window_action: Box, + id: &'static str, + buttons: [Option; MAX_BUTTONS_PER_SIDE], + close_action: Box, } impl LinuxWindowControls { - pub fn new(close_window_action: Box) -> Self { + pub fn new( + id: &'static str, + buttons: [Option; MAX_BUTTONS_PER_SIDE], + close_action: Box, + ) -> Self { Self { - close_window_action, + id, + buttons, + close_action, } } } impl RenderOnce for LinuxWindowControls { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let is_maximized = window.is_maximized(); + let supported_controls = window.window_controls(); + let button_elements: Vec = self + .buttons + .iter() + .filter_map(|b| *b) + .filter(|button| match button { + WindowButton::Minimize => supported_controls.minimize, + WindowButton::Maximize => supported_controls.maximize, + WindowButton::Close => true, + }) + .map(|button| { + create_window_button(button, button.id(), is_maximized, &*self.close_action, cx) + }) + .collect(); + h_flex() - .id("generic-window-controls") - .px_3() - .gap_3() - .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) - .child(WindowControl::new( - "minimize", - WindowControlType::Minimize, - cx, - )) - .child(WindowControl::new( - "maximize-or-restore", - if window.is_maximized() { - WindowControlType::Restore - } else { - WindowControlType::Maximize - }, - cx, - )) - .child(WindowControl::new_close( - "close", - WindowControlType::Close, - self.close_window_action, - cx, - )) + .id(self.id) + .when(!button_elements.is_empty(), |el| { + el.gap_3() + .px_3() + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .children(button_elements) + }) + } +} + +fn create_window_button( + button: WindowButton, + id: &'static str, + is_maximized: bool, + close_action: &dyn Action, + cx: &mut App, +) -> AnyElement { + match button { + WindowButton::Minimize => { + WindowControl::new(id, WindowControlType::Minimize, cx).into_any_element() + } + WindowButton::Maximize => WindowControl::new( + id, + if is_maximized { + WindowControlType::Restore + } else { + WindowControlType::Maximize + }, + cx, + ) + .into_any_element(), + WindowButton::Close => { + WindowControl::new_close(id, WindowControlType::Close, close_action.boxed_clone(), cx) + .into_any_element() + } } } diff --git a/crates/platform_title_bar/src/system_window_tabs.rs b/crates/platform_title_bar/src/system_window_tabs.rs index a9bf46cc4f9f33586d1129dec1c64a67f1e42198..f465d2ab8476eb1c834f32e1d0eb72cc468dc230 100644 --- a/crates/platform_title_bar/src/system_window_tabs.rs +++ b/crates/platform_title_bar/src/system_window_tabs.rs @@ -5,7 +5,7 @@ use gpui::{ Styled, SystemWindowTab, SystemWindowTabController, Window, WindowId, actions, canvas, div, }; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ Color, ContextMenu, DynamicSpacing, IconButton, IconButtonShape, IconName, IconSize, Label, LabelSize, Tab, h_flex, prelude::*, right_click_menu, diff --git a/crates/prettier/src/prettier.rs b/crates/prettier/src/prettier.rs index 90f512a5931fa89ac9b8a2216091f3633f872b6b..b0fd57f6980ca0f0f4d6d95ecd0e994ab80b2016 100644 --- a/crates/prettier/src/prettier.rs +++ b/crates/prettier/src/prettier.rs @@ -2,8 +2,8 @@ use anyhow::Context as _; use collections::{HashMap, HashSet}; use fs::Fs; use gpui::{AsyncApp, Entity}; -use language::language_settings::PrettierSettings; -use language::{Buffer, Diff, Language, language_settings::language_settings}; +use language::language_settings::{LanguageSettings, PrettierSettings}; +use language::{Buffer, Diff, Language}; use lsp::{LanguageServer, LanguageServerId}; use node_runtime::NodeRuntime; use paths::default_prettier_dir; @@ -356,7 +356,7 @@ impl Prettier { let params = buffer .update(cx, |buffer, cx| { let buffer_language = buffer.language().map(|language| language.as_ref()); - let language_settings = language_settings(buffer_language.map(|l| l.name()), buffer.file(), cx); + let language_settings = LanguageSettings::for_buffer(&buffer, cx); let prettier_settings = &language_settings.prettier; anyhow::ensure!( prettier_settings.allowed, @@ -505,11 +505,7 @@ impl Prettier { let buffer_language = buffer.language().map(|language| language.as_ref()); - let language_settings = language_settings( - buffer_language.map(|l| l.name()), - buffer.file(), - cx, - ); + let language_settings = LanguageSettings::for_buffer(buffer, cx); let prettier_settings = &language_settings.prettier; let parser = prettier_parser_name( buffer_path.as_deref(), diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml index dd9c8009597e9e1995260021d245b02e06732ca3..ccffbd29f4bd03b0d4bb0a070f4229a517597468 100644 --- a/crates/project/Cargo.toml +++ b/crates/project/Cargo.toml @@ -92,6 +92,7 @@ terminal.workspace = true text.workspace = true toml.workspace = true url.workspace = true +percent-encoding.workspace = true util.workspace = true watch.workspace = true wax.workspace = true diff --git a/crates/project/src/agent_server_store.rs b/crates/project/src/agent_server_store.rs index a9003e8567e6bf57ea18f8d70619e9417a626f43..218b1841d1178a5ebba29f4935e4699189567fde 100644 --- a/crates/project/src/agent_server_store.rs +++ b/crates/project/src/agent_server_store.rs @@ -12,6 +12,7 @@ use fs::Fs; use gpui::{AsyncApp, Context, Entity, EventEmitter, SharedString, Subscription, Task}; use http_client::{HttpClient, github::AssetKind}; use node_runtime::NodeRuntime; +use percent_encoding::percent_decode_str; use remote::RemoteClient; use rpc::{ AnyProtoClient, TypedEnvelope, @@ -22,11 +23,14 @@ use serde::{Deserialize, Serialize}; use settings::{RegisterSetting, SettingsStore}; use sha2::{Digest, Sha256}; use task::Shell; +use url::Url; use util::{ResultExt as _, debug_panic}; use crate::ProjectEnvironment; use crate::agent_registry_store::{AgentRegistryStore, RegistryAgent, RegistryTargetConfig}; +use crate::worktree_store::WorktreeStore; + #[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema)] pub struct AgentServerCommand { #[serde(rename = "command")] @@ -149,6 +153,7 @@ enum AgentServerStoreState { Remote { project_id: u64, upstream_client: Entity, + worktree_store: Entity, }, Collab, } @@ -231,6 +236,7 @@ impl AgentServerStore { AgentServerStoreState::Remote { project_id, upstream_client, + worktree_store, } => { let mut agents = vec![]; for (ext_id, manifest) in manifests { @@ -256,6 +262,7 @@ impl AgentServerStore { Box::new(RemoteExternalAgentServer { project_id: *project_id, upstream_client: upstream_client.clone(), + worktree_store: worktree_store.clone(), name: agent_server_name.clone(), new_version_available_tx: None, }) @@ -300,9 +307,9 @@ impl AgentServerStore { cx.emit(AgentServersUpdated); } - pub fn agent_icon(&self, name: &AgentId) -> Option { + pub fn agent_icon(&self, id: &AgentId) -> Option { self.external_agents - .get(name) + .get(id) .and_then(|entry| entry.icon.clone()) } @@ -612,11 +619,16 @@ impl AgentServerStore { this } - pub(crate) fn remote(project_id: u64, upstream_client: Entity) -> Self { + pub(crate) fn remote( + project_id: u64, + upstream_client: Entity, + worktree_store: Entity, + ) -> Self { Self { state: AgentServerStoreState::Remote { project_id, upstream_client, + worktree_store, }, external_agents: HashMap::default(), } @@ -751,8 +763,10 @@ impl AgentServerStore { .env .map(|env| env.into_iter().collect()) .unwrap_or_default(), - // root_dir and login are no longer used, but returned for backwards compatibility - root_dir: paths::home_dir().to_string_lossy().to_string(), + root_dir: envelope + .payload + .root_dir + .unwrap_or_else(|| paths::home_dir().to_string_lossy().to_string()), login: None, }) } @@ -766,6 +780,7 @@ impl AgentServerStore { let AgentServerStoreState::Remote { project_id, upstream_client, + worktree_store, } = &this.state else { debug_panic!( @@ -810,6 +825,7 @@ impl AgentServerStore { let agent = RemoteExternalAgentServer { project_id: *project_id, upstream_client: upstream_client.clone(), + worktree_store: worktree_store.clone(), name: agent_id.clone(), new_version_available_tx: new_version_available_txs .remove(&agent_id) @@ -906,6 +922,7 @@ impl AgentServerStore { struct RemoteExternalAgentServer { project_id: u64, upstream_client: Entity, + worktree_store: Entity, name: AgentId, new_version_available_tx: Option>>, } @@ -920,8 +937,16 @@ impl ExternalAgentServer for RemoteExternalAgentServer { let project_id = self.project_id; let name = self.name.to_string(); let upstream_client = self.upstream_client.downgrade(); + let worktree_store = self.worktree_store.clone(); self.new_version_available_tx = new_version_available_tx; cx.spawn(async move |cx| { + let root_dir = worktree_store.read_with(cx, |worktree_store, cx| { + crate::Project::default_visible_worktree_paths(worktree_store, cx) + .into_iter() + .next() + .map(|path| path.display().to_string()) + }); + let mut response = upstream_client .update(cx, |upstream_client, _| { upstream_client @@ -929,7 +954,7 @@ impl ExternalAgentServer for RemoteExternalAgentServer { .request(proto::GetAgentServerCommand { project_id, name, - root_dir: None, + root_dir, }) })? .await?; @@ -958,6 +983,58 @@ impl ExternalAgentServer for RemoteExternalAgentServer { } } +fn asset_kind_for_archive_url(archive_url: &str) -> Result { + let archive_path = Url::parse(archive_url) + .ok() + .map(|url| url.path().to_string()) + .unwrap_or_else(|| archive_url.to_string()); + + if archive_path.ends_with(".zip") { + Ok(AssetKind::Zip) + } else if archive_path.ends_with(".tar.gz") || archive_path.ends_with(".tgz") { + Ok(AssetKind::TarGz) + } else if archive_path.ends_with(".tar.bz2") || archive_path.ends_with(".tbz2") { + Ok(AssetKind::TarBz2) + } else { + bail!("unsupported archive type in URL: {archive_url}"); + } +} + +struct GithubReleaseArchive { + repo_name_with_owner: String, + tag: String, + asset_name: String, +} + +fn github_release_archive_from_url(archive_url: &str) -> Option { + fn decode_path_segment(segment: &str) -> Option { + percent_decode_str(segment) + .decode_utf8() + .ok() + .map(|segment| segment.into_owned()) + } + + let url = Url::parse(archive_url).ok()?; + if url.scheme() != "https" || url.host_str()? != "github.com" { + return None; + } + + let segments = url.path_segments()?.collect::>(); + if segments.len() < 6 || segments[2] != "releases" || segments[3] != "download" { + return None; + } + + Some(GithubReleaseArchive { + repo_name_with_owner: format!("{}/{}", segments[0], segments[1]), + tag: decode_path_segment(segments[4])?, + asset_name: segments[5..] + .iter() + .map(|segment| decode_path_segment(segment)) + .collect::>>()? + .join("/"), + }) +} + pub struct LocalExtensionArchiveAgent { pub fs: Arc, pub http_client: Arc, @@ -1052,41 +1129,27 @@ impl ExternalAgentServer for LocalExtensionArchiveAgent { let sha256 = if let Some(provided_sha) = &target_config.sha256 { // Use provided SHA256 Some(provided_sha.clone()) - } else if archive_url.starts_with("https://github.com/") { + } else if let Some(github_archive) = github_release_archive_from_url(archive_url) { // Try to fetch SHA256 from GitHub API - // Parse URL to extract repo and tag/file info - // Format: https://github.com/owner/repo/releases/download/tag/file.zip - if let Some(caps) = archive_url.strip_prefix("https://github.com/") { - let parts: Vec<&str> = caps.split('/').collect(); - if parts.len() >= 6 && parts[2] == "releases" && parts[3] == "download" { - let repo = format!("{}/{}", parts[0], parts[1]); - let tag = parts[4]; - let filename = parts[5..].join("/"); - - // Try to get release info from GitHub - if let Ok(release) = ::http_client::github::get_release_by_tag_name( - &repo, - tag, - http_client.clone(), - ) - .await - { - // Find matching asset - if let Some(asset) = - release.assets.iter().find(|a| a.name == filename) - { - // Strip "sha256:" prefix if present - asset.digest.as_ref().map(|d| { - d.strip_prefix("sha256:") - .map(|s| s.to_string()) - .unwrap_or_else(|| d.clone()) - }) - } else { - None - } - } else { - None - } + if let Ok(release) = ::http_client::github::get_release_by_tag_name( + &github_archive.repo_name_with_owner, + &github_archive.tag, + http_client.clone(), + ) + .await + { + // Find matching asset + if let Some(asset) = release + .assets + .iter() + .find(|a| a.name == github_archive.asset_name) + { + // Strip "sha256:" prefix if present + asset.digest.as_ref().map(|d| { + d.strip_prefix("sha256:") + .map(|s| s.to_string()) + .unwrap_or_else(|| d.clone()) + }) } else { None } @@ -1097,14 +1160,7 @@ impl ExternalAgentServer for LocalExtensionArchiveAgent { None }; - // Determine archive type from URL - let asset_kind = if archive_url.ends_with(".zip") { - AssetKind::Zip - } else if archive_url.ends_with(".tar.gz") || archive_url.ends_with(".tgz") { - AssetKind::TarGz - } else { - anyhow::bail!("unsupported archive type in URL: {}", archive_url); - }; + let asset_kind = asset_kind_for_archive_url(archive_url)?; // Download and extract ::http_client::github_download::download_server_binary( @@ -1245,35 +1301,24 @@ impl ExternalAgentServer for LocalRegistryArchiveAgent { if !fs.is_dir(&version_dir).await { let sha256 = if let Some(provided_sha) = &target_config.sha256 { Some(provided_sha.clone()) - } else if archive_url.starts_with("https://github.com/") { - if let Some(caps) = archive_url.strip_prefix("https://github.com/") { - let parts: Vec<&str> = caps.split('/').collect(); - if parts.len() >= 6 && parts[2] == "releases" && parts[3] == "download" { - let repo = format!("{}/{}", parts[0], parts[1]); - let tag = parts[4]; - let filename = parts[5..].join("/"); - - if let Ok(release) = ::http_client::github::get_release_by_tag_name( - &repo, - tag, - http_client.clone(), - ) - .await - { - if let Some(asset) = - release.assets.iter().find(|a| a.name == filename) - { - asset.digest.as_ref().and_then(|d| { - d.strip_prefix("sha256:") - .map(|s| s.to_string()) - .or_else(|| Some(d.clone())) - }) - } else { - None - } - } else { - None - } + } else if let Some(github_archive) = github_release_archive_from_url(archive_url) { + if let Ok(release) = ::http_client::github::get_release_by_tag_name( + &github_archive.repo_name_with_owner, + &github_archive.tag, + http_client.clone(), + ) + .await + { + if let Some(asset) = release + .assets + .iter() + .find(|a| a.name == github_archive.asset_name) + { + asset.digest.as_ref().and_then(|d| { + d.strip_prefix("sha256:") + .map(|s| s.to_string()) + .or_else(|| Some(d.clone())) + }) } else { None } @@ -1284,13 +1329,7 @@ impl ExternalAgentServer for LocalRegistryArchiveAgent { None }; - let asset_kind = if archive_url.ends_with(".zip") { - AssetKind::Zip - } else if archive_url.ends_with(".tar.gz") || archive_url.ends_with(".tgz") { - AssetKind::TarGz - } else { - anyhow::bail!("unsupported archive type in URL: {}", archive_url); - }; + let asset_kind = asset_kind_for_archive_url(archive_url)?; ::http_client::github_download::download_server_binary( &*http_client, @@ -1644,6 +1683,79 @@ impl CustomAgentServerSettings { } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_supported_archive_suffixes() { + assert!(matches!( + asset_kind_for_archive_url("https://example.com/agent.zip"), + Ok(AssetKind::Zip) + )); + assert!(matches!( + asset_kind_for_archive_url("https://example.com/agent.zip?download=1"), + Ok(AssetKind::Zip) + )); + assert!(matches!( + asset_kind_for_archive_url("https://example.com/agent.tar.gz"), + Ok(AssetKind::TarGz) + )); + assert!(matches!( + asset_kind_for_archive_url("https://example.com/agent.tar.gz?download=1#latest"), + Ok(AssetKind::TarGz) + )); + assert!(matches!( + asset_kind_for_archive_url("https://example.com/agent.tgz"), + Ok(AssetKind::TarGz) + )); + assert!(matches!( + asset_kind_for_archive_url("https://example.com/agent.tgz#download"), + Ok(AssetKind::TarGz) + )); + assert!(matches!( + asset_kind_for_archive_url("https://example.com/agent.tar.bz2"), + Ok(AssetKind::TarBz2) + )); + assert!(matches!( + asset_kind_for_archive_url("https://example.com/agent.tar.bz2?download=1"), + Ok(AssetKind::TarBz2) + )); + assert!(matches!( + asset_kind_for_archive_url("https://example.com/agent.tbz2"), + Ok(AssetKind::TarBz2) + )); + assert!(matches!( + asset_kind_for_archive_url("https://example.com/agent.tbz2#download"), + Ok(AssetKind::TarBz2) + )); + } + + #[test] + fn parses_github_release_archive_urls() { + let github_archive = github_release_archive_from_url( + "https://github.com/owner/repo/releases/download/release%2F2.3.5/agent.tar.bz2?download=1", + ) + .unwrap(); + + assert_eq!(github_archive.repo_name_with_owner, "owner/repo"); + assert_eq!(github_archive.tag, "release/2.3.5"); + assert_eq!(github_archive.asset_name, "agent.tar.bz2"); + } + + #[test] + fn rejects_unsupported_archive_suffixes() { + let error = asset_kind_for_archive_url("https://example.com/agent.tar.xz") + .err() + .map(|error| error.to_string()); + + assert_eq!( + error, + Some("unsupported archive type in URL: https://example.com/agent.tar.xz".to_string()) + ); + } +} + impl From for CustomAgentServerSettings { fn from(value: settings::CustomAgentServerSettings) -> Self { match value { diff --git a/crates/project/src/debugger/test.rs b/crates/project/src/debugger/test.rs index 53b88323e6326fe7d6d74f79a5e92845514c6b61..7ccbafa0e5507e3b7362a31df5170e285d7532f0 100644 --- a/crates/project/src/debugger/test.rs +++ b/crates/project/src/debugger/test.rs @@ -1,3 +1,4 @@ +#![expect(clippy::result_large_err)] use std::{path::Path, sync::Arc}; use dap::client::DebugAdapterClient; diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 3a4653345d7a84e702e657e80a360eeae00385ab..f439c5da157cdcdaec813a1fd63ea119af78cb83 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -34,7 +34,7 @@ use git::{ repository::{ Branch, CommitDetails, CommitDiff, CommitFile, CommitOptions, DiffType, FetchOptions, GitRepository, GitRepositoryCheckpoint, GraphCommitData, InitialGraphCommitData, LogOrder, - LogSource, PushOptions, Remote, RemoteCommandOutput, RepoPath, ResetMode, + LogSource, PushOptions, Remote, RemoteCommandOutput, RepoPath, ResetMode, SearchCommitArgs, UpstreamTrackingStatus, Worktree as GitWorktree, }, stash::{GitStash, StashEntry}, @@ -3706,6 +3706,23 @@ impl RepositorySnapshot { } } + /// The main worktree is the original checkout that other worktrees were + /// created from. + /// + /// For example, if you had both `~/code/zed` and `~/code/worktrees/zed-2`, + /// then `~/code/zed` is the main worktree and `~/code/worktrees/zed-2` is a linked worktree. + pub fn is_main_worktree(&self) -> bool { + self.work_directory_abs_path == self.original_repo_abs_path + } + + /// Returns true if this repository is a linked worktree, that is, one that + /// was created from another worktree. + /// + /// This is by definition the opposite of [`Self::is_main_worktree`]. + pub fn is_linked_worktree(&self) -> bool { + !self.is_main_worktree() + } + pub fn linked_worktrees(&self) -> &[GitWorktree] { &self.linked_worktrees } @@ -4553,6 +4570,32 @@ impl Repository { self.initial_graph_data.get(&(log_source, log_order)) } + pub fn search_commits( + &mut self, + log_source: LogSource, + search_args: SearchCommitArgs, + request_tx: smol::channel::Sender, + cx: &mut Context, + ) { + let repository_state = self.repository_state.clone(); + + cx.background_spawn(async move { + let repo_state = repository_state.await; + + match repo_state { + Ok(RepositoryState::Local(LocalRepositoryState { backend, .. })) => { + backend + .search_commits(log_source, search_args, request_tx) + .await + .log_err(); + } + Ok(RepositoryState::Remote(_)) => {} + Err(_) => {} + }; + }) + .detach(); + } + pub fn graph_data( &mut self, log_source: LogSource, diff --git a/crates/project/src/lsp_command.rs b/crates/project/src/lsp_command.rs index c5cd568fb88c0c1de66adb99bb47f96508a6df04..d4a4f9b04968413c51607f71047752a9b779b79a 100644 --- a/crates/project/src/lsp_command.rs +++ b/crates/project/src/lsp_command.rs @@ -18,7 +18,7 @@ use gpui::{App, AsyncApp, Entity, SharedString, Task, prelude::FluentBuilder}; use language::{ Anchor, Bias, Buffer, BufferSnapshot, CachedLspAdapter, CharKind, CharScopeContext, OffsetRangeExt, PointUtf16, ToOffset, ToPointUtf16, Transaction, Unclipped, - language_settings::{InlayHintKind, LanguageSettings, language_settings}, + language_settings::{InlayHintKind, LanguageSettings}, point_from_lsp, point_to_lsp, proto::{ deserialize_anchor, deserialize_anchor_range, deserialize_version, serialize_anchor, @@ -2936,9 +2936,7 @@ impl LspCommand for OnTypeFormatting { .await?; let options = buffer.update(&mut cx, |buffer, cx| { - lsp_formatting_options( - language_settings(buffer.language().map(|l| l.name()), buffer.file(), cx).as_ref(), - ) + lsp_formatting_options(LanguageSettings::for_buffer(buffer, cx).as_ref()) }); Ok(Self { @@ -3217,8 +3215,9 @@ impl InlayHints { Some(((uri, range), server_id)) => Some(( LanguageServerId(server_id as usize), lsp::Location { - uri: lsp::Uri::from_str(&uri) - .context("invalid uri in hint part {part:?}")?, + uri: lsp::Uri::from_str(&uri).with_context(|| { + format!("invalid uri in hint part {uri:?}") + })?, range: lsp::Range::new( point_to_lsp(PointUtf16::new( range.start.row, diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 9b0a14cbeaa83a1a7591f50fb913096a7d2af248..d36a45692bec13e2ab4c9b21d1ee4da6879ab6fc 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -71,15 +71,14 @@ use http_client::HttpClient; use itertools::Itertools as _; use language::{ Bias, BinaryStatus, Buffer, BufferRow, BufferSnapshot, CachedLspAdapter, Capability, CodeLabel, - Diagnostic, DiagnosticEntry, DiagnosticSet, DiagnosticSourceKind, Diff, File as _, Language, - LanguageName, LanguageRegistry, LocalFile, LspAdapter, LspAdapterDelegate, LspInstaller, - ManifestDelegate, ManifestName, Patch, PointUtf16, TextBufferSnapshot, ToOffset, ToPointUtf16, - Toolchain, Transaction, Unclipped, + CodeLabelExt, Diagnostic, DiagnosticEntry, DiagnosticSet, DiagnosticSourceKind, Diff, + File as _, Language, LanguageName, LanguageRegistry, LocalFile, LspAdapter, LspAdapterDelegate, + LspInstaller, ManifestDelegate, ManifestName, ModelineSettings, Patch, PointUtf16, + TextBufferSnapshot, ToOffset, ToPointUtf16, Toolchain, Transaction, Unclipped, language_settings::{ AllLanguageSettings, FormatOnSave, Formatter, LanguageSettings, all_language_settings, - language_settings, }, - point_to_lsp, + modeline, point_to_lsp, proto::{ deserialize_anchor, deserialize_anchor_range, deserialize_version, serialize_anchor, serialize_anchor_range, serialize_version, @@ -823,15 +822,7 @@ impl LocalLspStore { let adapter = adapter.clone(); if let Some(this) = this.upgrade() { this.update(cx, |this, cx| { - { - let buffer = params - .uri - .to_file_path() - .map(|file_path| this.get_buffer(&file_path, cx)) - .ok() - .flatten(); - adapter.process_diagnostics(&mut params, server_id, buffer); - } + adapter.process_diagnostics(&mut params, server_id); this.merge_lsp_diagnostics( DiagnosticSourceKind::Pushed, @@ -844,9 +835,9 @@ impl LocalLspStore { ), registration_id: None, }], - |_, diagnostic, cx| match diagnostic.source_kind { + |_, diagnostic, _cx| match diagnostic.source_kind { DiagnosticSourceKind::Other | DiagnosticSourceKind::Pushed => { - adapter.retain_old_diagnostic(diagnostic, cx) + adapter.retain_old_diagnostic(diagnostic) } DiagnosticSourceKind::Pulled => true, }, @@ -1601,9 +1592,7 @@ impl LocalLspStore { .language_servers_for_buffer(buffer, cx) .map(|(adapter, lsp)| (adapter.clone(), lsp.clone())) .collect::>(); - let settings = - language_settings(buffer.language().map(|l| l.name()), buffer.file(), cx) - .into_owned(); + let settings = LanguageSettings::for_buffer(buffer, cx).into_owned(); let request_timeout = ProjectSettings::get_global(cx) .global_lsp_settings .get_request_timeout(); @@ -4464,6 +4453,7 @@ impl LspStore { }) .detach(); + self.parse_modeline(buffer, cx); self.detect_language_for_buffer(buffer, cx); if let Some(local) = self.as_local_mut() { local.initialize_buffer(buffer, cx); @@ -4513,6 +4503,16 @@ impl LspStore { }) } + fn on_buffer_reloaded(&mut self, buffer: Entity, cx: &mut Context) { + if self.parse_modeline(&buffer, cx) { + self.detect_language_for_buffer(&buffer, cx); + } + + let buffer_id = buffer.read(cx).remote_id(); + let task = self.pull_diagnostics_for_buffer(buffer, cx); + self.buffer_reload_tasks.insert(buffer_id, task); + } + pub(crate) fn register_buffer_with_language_servers( &mut self, buffer: &Entity, @@ -4736,6 +4736,56 @@ impl LspStore { }) } + fn parse_modeline(&mut self, buffer_handle: &Entity, cx: &mut Context) -> bool { + let buffer = buffer_handle.read(cx); + let content = buffer.as_rope(); + + let modeline_settings = { + let settings_store = cx.global::(); + let modeline_lines = settings_store + .raw_user_settings() + .and_then(|s| s.content.modeline_lines) + .or(settings_store.raw_default_settings().modeline_lines) + .unwrap_or(5); + + const MAX_MODELINE_BYTES: usize = 1024; + + let first_bytes = + content.clip_offset(content.len().min(MAX_MODELINE_BYTES), Bias::Left); + let mut first_lines = Vec::new(); + let mut lines = content.chunks_in_range(0..first_bytes).lines(); + for _ in 0..modeline_lines { + if let Some(line) = lines.next() { + first_lines.push(line.to_string()); + } else { + break; + } + } + let first_lines_ref: Vec<_> = first_lines.iter().map(|line| line.as_str()).collect(); + + let last_start = + content.clip_offset(content.len().saturating_sub(MAX_MODELINE_BYTES), Bias::Left); + let mut last_lines = Vec::new(); + let mut lines = content + .reversed_chunks_in_range(last_start..content.len()) + .lines(); + for _ in 0..modeline_lines { + if let Some(line) = lines.next() { + last_lines.push(line.to_string()); + } else { + break; + } + } + let last_lines_ref: Vec<_> = + last_lines.iter().rev().map(|line| line.as_str()).collect(); + modeline::parse_modeline(&first_lines_ref, &last_lines_ref) + }; + + log::debug!("Parsed modeline settings: {:?}", modeline_settings); + + buffer_handle.update(cx, |buffer, _cx| buffer.set_modeline(modeline_settings)) + } + fn detect_language_for_buffer( &mut self, buffer_handle: &Entity, @@ -4744,9 +4794,19 @@ impl LspStore { // If the buffer has a language, set it and start the language server if we haven't already. let buffer = buffer_handle.read(cx); let file = buffer.file()?; - let content = buffer.as_rope(); - let available_language = self.languages.language_for_file(file, Some(content), cx); + let modeline_settings = buffer.modeline().map(Arc::as_ref); + + let available_language = if let Some(ModelineSettings { + mode: Some(mode_name), + .. + }) = modeline_settings + { + self.languages + .available_language_for_modeline_name(mode_name) + } else { + self.languages.language_for_file(file, Some(content), cx) + }; if let Some(available_language) = &available_language { if let Some(Ok(Ok(new_language))) = self .languages @@ -4791,8 +4851,12 @@ impl LspStore { } }); - let settings = - language_settings(Some(new_language.name()), buffer_file.as_ref(), cx).into_owned(); + let settings = LanguageSettings::resolve( + Some(&buffer_entity.read(cx)), + Some(&new_language.name()), + cx, + ) + .into_owned(); let buffer_file = File::from_dyn(buffer_file.as_ref()); let worktree_id = if let Some(file) = buffer_file { @@ -5100,10 +5164,9 @@ impl LspStore { let mut language_formatters_to_check = Vec::new(); for buffer in self.buffer_store.read(cx).buffers() { let buffer = buffer.read(cx); - let buffer_file = File::from_dyn(buffer.file()); - let buffer_language = buffer.language(); - let settings = language_settings(buffer_language.map(|l| l.name()), buffer.file(), cx); - if buffer_language.is_some() { + let settings = LanguageSettings::for_buffer(buffer, cx); + if buffer.language().is_some() { + let buffer_file = File::from_dyn(buffer.file()); language_formatters_to_check.push(( buffer_file.map(|f| f.worktree_id(cx)), settings.into_owned(), @@ -5553,9 +5616,9 @@ impl LspStore { }) .filter(|_| { maybe!({ - let language = buffer.read(cx).language_at(position)?; + buffer.read(cx).language_at(position)?; Some( - language_settings(Some(language.name()), buffer.read(cx).file(), cx) + LanguageSettings::for_buffer_at(&buffer.read(cx), position, cx) .linked_edits, ) }) == Some(true) @@ -5659,12 +5722,7 @@ impl LspStore { ) -> Task>> { let options = buffer.update(cx, |buffer, cx| { lsp_command::lsp_formatting_options( - language_settings( - buffer.language_at(position).map(|l| l.name()), - buffer.file(), - cx, - ) - .as_ref(), + LanguageSettings::for_buffer_at(buffer, position, cx).as_ref(), ) }); @@ -6206,13 +6264,9 @@ impl LspStore { let offset = position.to_offset(&snapshot); let scope = snapshot.language_scope_at(offset); let language = snapshot.language().cloned(); - let completion_settings = language_settings( - language.as_ref().map(|language| language.name()), - buffer.read(cx).file(), - cx, - ) - .completions - .clone(); + let completion_settings = LanguageSettings::for_buffer(&buffer.read(cx), cx) + .completions + .clone(); if !completion_settings.lsp { return Task::ready(Ok(Vec::new())); } @@ -7966,12 +8020,6 @@ impl LspStore { None } - fn on_buffer_reloaded(&mut self, buffer: Entity, cx: &mut Context) { - let buffer_id = buffer.read(cx).remote_id(); - let task = self.pull_diagnostics_for_buffer(buffer, cx); - self.buffer_reload_tasks.insert(buffer_id, task); - } - async fn refresh_workspace_configurations(lsp_store: &WeakEntity, cx: &mut AsyncApp) { maybe!(async move { let mut refreshed_servers = HashSet::default(); @@ -11150,23 +11198,6 @@ impl LspStore { cx.background_spawn(futures::future::join_all(tasks).map(|_| ())) } - fn get_buffer<'a>(&self, abs_path: &Path, cx: &'a App) -> Option<&'a Buffer> { - let (worktree, relative_path) = - self.worktree_store.read(cx).find_worktree(&abs_path, cx)?; - - let project_path = ProjectPath { - worktree_id: worktree.read(cx).id(), - path: relative_path, - }; - - Some( - self.buffer_store() - .read(cx) - .get_by_path(&project_path)? - .read(cx), - ) - } - #[cfg(any(test, feature = "test-support"))] pub fn update_diagnostics( &mut self, diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 53919e3ee54cf3a63e566f71d3719a72d39ac273..0f9ad1a4356d72bd15688d64a3909dd73dbaad35 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -120,6 +120,7 @@ use std::{ borrow::Cow, collections::BTreeMap, ffi::OsString, + future::Future, ops::{Not as _, Range}, path::{Path, PathBuf}, pin::pin, @@ -1185,7 +1186,6 @@ impl Project { worktree_store.clone(), environment.clone(), manifest_tree.clone(), - fs.clone(), cx, ) }); @@ -1499,8 +1499,13 @@ impl Project { ) }); - let agent_server_store = - cx.new(|_| AgentServerStore::remote(REMOTE_SERVER_PROJECT_ID, remote.clone())); + let agent_server_store = cx.new(|_| { + AgentServerStore::remote( + REMOTE_SERVER_PROJECT_ID, + remote.clone(), + worktree_store.clone(), + ) + }); cx.subscribe(&remote, Self::on_remote_client_event).detach(); @@ -2078,6 +2083,12 @@ impl Project { self.worktree_store.clone() } + /// Returns a future that resolves when all visible worktrees have completed + /// their initial scan. + pub fn wait_for_initial_scan(&self, cx: &App) -> impl Future + use<> { + self.worktree_store.read(cx).wait_for_initial_scan() + } + #[inline] pub fn context_server_store(&self) -> Entity { self.context_server_store.clone() @@ -2287,8 +2298,11 @@ impl Project { self.worktree_store.read(cx).visible_worktrees(cx) } - pub fn default_path_list(&self, cx: &App) -> PathList { - let worktree_roots = self + pub(crate) fn default_visible_worktree_paths( + worktree_store: &WorktreeStore, + cx: &App, + ) -> Vec { + worktree_store .visible_worktrees(cx) .sorted_by(|left, right| { left.read(cx) @@ -2304,7 +2318,12 @@ impl Project { Some(path.to_path_buf()) } }) - .collect::>(); + .collect() + } + + pub fn default_path_list(&self, cx: &App) -> PathList { + let worktree_roots = + Self::default_visible_worktree_paths(&self.worktree_store.read(cx), cx); if worktree_roots.is_empty() { PathList::new(&[paths::home_dir().as_path()]) diff --git a/crates/project/src/task_inventory.rs b/crates/project/src/task_inventory.rs index cbe4a73a654472550673fe54b60974c57e08991d..46999b2b7024c6035732b64de30a3e64cd65460c 100644 --- a/crates/project/src/task_inventory.rs +++ b/crates/project/src/task_inventory.rs @@ -15,7 +15,7 @@ use gpui::{App, AppContext as _, Context, Entity, SharedString, Task, WeakEntity use itertools::Itertools; use language::{ Buffer, ContextLocation, ContextProvider, File, Language, LanguageToolchainStore, Location, - language_settings::language_settings, + language_settings::LanguageSettings, }; use lsp::{LanguageServerId, LanguageServerName}; use paths::{debug_task_file_name, task_file_name}; @@ -84,10 +84,20 @@ impl InventoryFor { &self, worktree: WorktreeId, ) -> impl '_ + Iterator { - self.worktree - .get(&worktree) + let worktree_dirs = self.worktree.get(&worktree); + let has_zed_dir = worktree_dirs + .map(|dirs| { + dirs.keys() + .any(|dir| dir.file_name().is_some_and(|name| name == ".zed")) + }) + .unwrap_or(false); + + worktree_dirs .into_iter() .flatten() + .filter(move |(directory, _)| { + !(has_zed_dir && directory.file_name().is_some_and(|name| name == ".vscode")) + }) .flat_map(|(directory, templates)| { templates.iter().map(move |template| (directory, template)) }) @@ -302,17 +312,15 @@ impl Inventory { let last_scheduled_scenarios = self.last_scheduled_scenarios.iter().cloned().collect(); let adapter = task_contexts.location().and_then(|location| { - let (file, language) = { - let buffer = location.buffer.read(cx); - (buffer.file(), buffer.language()) - }; - let language_name = language.as_ref().map(|l| l.name()); - let adapter = language_settings(language_name, file, cx) + let buffer = location.buffer.read(cx); + let adapter = LanguageSettings::for_buffer(&buffer, cx) .debuggers .first() .map(SharedString::from) .or_else(|| { - language.and_then(|l| l.config().debuggers.first().map(SharedString::from)) + buffer + .language() + .and_then(|l| l.config().debuggers.first().map(SharedString::from)) }); adapter.map(|adapter| (adapter, DapRegistry::global(cx).locators())) }); @@ -350,19 +358,18 @@ impl Inventory { label: &str, cx: &App, ) -> Task> { - let (buffer_worktree_id, file, language) = buffer + let (buffer_worktree_id, language) = buffer + .as_ref() .map(|buffer| { let buffer = buffer.read(cx); - let file = buffer.file().cloned(); ( - file.as_ref().map(|file| file.worktree_id(cx)), - file, + buffer.file().as_ref().map(|file| file.worktree_id(cx)), buffer.language().cloned(), ) }) - .unwrap_or((None, None, None)); + .unwrap_or((None, None)); - let tasks = self.list_tasks(file, language, worktree_id.or(buffer_worktree_id), cx); + let tasks = self.list_tasks(buffer, language, worktree_id.or(buffer_worktree_id), cx); let label = label.to_owned(); cx.background_spawn(async move { tasks @@ -378,7 +385,7 @@ impl Inventory { /// and global tasks last. No specific order inside source kinds groups. pub fn list_tasks( &self, - file: Option>, + buffer: Option>, language: Option>, worktree: Option, cx: &App, @@ -394,14 +401,18 @@ impl Inventory { }); let language_tasks = language .filter(|language| { - language_settings(Some(language.name()), file.as_ref(), cx) - .tasks - .enabled + LanguageSettings::resolve( + buffer.as_ref().map(|b| b.read(cx)), + Some(&language.name()), + cx, + ) + .tasks + .enabled }) .and_then(|language| { language .context_provider() - .map(|provider| provider.associated_tasks(file, cx)) + .map(|provider| provider.associated_tasks(buffer, cx)) }); cx.background_spawn(async move { if let Some(t) = language_tasks { @@ -435,7 +446,18 @@ impl Inventory { let task_source_kind = language.as_ref().map(|language| TaskSourceKind::Language { name: language.name().into(), }); - let file = location.and_then(|location| location.buffer.read(cx).file().cloned()); + let buffer = location.map(|location| location.buffer.clone()); + + let worktrees_with_zed_tasks: HashSet = self + .templates_from_settings + .worktree + .iter() + .filter(|(_, dirs)| { + dirs.keys() + .any(|dir| dir.file_name().is_some_and(|name| name == ".zed")) + }) + .map(|(id, _)| *id) + .collect(); let mut task_labels_to_ids = HashMap::>::default(); let mut lru_score = 0_u32; @@ -446,6 +468,14 @@ impl Inventory { .filter(|(task_kind, _)| { if matches!(task_kind, TaskSourceKind::Language { .. }) { Some(task_kind) == task_source_kind.as_ref() + } else if let TaskSourceKind::Worktree { + id, + directory_in_worktree: dir, + .. + } = task_kind + { + !(worktrees_with_zed_tasks.contains(id) + && dir.file_name().is_some_and(|name| name == ".vscode")) } else { true } @@ -478,14 +508,18 @@ impl Inventory { let global_tasks = self.global_templates_from_settings().collect::>(); let associated_tasks = language .filter(|language| { - language_settings(Some(language.name()), file.as_ref(), cx) - .tasks - .enabled + LanguageSettings::resolve( + buffer.as_ref().map(|b| b.read(cx)), + Some(&language.name()), + cx, + ) + .tasks + .enabled }) .and_then(|language| { language .context_provider() - .map(|provider| provider.associated_tasks(file, cx)) + .map(|provider| provider.associated_tasks(buffer, cx)) }); let worktree_tasks = worktree .into_iter() @@ -1007,7 +1041,7 @@ impl ContextProviderWithTasks { } impl ContextProvider for ContextProviderWithTasks { - fn associated_tasks(&self, _: Option>, _: &App) -> Task> { + fn associated_tasks(&self, _: Option>, _: &App) -> Task> { Task::ready(Some(self.templates.clone())) } } diff --git a/crates/project/src/toolchain_store.rs b/crates/project/src/toolchain_store.rs index 0820e4506e5c6b8d51c2732c64afcb21566350dd..c72b99c6a11271870ab8d4b4b73a7c8eb5e095ba 100644 --- a/crates/project/src/toolchain_store.rs +++ b/crates/project/src/toolchain_store.rs @@ -4,7 +4,7 @@ use anyhow::{Context as _, Result, bail}; use async_trait::async_trait; use collections::{BTreeMap, IndexSet}; -use fs::Fs; + use gpui::{ App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Subscription, Task, WeakEntity, }; @@ -62,7 +62,6 @@ impl ToolchainStore { worktree_store: Entity, project_environment: Entity, manifest_tree: Entity, - fs: Arc, cx: &mut Context, ) -> Self { let entity = cx.new(|_| LocalToolchainStore { @@ -71,7 +70,6 @@ impl ToolchainStore { project_environment, active_toolchains: Default::default(), manifest_tree, - fs, }); let _sub = cx.subscribe(&entity, |_, _, e: &ToolchainStoreEvent, cx| { cx.emit(e.clone()) @@ -418,7 +416,6 @@ pub struct LocalToolchainStore { project_environment: Entity, active_toolchains: BTreeMap<(WorktreeId, LanguageName), BTreeMap, Toolchain>>, manifest_tree: Entity, - fs: Arc, } #[async_trait(?Send)] @@ -507,7 +504,6 @@ impl LocalToolchainStore { let registry = self.languages.clone(); let manifest_tree = self.manifest_tree.downgrade(); - let fs = self.fs.clone(); let environment = self.project_environment.clone(); cx.spawn(async move |this, cx| { @@ -554,12 +550,7 @@ impl LocalToolchainStore { cx.background_spawn(async move { Some(( toolchains - .list( - worktree_root, - relative_path.path.clone(), - project_env, - fs.as_ref(), - ) + .list(worktree_root, relative_path.path.clone(), project_env) .await, relative_path.path, )) @@ -593,7 +584,6 @@ impl LocalToolchainStore { ) -> Task> { let registry = self.languages.clone(); let environment = self.project_environment.clone(); - let fs = self.fs.clone(); cx.spawn(async move |_, cx| { let language = cx .background_spawn(registry.language_for_name(&language_name.0)) @@ -612,12 +602,8 @@ impl LocalToolchainStore { ) }) .await; - cx.background_spawn(async move { - toolchain_lister - .resolve(path, project_env, fs.as_ref()) - .await - }) - .await + cx.background_spawn(async move { toolchain_lister.resolve(path, project_env).await }) + .await }) } } diff --git a/crates/project/src/worktree_store.rs b/crates/project/src/worktree_store.rs index 31a6cc041eda875f3c7ee5b33b77519d7ee2b142..4d464182fa670c6efc7ea2644abd68ef0dcda90a 100644 --- a/crates/project/src/worktree_store.rs +++ b/crates/project/src/worktree_store.rs @@ -1,4 +1,5 @@ use std::{ + future::Future, path::{Path, PathBuf}, sync::{ Arc, @@ -15,6 +16,7 @@ use gpui::{ WeakEntity, }; use itertools::Either; +use postage::{prelude::Stream as _, watch}; use rpc::{ AnyProtoClient, ErrorExt, TypedEnvelope, proto::{self, REMOTE_SERVER_PROJECT_ID}, @@ -75,6 +77,7 @@ pub struct WorktreeStore { #[allow(clippy::type_complexity)] loading_worktrees: HashMap, Shared, Arc>>>>, + initial_scan_complete: (watch::Sender, watch::Receiver), state: WorktreeStoreState, } @@ -119,6 +122,7 @@ impl WorktreeStore { worktrees_reordered: false, scanning_enabled: true, retain_worktrees, + initial_scan_complete: watch::channel_with(true), state: WorktreeStoreState::Local { fs }, } } @@ -139,6 +143,7 @@ impl WorktreeStore { worktrees_reordered: false, scanning_enabled: true, retain_worktrees, + initial_scan_complete: watch::channel_with(true), state: WorktreeStoreState::Remote { upstream_client, upstream_project_id, @@ -174,6 +179,57 @@ impl WorktreeStore { pub fn disable_scanner(&mut self) { self.scanning_enabled = false; + *self.initial_scan_complete.0.borrow_mut() = true; + } + + /// Returns a future that resolves when all visible worktrees have completed + /// their initial scan (entries populated, git repos detected). + pub fn wait_for_initial_scan(&self) -> impl Future + use<> { + let mut rx = self.initial_scan_complete.1.clone(); + async move { + let mut done = *rx.borrow(); + while !done { + if let Some(value) = rx.recv().await { + done = value; + } else { + break; + } + } + } + } + + /// Returns whether all visible worktrees have completed their initial scan. + pub fn initial_scan_completed(&self) -> bool { + *self.initial_scan_complete.1.borrow() + } + + /// Checks whether all visible worktrees have completed their initial scan + /// and no worktree creations are pending, and updates the watch channel accordingly. + fn update_initial_scan_state(&mut self, cx: &App) { + let complete = self.loading_worktrees.is_empty() + && self + .visible_worktrees(cx) + .all(|wt| wt.read(cx).completed_scan_id() >= 1); + *self.initial_scan_complete.0.borrow_mut() = complete; + } + + /// Spawns a detached task that waits for a worktree's initial scan to complete, + /// then rechecks and updates the aggregate initial scan state. + fn observe_worktree_scan_completion( + &mut self, + worktree: &Entity, + cx: &mut Context, + ) { + let await_scan = worktree.update(cx, |worktree, _cx| worktree.wait_for_snapshot(1)); + cx.spawn(async move |this, cx| { + await_scan.await.ok(); + this.update(cx, |this, cx| { + this.update_initial_scan_state(cx); + }) + .ok(); + anyhow::Ok(()) + }) + .detach(); } /// Iterates through all worktrees, including ones that don't appear in the project panel @@ -554,12 +610,22 @@ impl WorktreeStore { self.loading_worktrees .insert(abs_path.clone(), task.shared()); + + if visible && self.scanning_enabled { + *self.initial_scan_complete.0.borrow_mut() = false; + } } let task = self.loading_worktrees.get(&abs_path).unwrap().clone(); cx.spawn(async move |this, cx| { let result = task.await; - this.update(cx, |this, _| this.loading_worktrees.remove(&abs_path)) - .ok(); + this.update(cx, |this, cx| { + this.loading_worktrees.remove(&abs_path); + if !visible || !this.scanning_enabled || result.is_err() { + this.update_initial_scan_state(cx); + } + }) + .ok(); + match result { Ok(worktree) => { if !is_via_collab { @@ -578,6 +644,13 @@ impl WorktreeStore { ); }); } + + this.update(cx, |this, cx| { + if this.scanning_enabled && visible { + this.observe_worktree_scan_completion(&worktree, cx); + } + }) + .ok(); } Ok(worktree) } @@ -768,6 +841,7 @@ impl WorktreeStore { false } }); + self.update_initial_scan_state(cx); self.send_project_updates(cx); } diff --git a/crates/project/tests/integration/project_tests.rs b/crates/project/tests/integration/project_tests.rs index d218e015c3454d4eb769512e12cd7fbba5d8ffc5..3230df665557077ed2f50142815242e7caef85a4 100644 --- a/crates/project/tests/integration/project_tests.rs +++ b/crates/project/tests/integration/project_tests.rs @@ -36,7 +36,7 @@ use git::{ use git2::RepositoryInitOptions; use gpui::{ App, AppContext, BackgroundExecutor, BorrowAppContext, Entity, FutureExt, SharedString, Task, - UpdateGlobal, + TestAppContext, UpdateGlobal, }; use itertools::Itertools; use language::{ @@ -44,7 +44,7 @@ use language::{ DiagnosticSourceKind, DiskState, FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, LanguageName, LineEnding, ManifestName, ManifestProvider, ManifestQuery, OffsetRangeExt, Point, ToPoint, Toolchain, ToolchainList, ToolchainLister, ToolchainMetadata, - language_settings::{LanguageSettingsContent, language_settings}, + language_settings::{LanguageSettings, LanguageSettingsContent}, markdown_lang, rust_lang, tree_sitter_typescript, }; use lsp::{ @@ -296,50 +296,43 @@ async fn test_editorconfig_support(cx: &mut gpui::TestAppContext) { cx.executor().run_until_parked(); - cx.update(|cx| { - let tree = worktree.read(cx); - let settings_for = |path: &str| { - let file_entry = tree.entry_for_path(rel_path(path)).unwrap().clone(); - let file = File::for_entry(file_entry, worktree.clone()); - let file_language = project - .read(cx) - .languages() - .load_language_for_file_path(file.path.as_std_path()); - let file_language = cx - .foreground_executor() - .block_on(file_language) - .expect("Failed to get file language"); - let file = file as _; - language_settings(Some(file_language.name()), Some(&file), cx).into_owned() - }; + let settings_for = async |path: &str, cx: &mut TestAppContext| -> LanguageSettings { + let buffer = project + .update(cx, |project, cx| { + project.open_buffer((worktree.read(cx).id(), rel_path(path)), cx) + }) + .await + .unwrap(); + cx.update(|cx| LanguageSettings::for_buffer(&buffer.read(cx), cx).into_owned()) + }; - let settings_a = settings_for("a.rs"); - let settings_b = settings_for("b/b.rs"); - let settings_c = settings_for("c.js"); - let settings_d = settings_for("d/d.rs"); - let settings_readme = settings_for("README.json"); + let settings_a = settings_for("a.rs", cx).await; + let settings_b = settings_for("b/b.rs", cx).await; + let settings_c = settings_for("c.js", cx).await; + let settings_d = settings_for("d/d.rs", cx).await; + let settings_readme = settings_for("README.json", cx).await; + // .editorconfig overrides .zed/settings + assert_eq!(Some(settings_a.tab_size), NonZeroU32::new(3)); + assert_eq!(settings_a.hard_tabs, true); + assert_eq!(settings_a.ensure_final_newline_on_save, true); + assert_eq!(settings_a.remove_trailing_whitespace_on_save, true); + assert_eq!(settings_a.preferred_line_length, 120); - // .editorconfig overrides .zed/settings - assert_eq!(Some(settings_a.tab_size), NonZeroU32::new(3)); - assert_eq!(settings_a.hard_tabs, true); - assert_eq!(settings_a.ensure_final_newline_on_save, true); - assert_eq!(settings_a.remove_trailing_whitespace_on_save, true); - assert_eq!(settings_a.preferred_line_length, 120); + // .editorconfig in b/ overrides .editorconfig in root + assert_eq!(Some(settings_b.tab_size), NonZeroU32::new(2)); - // .editorconfig in subdirectory overrides .editorconfig in root - assert_eq!(Some(settings_b.tab_size), NonZeroU32::new(2)); - assert_eq!(Some(settings_d.tab_size), NonZeroU32::new(1)); + // .editorconfig in subdirectory overrides .editorconfig in root + assert_eq!(Some(settings_d.tab_size), NonZeroU32::new(1)); - // "indent_size" is not set, so "tab_width" is used - assert_eq!(Some(settings_c.tab_size), NonZeroU32::new(10)); + // "indent_size" is not set, so "tab_width" is used + assert_eq!(Some(settings_c.tab_size), NonZeroU32::new(10)); - // When max_line_length is "off", default to .zed/settings.json - assert_eq!(settings_b.preferred_line_length, 64); - assert_eq!(settings_c.preferred_line_length, 64); + // When max_line_length is "off", default to .zed/settings.json + assert_eq!(settings_b.preferred_line_length, 64); + assert_eq!(settings_c.preferred_line_length, 64); - // README.md should not be affected by .editorconfig's globe "*.rs" - assert_eq!(Some(settings_readme.tab_size), NonZeroU32::new(8)); - }); + // README.md should not be affected by .editorconfig's globe "*.rs" + assert_eq!(Some(settings_readme.tab_size), NonZeroU32::new(8)); } #[gpui::test] @@ -373,37 +366,28 @@ async fn test_external_editorconfig_support(cx: &mut gpui::TestAppContext) { let worktree = project.update(cx, |project, cx| project.worktrees(cx).next().unwrap()); cx.executor().run_until_parked(); + let settings_for = async |path: &str, cx: &mut TestAppContext| -> LanguageSettings { + let buffer = project + .update(cx, |project, cx| { + project.open_buffer((worktree.read(cx).id(), rel_path(path)), cx) + }) + .await + .unwrap(); + cx.update(|cx| LanguageSettings::for_buffer(&buffer.read(cx), cx).into_owned()) + }; - cx.update(|cx| { - let tree = worktree.read(cx); - let settings_for = |path: &str| { - let file_entry = tree.entry_for_path(rel_path(path)).unwrap().clone(); - let file = File::for_entry(file_entry, worktree.clone()); - let file_language = project - .read(cx) - .languages() - .load_language_for_file_path(file.path.as_std_path()); - let file_language = cx - .foreground_executor() - .block_on(file_language) - .expect("Failed to get file language"); - let file = file as _; - language_settings(Some(file_language.name()), Some(&file), cx).into_owned() - }; - - let settings_rs = settings_for("main.rs"); - let settings_md = settings_for("README.md"); - let settings_txt = settings_for("other.txt"); + let settings_rs = settings_for("main.rs", cx).await; + let settings_md = settings_for("README.md", cx).await; + let settings_txt = settings_for("other.txt", cx).await; - // main.rs gets indent_size = 2 from parent's external .editorconfig - assert_eq!(Some(settings_rs.tab_size), NonZeroU32::new(2)); + // main.rs gets indent_size = 2 from parent's external .editorconfig + assert_eq!(Some(settings_rs.tab_size), NonZeroU32::new(2)); - // README.md gets indent_size = 3 from internal worktree .editorconfig - assert_eq!(Some(settings_md.tab_size), NonZeroU32::new(3)); + // README.md gets indent_size = 3 from internal worktree .editorconfig + assert_eq!(Some(settings_md.tab_size), NonZeroU32::new(3)); - // other.txt gets indent_size = 4 from grandparent's external .editorconfig - assert_eq!(Some(settings_txt.tab_size), NonZeroU32::new(4)); - }); + // other.txt gets indent_size = 4 from grandparent's external .editorconfig + assert_eq!(Some(settings_txt.tab_size), NonZeroU32::new(4)); } #[gpui::test] @@ -432,24 +416,14 @@ async fn test_internal_editorconfig_root_stops_traversal(cx: &mut gpui::TestAppC cx.executor().run_until_parked(); + let buffer = project + .update(cx, |project, cx| { + project.open_buffer((worktree.read(cx).id(), rel_path("src/file.rs")), cx) + }) + .await + .unwrap(); cx.update(|cx| { - let tree = worktree.read(cx); - let file_entry = tree - .entry_for_path(rel_path("src/file.rs")) - .unwrap() - .clone(); - let file = File::for_entry(file_entry, worktree.clone()); - let file_language = project - .read(cx) - .languages() - .load_language_for_file_path(file.path.as_std_path()); - let file_language = cx - .foreground_executor() - .block_on(file_language) - .expect("Failed to get file language"); - let file = file as _; - let settings = language_settings(Some(file_language.name()), Some(&file), cx).into_owned(); - + let settings = LanguageSettings::for_buffer(buffer.read(cx), cx).into_owned(); assert_eq!(Some(settings.tab_size), NonZeroU32::new(2)); }); } @@ -480,20 +454,15 @@ async fn test_external_editorconfig_root_stops_traversal(cx: &mut gpui::TestAppC cx.executor().run_until_parked(); + let buffer = project + .update(cx, |project, cx| { + project.open_buffer((worktree.read(cx).id(), rel_path("file.rs")), cx) + }) + .await + .unwrap(); + cx.update(|cx| { - let tree = worktree.read(cx); - let file_entry = tree.entry_for_path(rel_path("file.rs")).unwrap().clone(); - let file = File::for_entry(file_entry, worktree.clone()); - let file_language = project - .read(cx) - .languages() - .load_language_for_file_path(file.path.as_std_path()); - let file_language = cx - .foreground_executor() - .block_on(file_language) - .expect("Failed to get file language"); - let file = file as _; - let settings = language_settings(Some(file_language.name()), Some(&file), cx).into_owned(); + let settings = LanguageSettings::for_buffer(&buffer.read(cx), cx); // file.rs gets indent_size = 2 from worktree's root config, NOT 99 from parent assert_eq!(Some(settings.tab_size), NonZeroU32::new(2)); @@ -528,20 +497,15 @@ async fn test_external_editorconfig_root_in_parent_stops_traversal(cx: &mut gpui cx.executor().run_until_parked(); + let buffer = project + .update(cx, |project, cx| { + project.open_buffer((worktree.read(cx).id(), rel_path("file.rs")), cx) + }) + .await + .unwrap(); + cx.update(|cx| { - let tree = worktree.read(cx); - let file_entry = tree.entry_for_path(rel_path("file.rs")).unwrap().clone(); - let file = File::for_entry(file_entry, worktree.clone()); - let file_language = project - .read(cx) - .languages() - .load_language_for_file_path(file.path.as_std_path()); - let file_language = cx - .foreground_executor() - .block_on(file_language) - .expect("Failed to get file language"); - let file = file as _; - let settings = language_settings(Some(file_language.name()), Some(&file), cx).into_owned(); + let settings = LanguageSettings::for_buffer(&buffer.read(cx), cx); // file.rs gets indent_size = 4 from parent's root config, NOT 99 from grandparent assert_eq!(Some(settings.tab_size), NonZeroU32::new(4)); @@ -584,30 +548,24 @@ async fn test_external_editorconfig_shared_across_worktrees(cx: &mut gpui::TestA cx.executor().run_until_parked(); - cx.update(|cx| { - let worktrees: Vec<_> = project.read(cx).worktrees(cx).collect(); - assert_eq!(worktrees.len(), 2); + let worktrees: Vec<_> = cx.update(|cx| project.read(cx).worktrees(cx).collect()); + assert_eq!(worktrees.len(), 2); - for worktree in worktrees { - let tree = worktree.read(cx); - let file_entry = tree.entry_for_path(rel_path("file.rs")).unwrap().clone(); - let file = File::for_entry(file_entry, worktree.clone()); - let file_language = project - .read(cx) - .languages() - .load_language_for_file_path(file.path.as_std_path()); - let file_language = cx - .foreground_executor() - .block_on(file_language) - .expect("Failed to get file language"); - let file = file as _; - let settings = - language_settings(Some(file_language.name()), Some(&file), cx).into_owned(); + for worktree in worktrees { + let buffer = project + .update(cx, |project, cx| { + project.open_buffer((worktree.read(cx).id(), rel_path("file.rs")), cx) + }) + .await + .unwrap(); + + cx.update(|cx| { + let settings = LanguageSettings::for_buffer(&buffer.read(cx), cx); // Both worktrees should get indent_size = 5 from shared parent .editorconfig assert_eq!(Some(settings.tab_size), NonZeroU32::new(5)); - } - }); + }); + } } #[gpui::test] @@ -637,20 +595,15 @@ async fn test_external_editorconfig_not_loaded_without_internal_config( cx.executor().run_until_parked(); + let buffer = project + .update(cx, |project, cx| { + project.open_buffer((worktree.read(cx).id(), rel_path("file.rs")), cx) + }) + .await + .unwrap(); + cx.update(|cx| { - let tree = worktree.read(cx); - let file_entry = tree.entry_for_path(rel_path("file.rs")).unwrap().clone(); - let file = File::for_entry(file_entry, worktree.clone()); - let file_language = project - .read(cx) - .languages() - .load_language_for_file_path(file.path.as_std_path()); - let file_language = cx - .foreground_executor() - .block_on(file_language) - .expect("Failed to get file language"); - let file = file as _; - let settings = language_settings(Some(file_language.name()), Some(&file), cx).into_owned(); + let settings = LanguageSettings::for_buffer(&buffer.read(cx), cx); // file.rs should have default tab_size = 4, NOT 99 from parent's external .editorconfig // because without an internal .editorconfig, external configs are not loaded @@ -684,20 +637,15 @@ async fn test_external_editorconfig_modification_triggers_refresh(cx: &mut gpui: cx.executor().run_until_parked(); + let buffer = project + .update(cx, |project, cx| { + project.open_buffer((worktree.read(cx).id(), rel_path("file.rs")), cx) + }) + .await + .unwrap(); + cx.update(|cx| { - let tree = worktree.read(cx); - let file_entry = tree.entry_for_path(rel_path("file.rs")).unwrap().clone(); - let file = File::for_entry(file_entry, worktree.clone()); - let file_language = project - .read(cx) - .languages() - .load_language_for_file_path(file.path.as_std_path()); - let file_language = cx - .foreground_executor() - .block_on(file_language) - .expect("Failed to get file language"); - let file = file as _; - let settings = language_settings(Some(file_language.name()), Some(&file), cx).into_owned(); + let settings = LanguageSettings::for_buffer(&buffer.read(cx), cx); // Test initial settings: tab_size = 4 from parent's external .editorconfig assert_eq!(Some(settings.tab_size), NonZeroU32::new(4)); @@ -712,20 +660,15 @@ async fn test_external_editorconfig_modification_triggers_refresh(cx: &mut gpui: cx.executor().run_until_parked(); + let buffer = project + .update(cx, |project, cx| { + project.open_buffer((worktree.read(cx).id(), rel_path("file.rs")), cx) + }) + .await + .unwrap(); + cx.update(|cx| { - let tree = worktree.read(cx); - let file_entry = tree.entry_for_path(rel_path("file.rs")).unwrap().clone(); - let file = File::for_entry(file_entry, worktree.clone()); - let file_language = project - .read(cx) - .languages() - .load_language_for_file_path(file.path.as_std_path()); - let file_language = cx - .foreground_executor() - .block_on(file_language) - .expect("Failed to get file language"); - let file = file as _; - let settings = language_settings(Some(file_language.name()), Some(&file), cx).into_owned(); + let settings = LanguageSettings::for_buffer(&buffer.read(cx), cx); // Test settings updated: tab_size = 8 assert_eq!(Some(settings.tab_size), NonZeroU32::new(8)); @@ -760,21 +703,16 @@ async fn test_adding_worktree_discovers_external_editorconfigs(cx: &mut gpui::Te cx.executor().run_until_parked(); + let buffer = project + .update(cx, |project, cx| { + let id = project.worktrees(cx).next().unwrap().read(cx).id(); + project.open_buffer((id, rel_path("file.rs")), cx) + }) + .await + .unwrap(); + cx.update(|cx| { - let worktree = project.read(cx).worktrees(cx).next().unwrap(); - let tree = worktree.read(cx); - let file_entry = tree.entry_for_path(rel_path("file.rs")).unwrap().clone(); - let file = File::for_entry(file_entry, worktree.clone()); - let file_language = project - .read(cx) - .languages() - .load_language_for_file_path(file.path.as_std_path()); - let file_language = cx - .foreground_executor() - .block_on(file_language) - .expect("Failed to get file language"); - let file = file as _; - let settings = language_settings(Some(file_language.name()), Some(&file), cx).into_owned(); + let settings = LanguageSettings::for_buffer(&buffer.read(cx), cx).into_owned(); // Test existing worktree has tab_size = 7 assert_eq!(Some(settings.tab_size), NonZeroU32::new(7)); @@ -789,20 +727,15 @@ async fn test_adding_worktree_discovers_external_editorconfigs(cx: &mut gpui::Te cx.executor().run_until_parked(); + let buffer = project + .update(cx, |project, cx| { + project.open_buffer((new_worktree.read(cx).id(), rel_path("file.rs")), cx) + }) + .await + .unwrap(); + cx.update(|cx| { - let tree = new_worktree.read(cx); - let file_entry = tree.entry_for_path(rel_path("file.rs")).unwrap().clone(); - let file = File::for_entry(file_entry, new_worktree.clone()); - let file_language = project - .read(cx) - .languages() - .load_language_for_file_path(file.path.as_std_path()); - let file_language = cx - .foreground_executor() - .block_on(file_language) - .expect("Failed to get file language"); - let file = file as _; - let settings = language_settings(Some(file_language.name()), Some(&file), cx).into_owned(); + let settings = LanguageSettings::for_buffer(&buffer.read(cx), cx); // Verify new worktree also has tab_size = 7 from shared parent editorconfig assert_eq!(Some(settings.tab_size), NonZeroU32::new(7)); @@ -943,20 +876,15 @@ async fn test_shared_external_editorconfig_cleanup_with_multiple_worktrees( assert_eq!(watcher_paths.len(), 1); }); + let buffer = project + .update(cx, |project, cx| { + project.open_buffer((worktree_b.read(cx).id(), rel_path("file.rs")), cx) + }) + .await + .unwrap(); + cx.update(|cx| { - let tree = worktree_b.read(cx); - let file_entry = tree.entry_for_path(rel_path("file.rs")).unwrap().clone(); - let file = File::for_entry(file_entry, worktree_b.clone()); - let file_language = project - .read(cx) - .languages() - .load_language_for_file_path(file.path.as_std_path()); - let file_language = cx - .foreground_executor() - .block_on(file_language) - .expect("Failed to get file language"); - let file = file as _; - let settings = language_settings(Some(file_language.name()), Some(&file), cx).into_owned(); + let settings = LanguageSettings::for_buffer(&buffer.read(cx), cx); // Test worktree_b still has correct settings assert_eq!(Some(settings.tab_size), NonZeroU32::new(5)); @@ -1083,26 +1011,28 @@ async fn test_managing_project_specific_settings(cx: &mut gpui::TestAppContext) id_base: "local worktree tasks from directory \".zed\"".into(), }; - let all_tasks = cx - .update(|cx| { - let tree = worktree.read(cx); - - let file_a = File::for_entry( - tree.entry_for_path(rel_path("a/a.rs")).unwrap().clone(), - worktree.clone(), - ) as _; - let settings_a = language_settings(None, Some(&file_a), cx); - let file_b = File::for_entry( - tree.entry_for_path(rel_path("b/b.rs")).unwrap().clone(), - worktree.clone(), - ) as _; - let settings_b = language_settings(None, Some(&file_b), cx); + let buffer_a = project + .update(cx, |project, cx| { + project.open_buffer((worktree.read(cx).id(), rel_path("a/a.rs")), cx) + }) + .await + .unwrap(); + let buffer_b = project + .update(cx, |project, cx| { + project.open_buffer((worktree.read(cx).id(), rel_path("b/b.rs")), cx) + }) + .await + .unwrap(); + cx.update(|cx| { + let settings_a = LanguageSettings::for_buffer(&buffer_a.read(cx), cx); + let settings_b = LanguageSettings::for_buffer(&buffer_b.read(cx), cx); - assert_eq!(settings_a.tab_size.get(), 8); - assert_eq!(settings_b.tab_size.get(), 2); + assert_eq!(settings_a.tab_size.get(), 8); + assert_eq!(settings_b.tab_size.get(), 2); + }); - get_all_tasks(&project, task_contexts.clone(), cx) - }) + let all_tasks = cx + .update(|cx| get_all_tasks(&project, task_contexts.clone(), cx)) .await .into_iter() .map(|(source_kind, task)| { @@ -11883,6 +11813,77 @@ async fn test_undo_encoding_change(cx: &mut gpui::TestAppContext) { }); } +#[gpui::test] +async fn test_initial_scan_complete(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/root"), + json!({ + "a": { + ".git": {}, + ".zed": { + "tasks.json": r#"[{"label": "task-a", "command": "echo a"}]"# + }, + "src": { "main.rs": "" } + }, + "b": { + ".git": {}, + ".zed": { + "tasks.json": r#"[{"label": "task-b", "command": "echo b"}]"# + }, + "src": { "lib.rs": "" } + }, + }), + ) + .await; + + let repos_created = Rc::new(RefCell::new(Vec::new())); + let _observe = { + let repos_created = repos_created.clone(); + cx.update(|cx| { + cx.observe_new::(move |repo, _, cx| { + repos_created.borrow_mut().push(cx.entity().downgrade()); + let _ = repo; + }) + }) + }; + + let project = Project::test( + fs.clone(), + [path!("/root/a").as_ref(), path!("/root/b").as_ref()], + cx, + ) + .await; + + let scan_complete = project.read_with(cx, |project, cx| project.wait_for_initial_scan(cx)); + scan_complete.await; + + project.read_with(cx, |project, cx| { + assert!( + project.worktree_store().read(cx).initial_scan_completed(), + "Expected initial scan to be completed after awaiting wait_for_initial_scan" + ); + }); + + let created_repos_len = repos_created.borrow().len(); + assert_eq!( + created_repos_len, 2, + "Expected 2 repositories to be created during scan, got {}", + created_repos_len + ); + + project.read_with(cx, |project, cx| { + let git_store = project.git_store().read(cx); + assert_eq!( + git_store.repositories().len(), + 2, + "Expected 2 repositories in GitStore" + ); + }); +} + pub fn init_test(cx: &mut gpui::TestAppContext) { zlog::init_test(); @@ -11930,7 +11931,6 @@ fn python_lang(fs: Arc) -> Arc { worktree_root: PathBuf, subroot_relative_path: Arc, _: Option>, - _: &dyn Fs, ) -> ToolchainList { // This lister will always return a path .venv directories within ancestors let ancestors = subroot_relative_path.ancestors().collect::>(); @@ -11955,7 +11955,6 @@ fn python_lang(fs: Arc) -> Arc { &self, _: PathBuf, _: Option>, - _: &dyn Fs, ) -> anyhow::Result { Err(anyhow::anyhow!("Not implemented")) } diff --git a/crates/project/tests/integration/search.rs b/crates/project/tests/integration/search.rs index b28240289c8a5b28e4db2827e4c08b745082f4f3..79266405084d293329056b55a57c72a043aa8ff0 100644 --- a/crates/project/tests/integration/search.rs +++ b/crates/project/tests/integration/search.rs @@ -148,7 +148,7 @@ async fn test_multiline_regex(cx: &mut gpui::TestAppContext) { use language::Buffer; let text = Rope::from("hello\nworld\nhello\nworld"); let snapshot = cx - .update(|app| Buffer::build_snapshot(text, None, None, app)) + .update(|app| Buffer::build_snapshot(text, None, None, None, app)) .await; let results = search_query.search(&snapshot, None).await; diff --git a/crates/project/tests/integration/task_inventory.rs b/crates/project/tests/integration/task_inventory.rs index fe42a0dea28645fcbf636f9e62608b549249fb93..6c51fa93571c4ca5d5f55631c67b29c1bc1c9963 100644 --- a/crates/project/tests/integration/task_inventory.rs +++ b/crates/project/tests/integration/task_inventory.rs @@ -560,6 +560,54 @@ async fn test_inventory_static_task_filters(cx: &mut TestAppContext) { ); } +#[gpui::test] +async fn test_zed_tasks_take_precedence_over_vscode(cx: &mut TestAppContext) { + init_test(cx); + let inventory = cx.update(|cx| Inventory::new(cx)); + let worktree_id = WorktreeId::from_usize(0); + + inventory.update(cx, |inventory, _| { + inventory + .update_file_based_tasks( + TaskSettingsLocation::Worktree(SettingsLocation { + worktree_id, + path: rel_path(".vscode"), + }), + Some(&mock_tasks_from_names(["vscode_task"])), + ) + .unwrap(); + }); + assert_eq!( + task_template_names(&inventory, Some(worktree_id), cx).await, + vec!["vscode_task"], + "With only .vscode tasks, they should appear" + ); + + inventory.update(cx, |inventory, _| { + inventory + .update_file_based_tasks( + TaskSettingsLocation::Worktree(SettingsLocation { + worktree_id, + path: rel_path(".zed"), + }), + Some(&mock_tasks_from_names(["zed_task"])), + ) + .unwrap(); + }); + assert_eq!( + task_template_names(&inventory, Some(worktree_id), cx).await, + vec!["zed_task"], + "With both .zed and .vscode tasks, only .zed tasks should appear" + ); + + register_worktree_task_used(&inventory, worktree_id, "zed_task", cx).await; + let resolved = resolved_task_names(&inventory, Some(worktree_id), cx).await; + assert!( + !resolved.iter().any(|name| name == "vscode_task"), + "Previously used .vscode tasks should not appear when .zed tasks exist, got: {resolved:?}" + ); +} + fn init_test(_cx: &mut TestAppContext) { zlog::init_test(); TaskStore::init(None); diff --git a/crates/project_panel/Cargo.toml b/crates/project_panel/Cargo.toml index 4306a25132ba460e1b3e48437226bf56020b6834..2192b8daf3a301d580a3cef73426f6348508a566 100644 --- a/crates/project_panel/Cargo.toml +++ b/crates/project_panel/Cargo.toml @@ -20,7 +20,6 @@ doctest = false anyhow.workspace = true collections.workspace = true command_palette_hooks.workspace = true -db.workspace = true editor.workspace = true file_icons.workspace = true git_ui.workspace = true @@ -37,6 +36,7 @@ serde_json.workspace = true settings.workspace = true smallvec.workspace = true theme.workspace = true +theme_settings.workspace = true rayon.workspace = true ui.workspace = true util.workspace = true diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 41acd58c3cd2fb06ac68d1673a6c9fb21bc46bb5..e9062364fc73ed6e266e3f8904be51eaaf5b6535 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -6,7 +6,6 @@ use anyhow::{Context as _, Result}; use client::{ErrorCode, ErrorExt}; use collections::{BTreeSet, HashMap, hash_map}; use command_palette_hooks::CommandPaletteFilter; -use db::kvp::KeyValueStore; use editor::{ Editor, EditorEvent, MultiBufferOffset, items::{ @@ -42,7 +41,7 @@ use project::{ use project_panel_settings::ProjectPanelSettings; use rayon::slice::ParallelSliceMut; use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use settings::{ DockSide, ProjectPanelEntrySpacing, Settings, SettingsStore, ShowDiagnostics, ShowIndentGuides, update_settings_file, @@ -59,12 +58,12 @@ use std::{ sync::Arc, time::Duration, }; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ Color, ContextMenu, ContextMenuEntry, DecoratedIcon, Divider, Icon, IconDecoration, - IconDecorationKind, IndentGuideColors, IndentGuideLayout, KeyBinding, Label, LabelSize, - ListItem, ListItemSpacing, ScrollAxes, ScrollableHandle, Scrollbars, StickyCandidate, Tooltip, - WithScrollbar, prelude::*, v_flex, + IconDecorationKind, IndentGuideColors, IndentGuideLayout, Indicator, KeyBinding, Label, + LabelSize, ListItem, ListItemSpacing, ScrollAxes, ScrollableHandle, Scrollbars, + StickyCandidate, Tooltip, WithScrollbar, prelude::*, v_flex, }; use util::{ ResultExt, TakeUntilExt, TryFutureExt, maybe, @@ -72,8 +71,8 @@ use util::{ rel_path::{RelPath, RelPathBuf}, }; use workspace::{ - DraggedSelection, OpenInTerminal, OpenOptions, OpenVisible, PreviewTabsSettings, SelectedEntry, - SplitDirection, Workspace, + DraggedSelection, OpenInTerminal, OpenMode, OpenOptions, OpenVisible, PreviewTabsSettings, + SelectedEntry, SplitDirection, Workspace, dock::{DockPosition, Panel, PanelEvent}, notifications::{DetachAndPromptErr, NotifyResultExt, NotifyTaskExt}, }; @@ -83,7 +82,10 @@ use zed_actions::{ workspace::OpenWithSystem, }; -use crate::undo::{ProjectPanelOperation, UndoManager}; +use crate::{ + project_panel_settings::ProjectPanelScrollbarProxy, + undo::{ProjectPanelOperation, UndoManager}, +}; const PROJECT_PANEL_KEY: &str = "ProjectPanel"; const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX; @@ -148,8 +150,6 @@ pub struct ProjectPanel { clipboard: Option, _dragged_entry_destination: Option>, workspace: WeakEntity, - width: Option, - pending_serialization: Task>, diagnostics: HashMap<(WorktreeId, Arc), DiagnosticSeverity>, diagnostic_counts: HashMap<(WorktreeId, Arc), DiagnosticCount>, diagnostic_summary_update: Task<()>, @@ -608,11 +608,6 @@ pub enum Event { Focus, } -#[derive(Serialize, Deserialize)] -struct SerializedProjectPanel { - width: Option, -} - struct DraggedProjectEntryView { selection: SelectedEntry, icon: Option, @@ -878,8 +873,6 @@ impl ProjectPanel { clipboard: None, _dragged_entry_destination: None, workspace: workspace.weak_handle(), - width: None, - pending_serialization: Task::ready(None), diagnostics: Default::default(), diagnostic_counts: Default::default(), diagnostic_summary_update: Task::ready(()), @@ -1000,37 +993,8 @@ impl ProjectPanel { workspace: WeakEntity, mut cx: AsyncWindowContext, ) -> Result> { - let serialized_panel = match workspace - .read_with(&cx, |workspace, _| { - ProjectPanel::serialization_key(workspace) - }) - .ok() - .flatten() - { - Some(serialization_key) => { - let kvp = cx.update(|_, cx| KeyValueStore::global(cx))?; - cx.background_spawn(async move { kvp.read_kvp(&serialization_key) }) - .await - .context("loading project panel") - .log_err() - .flatten() - .map(|panel| serde_json::from_str::(&panel)) - .transpose() - .log_err() - .flatten() - } - None => None, - }; - workspace.update_in(&mut cx, |workspace, window, cx| { - let panel = ProjectPanel::new(workspace, window, cx); - if let Some(serialized_panel) = serialized_panel { - panel.update(cx, |panel, cx| { - panel.width = serialized_panel.width.map(|px| px.round()); - cx.notify(); - }); - } - panel + ProjectPanel::new(workspace, window, cx) }) } @@ -1104,40 +1068,6 @@ impl ProjectPanel { .or_insert(diagnostic_severity); } - fn serialization_key(workspace: &Workspace) -> Option { - workspace - .database_id() - .map(|id| i64::from(id).to_string()) - .or(workspace.session_id()) - .map(|id| format!("{}-{:?}", PROJECT_PANEL_KEY, id)) - } - - fn serialize(&mut self, cx: &mut Context) { - let Some(serialization_key) = self - .workspace - .read_with(cx, |workspace, _| { - ProjectPanel::serialization_key(workspace) - }) - .ok() - .flatten() - else { - return; - }; - let width = self.width; - let kvp = KeyValueStore::global(cx); - self.pending_serialization = cx.background_spawn( - async move { - kvp.write_kvp( - serialization_key, - serde_json::to_string(&SerializedProjectPanel { width })?, - ) - .await?; - anyhow::Ok(()) - } - .log_err(), - ); - } - fn focus_in(&mut self, window: &mut Window, cx: &mut Context) { if !self.focus_handle.contains_focused(window, cx) { cx.emit(Event::Focus); @@ -5425,6 +5355,10 @@ impl ProjectPanel { false } }; + let git_indicator = settings + .git_status_indicator + .then(|| git_status_indicator(details.git_status)) + .flatten(); let id: ElementId = if is_sticky { SharedString::from(format!("project_panel_sticky_item_{}", entry_id.to_usize())).into() @@ -5769,7 +5703,9 @@ impl ProjectPanel { }) .selectable(false) .when( - canonical_path.is_some() || diagnostic_count.is_some(), + canonical_path.is_some() + || diagnostic_count.is_some() + || git_indicator.is_some(), |this| { let symlink_element = canonical_path.map(|path| { div() @@ -5812,6 +5748,20 @@ impl ProjectPanel { }, ) }) + .when_some(git_indicator, |this, (label, color)| { + let git_indicator = if kind.is_dir() { + Indicator::dot() + .color(Color::Custom(color.color(cx).opacity(0.5))) + .into_any_element() + } else { + Label::new(label) + .size(LabelSize::Small) + .color(color) + .into_any_element() + }; + + this.child(git_indicator) + }) .when_some(symlink_element, |this, el| this.child(el)) .into_any_element(), ) @@ -7091,8 +7041,9 @@ impl Render for ProjectPanel { ) .custom_scrollbars( { - let mut scrollbars = Scrollbars::for_settings::() - .tracked_scroll_handle(&self.scroll_handle); + let mut scrollbars = + Scrollbars::for_settings::() + .tracked_scroll_handle(&self.scroll_handle); if horizontal_scroll { scrollbars = scrollbars.with_track_along( ScrollAxes::Horizontal, @@ -7175,7 +7126,7 @@ impl Render for ProjectPanel { .workspace .update(cx, |workspace, cx| { workspace.open_workspace_for_paths( - true, + OpenMode::Replace, external_paths.paths().to_owned(), window, cx, @@ -7252,17 +7203,8 @@ impl Panel for ProjectPanel { }); } - fn size(&self, _: &Window, cx: &App) -> Pixels { - self.width - .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width) - } - - fn set_size(&mut self, size: Option, window: &mut Window, cx: &mut Context) { - self.width = size; - cx.notify(); - cx.defer_in(window, |this, _, cx| { - this.serialize(cx); - }); + fn default_size(&self, _: &Window, cx: &App) -> Pixels { + ProjectPanelSettings::get_global(cx).default_width } fn icon(&self, _: &Window, cx: &App) -> Option { @@ -7301,7 +7243,7 @@ impl Panel for ProjectPanel { } fn activation_priority(&self) -> u32 { - 0 + 1 } } @@ -7368,5 +7310,30 @@ pub fn par_sort_worktree_entries_with_mode( entries.par_sort_by(|lhs, rhs| cmp_with_mode(lhs, rhs, &mode)); } +fn git_status_indicator(git_status: GitSummary) -> Option<(&'static str, Color)> { + if git_status.conflict > 0 { + return Some(("!", Color::Conflict)); + } + if git_status.untracked > 0 { + return Some(("U", Color::Created)); + } + if git_status.worktree.deleted > 0 { + return Some(("D", Color::Deleted)); + } + if git_status.worktree.modified > 0 { + return Some(("M", Color::Warning)); + } + if git_status.index.deleted > 0 { + return Some(("D", Color::Deleted)); + } + if git_status.index.modified > 0 { + return Some(("M", Color::Modified)); + } + if git_status.index.added > 0 { + return Some(("A", Color::Created)); + } + None +} + #[cfg(test)] mod project_panel_tests; diff --git a/crates/project_panel/src/project_panel_settings.rs b/crates/project_panel/src/project_panel_settings.rs index de2ff8e0087b8e7dbe4fcc533e3eea0470553b50..64f3ea42928399201c497ba58041ed0bf6ed5ba1 100644 --- a/crates/project_panel/src/project_panel_settings.rs +++ b/crates/project_panel/src/project_panel_settings.rs @@ -1,4 +1,4 @@ -use editor::EditorSettings; +use editor::{EditorSettings, ui_scrollbar_settings_from_raw}; use gpui::Pixels; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -36,6 +36,7 @@ pub struct ProjectPanelSettings { pub auto_open: AutoOpenSettings, pub sort_mode: ProjectPanelSortMode, pub diagnostic_badges: bool, + pub git_status_indicator: bool, } #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] @@ -80,9 +81,13 @@ impl AutoOpenSettings { } } -impl ScrollbarVisibility for ProjectPanelSettings { +#[derive(Default)] +pub(crate) struct ProjectPanelScrollbarProxy; + +impl ScrollbarVisibility for ProjectPanelScrollbarProxy { fn visibility(&self, cx: &ui::App) -> ShowScrollbar { - self.scrollbar + ProjectPanelSettings::get_global(cx) + .scrollbar .show .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show) } @@ -119,7 +124,7 @@ impl Settings for ProjectPanelSettings { scrollbar: { let scrollbar = project_panel.scrollbar.unwrap(); ScrollbarSettings { - show: scrollbar.show.map(Into::into), + show: scrollbar.show.map(ui_scrollbar_settings_from_raw), horizontal_scroll: scrollbar.horizontal_scroll.unwrap(), } }, @@ -137,6 +142,7 @@ impl Settings for ProjectPanelSettings { }, sort_mode: project_panel.sort_mode.unwrap(), diagnostic_badges: project_panel.diagnostic_badges.unwrap(), + git_status_indicator: project_panel.git_status_indicator.unwrap(), } } } diff --git a/crates/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs index afcc6db8d1600ed7df438d2e3e5546ba13fe4dd0..55b53cde8b6252f8b9732cf4effc35ea53c073e0 100644 --- a/crates/project_panel/src/project_panel_tests.rs +++ b/crates/project_panel/src/project_panel_tests.rs @@ -10428,7 +10428,7 @@ pub(crate) fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); crate::init(cx); cx.update_global::(|store, cx| { @@ -10446,7 +10446,7 @@ pub(crate) fn init_test(cx: &mut TestAppContext) { fn init_test_with_editor(cx: &mut TestAppContext) { cx.update(|cx| { let app_state = AppState::test(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); crate::init(cx); workspace::init(app_state, cx); diff --git a/crates/project_symbols/Cargo.toml b/crates/project_symbols/Cargo.toml index 83e3cb587d46a5bddf1c8b30c593c18a9b131ad2..da23116e83b465a3ad1aace883d2abb15ad9aa9b 100644 --- a/crates/project_symbols/Cargo.toml +++ b/crates/project_symbols/Cargo.toml @@ -23,6 +23,7 @@ project.workspace = true serde_json.workspace = true settings.workspace = true theme.workspace = true +theme_settings.workspace = true util.workspace = true workspace.workspace = true diff --git a/crates/project_symbols/src/project_symbols.rs b/crates/project_symbols/src/project_symbols.rs index d62935ab3819d2e6857c233a863af434f60f93a3..84b92f3eaa4f0216b881526b3aac42f8980ffe78 100644 --- a/crates/project_symbols/src/project_symbols.rs +++ b/crates/project_symbols/src/project_symbols.rs @@ -9,7 +9,8 @@ use picker::{Picker, PickerDelegate}; use project::{Project, Symbol, lsp_store::SymbolLocation}; use settings::Settings; use std::{cmp::Reverse, sync::Arc}; -use theme::{ActiveTheme, ThemeSettings}; +use theme::ActiveTheme; +use theme_settings::ThemeSettings; use util::ResultExt; use workspace::{ Workspace, @@ -477,7 +478,7 @@ mod tests { cx.update(|cx| { let store = SettingsStore::test(cx); cx.set_global(store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); release_channel::init(semver::Version::new(0, 0, 0), cx); editor::init(cx); }); diff --git a/crates/prompt_store/src/prompts.rs b/crates/prompt_store/src/prompts.rs index 6a845bb8dd394f8a1ff26a8a0e130156a2a158bd..b0052947c44445be37f99e99cf723d5aa53c5008 100644 --- a/crates/prompt_store/src/prompts.rs +++ b/crates/prompt_store/src/prompts.rs @@ -26,9 +26,9 @@ pub const RULES_FILE_NAMES: &[&str] = &[ ".windsurfrules", ".clinerules", ".github/copilot-instructions.md", - "CLAUDE.md", "AGENT.md", "AGENTS.md", + "CLAUDE.md", "GEMINI.md", ]; diff --git a/crates/recent_projects/src/disconnected_overlay.rs b/crates/recent_projects/src/disconnected_overlay.rs index 732b50c123d9d61750781df81ce00b392997af3c..e78762eb283160f84b163771b9835188d2ffce4a 100644 --- a/crates/recent_projects/src/disconnected_overlay.rs +++ b/crates/recent_projects/src/disconnected_overlay.rs @@ -125,7 +125,7 @@ impl DisconnectedOverlay { paths, app_state, OpenOptions { - replace_window: Some(window_handle), + requesting_window: Some(window_handle), ..Default::default() }, cx, diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index cd01af2ce9778af441c31d17e4424627997b2495..4dc06036ef8416fd859cc815ab090ba5896c0040 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -46,7 +46,7 @@ use ui::{ }; use util::{ResultExt, paths::PathExt}; use workspace::{ - HistoryManager, ModalView, MultiWorkspace, OpenOptions, OpenVisible, PathList, + HistoryManager, ModalView, MultiWorkspace, OpenMode, OpenOptions, OpenVisible, PathList, SerializedWorkspaceLocation, Workspace, WorkspaceDb, WorkspaceId, notifications::DetachAndPromptErr, with_active_or_new_workspace, }; @@ -262,13 +262,13 @@ pub fn init(cx: &mut App) { user: None, }); - let replace_window = match create_new_window { + let requesting_window = match create_new_window { false => window_handle, true => None, }; let open_options = workspace::OpenOptions { - replace_window, + requesting_window, ..Default::default() }; @@ -321,7 +321,7 @@ pub fn init(cx: &mut App) { let fs = workspace.project().read(cx).fs().clone(); add_wsl_distro(fs, &open_wsl.distro, cx); let open_options = OpenOptions { - replace_window: window.window_handle().downcast::(), + requesting_window: window.window_handle().downcast::(), ..Default::default() }; @@ -1031,14 +1031,14 @@ impl PickerDelegate for RecentProjectsDelegate { if let Some(handle) = window.window_handle().downcast::() { cx.defer(move |cx| { handle - .update(cx, |multi_workspace, _window, cx| { + .update(cx, |multi_workspace, window, cx| { let workspace = multi_workspace .workspaces() .iter() .find(|ws| ws.read(cx).database_id() == Some(workspace_id)) .cloned(); if let Some(workspace) = workspace { - multi_workspace.activate(workspace, cx); + multi_workspace.activate(workspace, window, cx); } }) .log_err(); @@ -1079,7 +1079,12 @@ impl PickerDelegate for RecentProjectsDelegate { cx.defer(move |cx| { if let Some(task) = handle .update(cx, |multi_workspace, window, cx| { - multi_workspace.open_project(paths, window, cx) + multi_workspace.open_project( + paths, + OpenMode::Replace, + window, + cx, + ) }) .log_err() { @@ -1090,7 +1095,12 @@ impl PickerDelegate for RecentProjectsDelegate { return; } else { workspace - .open_workspace_for_paths(false, paths, window, cx) + .open_workspace_for_paths( + OpenMode::NewWindow, + paths, + window, + cx, + ) .detach_and_prompt_err( "Failed to open project", window, @@ -1107,7 +1117,7 @@ impl PickerDelegate for RecentProjectsDelegate { None }; let open_options = OpenOptions { - replace_window, + requesting_window: replace_window, ..Default::default() }; if let RemoteConnectionOptions::Ssh(connection) = &mut connection { @@ -1804,12 +1814,13 @@ impl RecentProjectsDelegate { cx.defer(move |cx| { handle .update(cx, |multi_workspace, window, cx| { - let index = multi_workspace + let workspace = multi_workspace .workspaces() .iter() - .position(|ws| ws.read(cx).database_id() == Some(workspace_id)); - if let Some(index) = index { - multi_workspace.remove_workspace(index, window, cx); + .find(|ws| ws.read(cx).database_id() == Some(workspace_id)) + .cloned(); + if let Some(workspace) = workspace { + multi_workspace.remove(&workspace, window, cx); } }) .log_err(); @@ -1886,7 +1897,7 @@ mod tests { use super::*; #[gpui::test] - async fn test_dirty_workspace_survives_when_opening_recent_project(cx: &mut TestAppContext) { + async fn test_dirty_workspace_replaced_when_opening_recent_project(cx: &mut TestAppContext) { let app_state = init_test(cx); cx.update(|cx| { @@ -1995,6 +2006,15 @@ mod tests { cx.dispatch_action(*multi_workspace, menu::Confirm); cx.run_until_parked(); + // prepare_to_close triggers a save prompt for the dirty buffer. + // Choose "Don't Save" (index 2) to discard and continue replacing. + assert!( + cx.has_pending_prompt(), + "Should prompt to save dirty buffer before replacing workspace" + ); + cx.simulate_prompt_answer("Don't Save"); + cx.run_until_parked(); + multi_workspace .update(cx, |multi_workspace, _, cx| { assert!( @@ -2007,26 +2027,16 @@ mod tests { ); assert!( - multi_workspace.workspaces().len() >= 2, - "Should have at least 2 workspaces: the dirty one and the newly opened one" + !multi_workspace.workspaces().contains(&dirty_workspace), + "The original dirty workspace should have been replaced" ); assert!( - multi_workspace.workspaces().contains(&dirty_workspace), - "The original dirty workspace should still be present" - ); - - assert!( - dirty_workspace.read(cx).is_edited(), - "The original workspace should still be dirty" + !multi_workspace.workspace().read(cx).is_edited(), + "The active workspace should be the freshly opened one, not dirty" ); }) .unwrap(); - - assert!( - !cx.has_pending_prompt(), - "No save prompt in multi-workspace mode — dirty workspace survives in background" - ); } fn open_recent_projects( diff --git a/crates/recent_projects/src/remote_connections.rs b/crates/recent_projects/src/remote_connections.rs index 5275cdaa1526a670e817ff3b229d7e92b94bb309..3611b55ec65c94695e4e8835fa7afe8badc80a29 100644 --- a/crates/recent_projects/src/remote_connections.rs +++ b/crates/recent_projects/src/remote_connections.rs @@ -132,7 +132,7 @@ pub async fn open_remote_project( open_options: workspace::OpenOptions, cx: &mut AsyncApp, ) -> Result<()> { - let created_new_window = open_options.replace_window.is_none(); + let created_new_window = open_options.requesting_window.is_none(); let (existing, open_visible) = find_existing_workspace( &paths, @@ -159,7 +159,7 @@ pub async fn open_remote_project( let open_results = existing_window .update(cx, |multi_workspace, window, cx| { window.activate_window(); - multi_workspace.activate(existing_workspace.clone(), cx); + multi_workspace.activate(existing_workspace.clone(), window, cx); existing_workspace.update(cx, |workspace, cx| { workspace.open_paths( resolved_paths, @@ -201,7 +201,7 @@ pub async fn open_remote_project( ); } - let (window, initial_workspace) = if let Some(window) = open_options.replace_window { + let (window, initial_workspace) = if let Some(window) = open_options.requesting_window { let workspace = window.update(cx, |multi_workspace, _, _| { multi_workspace.workspace().clone() })?; @@ -854,7 +854,7 @@ mod tests { paths, app_state, workspace::OpenOptions { - replace_window: Some(window), + requesting_window: Some(window), ..Default::default() }, &mut async_cx, diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index 4569492d4c73b6e8087cf8363db805a645e5314e..f7054687579155d4895ae191de1b7fa7cd14fbf6 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -1566,7 +1566,7 @@ impl RemoteServerProjects { project.paths.into_iter().map(PathBuf::from).collect(), app_state, OpenOptions { - replace_window, + requesting_window: replace_window, ..OpenOptions::default() }, cx, @@ -1852,7 +1852,6 @@ impl RemoteServerProjects { cx: &mut Context, ) { let replace_window = window.window_handle().downcast::(); - let app_state = Arc::downgrade(&app_state); cx.spawn_in(window, async move |entity, cx| { let (connection, starting_dir) = @@ -1895,7 +1894,7 @@ impl RemoteServerProjects { vec![starting_dir].into_iter().map(PathBuf::from).collect(), app_state, OpenOptions { - replace_window, + requesting_window: replace_window, ..OpenOptions::default() }, cx, diff --git a/crates/recent_projects/src/sidebar_recent_projects.rs b/crates/recent_projects/src/sidebar_recent_projects.rs index bef88557b12aa076658799ff0c08518c68b6e729..4741c23049b34263c9b65d6c751675543d01c3df 100644 --- a/crates/recent_projects/src/sidebar_recent_projects.rs +++ b/crates/recent_projects/src/sidebar_recent_projects.rs @@ -17,8 +17,8 @@ use ui::{KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*}; use ui_input::ErasedEditor; use util::{ResultExt, paths::PathExt}; use workspace::{ - MultiWorkspace, OpenOptions, PathList, SerializedWorkspaceLocation, Workspace, WorkspaceDb, - WorkspaceId, notifications::DetachAndPromptErr, + MultiWorkspace, OpenMode, OpenOptions, PathList, SerializedWorkspaceLocation, Workspace, + WorkspaceDb, WorkspaceId, notifications::DetachAndPromptErr, }; use crate::{highlights_for_path, icon_for_remote_connection, open_remote_project}; @@ -272,7 +272,7 @@ impl PickerDelegate for SidebarRecentProjectsDelegate { cx.defer(move |cx| { if let Some(task) = handle .update(cx, |multi_workspace, window, cx| { - multi_workspace.open_project(paths, window, cx) + multi_workspace.open_project(paths, OpenMode::Activate, window, cx) }) .log_err() { @@ -287,7 +287,7 @@ impl PickerDelegate for SidebarRecentProjectsDelegate { let app_state = workspace.app_state().clone(); let replace_window = window.window_handle().downcast::(); let open_options = OpenOptions { - replace_window, + requesting_window: replace_window, ..Default::default() }; if let RemoteConnectionOptions::Ssh(connection) = &mut connection { @@ -403,8 +403,8 @@ impl PickerDelegate for SidebarRecentProjectsDelegate { Some( v_flex() - .flex_1() .p_1p5() + .flex_1() .gap_1() .border_t_1() .border_color(cx.theme().colors().border_variant) @@ -414,9 +414,10 @@ impl PickerDelegate for SidebarRecentProjectsDelegate { }; Button::new("open_local_folder", "Add Local Project") .key_binding(KeyBinding::for_action_in(&open_action, &focus_handle, cx)) - .on_click(move |_, window, cx| { + .on_click(cx.listener(move |_, _, window, cx| { + cx.emit(DismissEvent); window.dispatch_action(open_action.boxed_clone(), cx) - }) + })) }) .into_any(), ) diff --git a/crates/recent_projects/src/wsl_picker.rs b/crates/recent_projects/src/wsl_picker.rs index 7f2a69eb68cb93742d98f438f75f74c95bf3f7d5..9c08c4f5f4941a80afdd2d9cbb6f2c51ee8ec754 100644 --- a/crates/recent_projects/src/wsl_picker.rs +++ b/crates/recent_projects/src/wsl_picker.rs @@ -235,9 +235,6 @@ impl WslOpenModal { cx: &mut Context, ) { let app_state = workspace::AppState::global(cx); - let Some(app_state) = app_state.upgrade() else { - return; - }; let connection_options = RemoteConnectionOptions::Wsl(WslConnectionOptions { distro_name: distro.to_string(), @@ -248,14 +245,16 @@ impl WslOpenModal { true => secondary, false => !secondary, }; - let replace_window = match replace_current_window { - true => window.window_handle().downcast::(), - false => None, + let open_mode = if replace_current_window { + workspace::OpenMode::Replace + } else { + workspace::OpenMode::NewWindow }; let paths = self.paths.clone(); let open_options = workspace::OpenOptions { - replace_window, + requesting_window: window.window_handle().downcast::(), + open_mode, ..Default::default() }; diff --git a/crates/remote_connection/Cargo.toml b/crates/remote_connection/Cargo.toml index 53e20eb5eb0708252a90819d37b38e214aa95d67..d3b37f6985bb0b47a1a1902fc5a856c2df974a60 100644 --- a/crates/remote_connection/Cargo.toml +++ b/crates/remote_connection/Cargo.toml @@ -28,7 +28,7 @@ release_channel.workspace = true remote.workspace = true semver.workspace = true settings.workspace = true -theme.workspace = true +theme_settings.workspace = true ui.workspace = true ui_input.workspace = true workspace.workspace = true \ No newline at end of file diff --git a/crates/remote_connection/src/remote_connection.rs b/crates/remote_connection/src/remote_connection.rs index d4df85d7b94b52c6f6bef0f052e515797b4f79c3..df6260d1c5b3cd1704bfe0ce6a8476bbc0f39670 100644 --- a/crates/remote_connection/src/remote_connection.rs +++ b/crates/remote_connection/src/remote_connection.rs @@ -13,10 +13,10 @@ use release_channel::ReleaseChannel; use remote::{ConnectionIdentifier, RemoteClient, RemoteConnectionOptions, RemotePlatform}; use semver::Version; use settings::Settings; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ - ActiveTheme, Color, CommonAnimationExt, Context, InteractiveElement, IntoElement, KeyBinding, - LabelCommon, ListItem, Styled, Window, prelude::*, + ActiveTheme, CommonAnimationExt, Context, InteractiveElement, KeyBinding, ListItem, Tooltip, + prelude::*, }; use ui_input::{ERASED_EDITOR_FACTORY, ErasedEditor}; use workspace::{DismissDecision, ModalView}; @@ -30,6 +30,8 @@ pub struct RemoteConnectionPrompt { prompt: Option<(Entity, oneshot::Sender)>, cancellation: Option>, editor: Arc, + is_password_prompt: bool, + is_masked: bool, } impl Drop for RemoteConnectionPrompt { @@ -70,6 +72,8 @@ impl RemoteConnectionPrompt { status_message: None, cancellation: None, prompt: None, + is_password_prompt: false, + is_masked: true, } } @@ -85,7 +89,9 @@ impl RemoteConnectionPrompt { cx: &mut Context, ) { let is_yes_no = prompt.contains("yes/no"); - self.editor.set_masked(!is_yes_no, window, cx); + self.is_password_prompt = !is_yes_no; + self.is_masked = !is_yes_no; + self.editor.set_masked(self.is_masked, window, cx); let markdown = cx.new(|cx| Markdown::new_text(prompt.into(), cx)); self.prompt = Some((markdown, tx)); @@ -133,40 +139,87 @@ impl Render for RemoteConnectionPrompt { ..Default::default() }; + let is_password_prompt = self.is_password_prompt; + let is_masked = self.is_masked; + let (masked_password_icon, masked_password_tooltip) = if is_masked { + (IconName::Eye, "Toggle to Unmask Password") + } else { + (IconName::EyeOff, "Toggle to Mask Password") + }; + v_flex() .key_context("PasswordPrompt") .p_2() .size_full() - .text_buffer(cx) - .when_some(self.status_message.clone(), |el, status_message| { - el.child( + .when_some(self.prompt.as_ref(), |this, prompt| { + this.child( + v_flex() + .text_sm() + .size_full() + .overflow_hidden() + .child( + h_flex() + .w_full() + .justify_between() + .child(MarkdownElement::new(prompt.0.clone(), markdown_style)) + .when(is_password_prompt, |this| { + this.child( + IconButton::new("toggle_mask", masked_password_icon) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text(masked_password_tooltip)) + .on_click(cx.listener(|this, _, window, cx| { + this.is_masked = !this.is_masked; + this.editor.set_masked(this.is_masked, window, cx); + window.focus(&this.editor.focus_handle(cx), cx); + cx.notify(); + })), + ) + }), + ) + .child(div().flex_1().child(self.editor.render(window, cx))), + ) + .when(window.capslock().on, |this| { + this.child( + h_flex() + .py_0p5() + .min_w_0() + .w_full() + .gap_1() + .child( + Icon::new(IconName::Warning) + .size(IconSize::Small) + .color(Color::Muted), + ) + .child( + Label::new("Caps lock is on.") + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + }) + }) + .when_some(self.status_message.clone(), |this, status_message| { + this.child( h_flex() - .gap_2() + .min_w_0() + .w_full() + .mt_1() + .gap_1() .child( - Icon::new(IconName::ArrowCircle) + Icon::new(IconName::LoadCircle) + .size(IconSize::Small) .color(Color::Muted) .with_rotate_animation(2), ) .child( - div() - .text_ellipsis() - .overflow_x_hidden() - .child(format!("{}…", status_message)), + Label::new(format!("{}…", status_message)) + .size(LabelSize::Small) + .color(Color::Muted) + .truncate() + .flex_1(), ), ) }) - .when_some(self.prompt.as_ref(), |el, prompt| { - el.child( - div() - .size_full() - .overflow_hidden() - .child(MarkdownElement::new(prompt.0.clone(), markdown_style)) - .child(self.editor.render(window, cx)), - ) - .when(window.capslock().on, |el| { - el.child(Label::new("⚠️ ⇪ is on")) - }) - }) } } diff --git a/crates/remote_server/Cargo.toml b/crates/remote_server/Cargo.toml index 36944261cded68b564df8093d5b7a7621a644c11..c6ce45ba1ce28386d0776eb40299919f92aa8e53 100644 --- a/crates/remote_server/Cargo.toml +++ b/crates/remote_server/Cargo.toml @@ -98,6 +98,7 @@ node_runtime = { workspace = true, features = ["test-support"] } pretty_assertions.workspace = true project = { workspace = true, features = ["test-support"] } remote = { workspace = true, features = ["test-support"] } +theme_settings.workspace = true theme = { workspace = true, features = ["test-support"] } language_model = { workspace = true, features = ["test-support"] } lsp = { workspace = true, features = ["test-support"] } diff --git a/crates/remote_server/src/headless_project.rs b/crates/remote_server/src/headless_project.rs index ac6be6d413c08a73b1aa872b1f5acef6931d9c12..c725f8177648ea0ca16106251e65908255a38d6d 100644 --- a/crates/remote_server/src/headless_project.rs +++ b/crates/remote_server/src/headless_project.rs @@ -129,7 +129,6 @@ impl HeadlessProject { worktree_store.clone(), environment.clone(), manifest_tree.clone(), - fs.clone(), cx, ) }); diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index 0f1d1e3769c405abce5ebf55818f19e64afadc82..86b7f93eb2c737cac55dbf2882f91ec277e4e174 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -7,6 +7,7 @@ use client::{Client, UserStore}; use clock::FakeSystemClock; use collections::{HashMap, HashSet}; use language_model::LanguageModelToolResultContent; +use languages::rust_lang; use extension::ExtensionHostProxy; use fs::{FakeFs, Fs}; @@ -14,7 +15,7 @@ use gpui::{AppContext as _, Entity, SharedString, TestAppContext}; use http_client::{BlockedHttpClient, FakeHttpClient}; use language::{ Buffer, FakeLspAdapter, LanguageConfig, LanguageMatcher, LanguageRegistry, LineEnding, - language_settings::{AllLanguageSettings, language_settings}, + language_settings::{AllLanguageSettings, LanguageSettings}, }; use lsp::{ CompletionContext, CompletionResponse, CompletionTriggerKind, DEFAULT_LSP_REQUEST_TIMEOUT, @@ -481,6 +482,7 @@ async fn test_remote_settings(cx: &mut TestAppContext, server_cx: &mut TestAppCo let worktree_id = project .update(cx, |project, cx| { + project.languages().add(rust_lang()); project.find_or_create_worktree("/code/project1", true, cx) }) .await @@ -521,9 +523,8 @@ async fn test_remote_settings(cx: &mut TestAppContext, server_cx: &mut TestAppCo }); cx.read(|cx| { - let file = buffer.read(cx).file(); assert_eq!( - language_settings(Some("Rust".into()), file, cx).language_servers, + LanguageSettings::for_buffer(buffer.read(cx), cx).language_servers, ["override-rust-analyzer".to_string()] ) }); @@ -646,6 +647,7 @@ async fn test_remote_lsp(cx: &mut TestAppContext, server_cx: &mut TestAppContext let worktree_id = project .update(cx, |project, cx| { + project.languages().add(rust_lang()); project.find_or_create_worktree(path!("/code/project1"), true, cx) }) .await @@ -668,9 +670,8 @@ async fn test_remote_lsp(cx: &mut TestAppContext, server_cx: &mut TestAppContext let fake_second_lsp = fake_second_lsp.next().await.unwrap(); cx.read(|cx| { - let file = buffer.read(cx).file(); assert_eq!( - language_settings(Some("Rust".into()), file, cx).language_servers, + LanguageSettings::for_buffer(buffer.read(cx), cx).language_servers, ["rust-analyzer".to_string(), "fake-analyzer".to_string()] ) }); @@ -1660,7 +1661,7 @@ async fn test_remote_git_diffs_when_recv_update_repository_delay( cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); release_channel::init(semver::Version::new(0, 0, 0), cx); editor::init(cx); }); diff --git a/crates/repl/Cargo.toml b/crates/repl/Cargo.toml index 4329b29ada504cf536337c94b14790acea73ea11..5477c1c5107e7450ad2eaeaba6a880256b62f30f 100644 --- a/crates/repl/Cargo.toml +++ b/crates/repl/Cargo.toml @@ -53,6 +53,7 @@ telemetry.workspace = true terminal.workspace = true terminal_view.workspace = true theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true uuid.workspace = true diff --git a/crates/repl/src/notebook/cell.rs b/crates/repl/src/notebook/cell.rs index 200424742aff113d637fe9aca30999c0f95e79a5..ba70e50f8cbccc32bef5de5c1864a3d8db46aa89 100644 --- a/crates/repl/src/notebook/cell.rs +++ b/crates/repl/src/notebook/cell.rs @@ -12,7 +12,7 @@ use markdown::{Markdown, MarkdownElement, MarkdownStyle}; use nbformat::v4::{CellId, CellMetadata, CellType}; use runtimelib::{JupyterMessage, JupyterMessageContent}; use settings::Settings as _; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{CommonAnimationExt, IconButtonShape, prelude::*}; use util::ResultExt; diff --git a/crates/repl/src/outputs.rs b/crates/repl/src/outputs.rs index f6d2bc4d3173ce64700b7b5ac45301df0fe0ab53..ad0bd56858636bf8fbd2501bab28aae25b99c2a0 100644 --- a/crates/repl/src/outputs.rs +++ b/crates/repl/src/outputs.rs @@ -895,7 +895,7 @@ mod tests { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); }); let fs = project::FakeFs::new(cx.background_executor.clone()); let project = project::Project::test(fs, [] as [&Path; 0], cx).await; diff --git a/crates/repl/src/outputs/plain.rs b/crates/repl/src/outputs/plain.rs index 71e2624f8ad7b0172a86793d5d81b38339b04f36..bc6d04019ce0129529a886e827c3f2ec8e6574ce 100644 --- a/crates/repl/src/outputs/plain.rs +++ b/crates/repl/src/outputs/plain.rs @@ -27,7 +27,7 @@ use language::Buffer; use settings::Settings as _; use terminal::terminal_settings::TerminalSettings; use terminal_view::terminal_element::TerminalElement; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{IntoElement, prelude::*}; use crate::outputs::OutputContent; @@ -275,7 +275,7 @@ mod tests { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); }); cx.add_empty_window() } diff --git a/crates/repl/src/outputs/table.rs b/crates/repl/src/outputs/table.rs index f6bf30f394d2232750f7f1beb21dbbc27c0ba941..fc5ccaf75a5b25ba9b32db68e47a96d876f68cf7 100644 --- a/crates/repl/src/outputs/table.rs +++ b/crates/repl/src/outputs/table.rs @@ -59,7 +59,7 @@ use runtimelib::datatable::TableSchema; use runtimelib::media::datatable::TabularDataResource; use serde_json::Value; use settings::Settings; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{IntoElement, Styled, div, prelude::*, v_flex}; use util::markdown::MarkdownEscaped; diff --git a/crates/rpc/src/message_stream.rs b/crates/rpc/src/message_stream.rs index 023e916df3113e73adafdc0d38948121ad2e9cec..34888d98147124c5c971843d0124fceb95e47cde 100644 --- a/crates/rpc/src/message_stream.rs +++ b/crates/rpc/src/message_stream.rs @@ -7,7 +7,6 @@ use futures::{SinkExt as _, StreamExt as _}; use proto::Message as _; use std::time::Instant; use std::{fmt::Debug, io}; -use zstd::zstd_safe::WriteBuf; const KIB: usize = 1024; const MIB: usize = KIB * 1024; @@ -87,7 +86,10 @@ where let received_at = Instant::now(); match bytes? { WebSocketMessage::Binary(bytes) => { - zstd::stream::copy_decode(bytes.as_slice(), &mut self.encoding_buffer)?; + zstd::stream::copy_decode( + zstd::zstd_safe::WriteBuf::as_slice(&*bytes), + &mut self.encoding_buffer, + )?; let envelope = Envelope::decode(self.encoding_buffer.as_slice()) .map_err(io::Error::from)?; diff --git a/crates/rules_library/Cargo.toml b/crates/rules_library/Cargo.toml index 59c298de923f98135c99fca0c8da2fa42ac2e17e..352f86bd72fca294745cc0f74b401cc48f35d7fd 100644 --- a/crates/rules_library/Cargo.toml +++ b/crates/rules_library/Cargo.toml @@ -28,7 +28,7 @@ release_channel.workspace = true rope.workspace = true serde.workspace = true settings.workspace = true -theme.workspace = true +theme_settings.workspace = true ui.workspace = true ui_input.workspace = true util.workspace = true diff --git a/crates/rules_library/src/rules_library.rs b/crates/rules_library/src/rules_library.rs index b4ff8033446410d063cddccfa6b76eaa77ecfac9..1c8e90794674dfc737480981954f91312add1ee5 100644 --- a/crates/rules_library/src/rules_library.rs +++ b/crates/rules_library/src/rules_library.rs @@ -20,7 +20,7 @@ use std::rc::Rc; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::time::Duration; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{Divider, ListItem, ListItemSpacing, ListSubHeader, Tooltip, prelude::*}; use ui_input::ErasedEditor; use util::{ResultExt, TryFutureExt}; @@ -1392,7 +1392,7 @@ impl RulesLibrary { impl Render for RulesLibrary { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let ui_font = theme::setup_ui_font(window, cx); + let ui_font = theme_settings::setup_ui_font(window, cx); let theme = cx.theme().clone(); client_side_decorations( diff --git a/crates/schema_generator/Cargo.toml b/crates/schema_generator/Cargo.toml index b92298a3b41d62b861c19a1f22ceaee0d63828b5..71beb54597e72286cbf539897741088dde873e6c 100644 --- a/crates/schema_generator/Cargo.toml +++ b/crates/schema_generator/Cargo.toml @@ -17,3 +17,4 @@ serde.workspace = true serde_json.workspace = true settings.workspace = true theme.workspace = true +theme_settings.workspace = true \ No newline at end of file diff --git a/crates/schema_generator/src/main.rs b/crates/schema_generator/src/main.rs index a77060c54d1361dc96204238a282f8e75946a37b..d34cd897b9e7eb27b6c9343513d85ed8497d291a 100644 --- a/crates/schema_generator/src/main.rs +++ b/crates/schema_generator/src/main.rs @@ -2,7 +2,8 @@ use anyhow::Result; use clap::{Parser, ValueEnum}; use schemars::schema_for; use settings::ProjectSettingsContent; -use theme::{IconThemeFamilyContent, ThemeFamilyContent}; +use theme::IconThemeFamilyContent; +use theme_settings::ThemeFamilyContent; #[derive(Parser, Debug)] pub struct Args { diff --git a/crates/search/Cargo.toml b/crates/search/Cargo.toml index 9ea013af6c315ff11508b195e9d79493d05fee6b..4213aa39a046e944cd34f9a1530bd15d1c442863 100644 --- a/crates/search/Cargo.toml +++ b/crates/search/Cargo.toml @@ -38,6 +38,7 @@ serde_json.workspace = true settings.workspace = true smol.workspace = true theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true util_macros.workspace = true diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index 5381e47db092fb65ca3cdb844987c6714ca4cd76..cab8e20cd22e1f4155232f36416be77d4f2ca24d 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -20,9 +20,9 @@ use editor::{ }; use futures::channel::oneshot; use gpui::{ - App, ClickEvent, Context, Entity, EventEmitter, Focusable, InteractiveElement as _, - IntoElement, KeyContext, ParentElement as _, Render, ScrollHandle, Styled, Subscription, Task, - WeakEntity, Window, div, + Action as _, App, ClickEvent, Context, Entity, EventEmitter, Focusable, + InteractiveElement as _, IntoElement, KeyContext, ParentElement as _, Render, ScrollHandle, + Styled, Subscription, Task, WeakEntity, Window, div, }; use language::{Language, LanguageRegistry}; use project::{ @@ -33,7 +33,9 @@ use project::{ use fs::Fs; use settings::{DiffViewStyle, Settings, update_settings_file}; use std::{any::TypeId, sync::Arc}; -use zed_actions::{outline::ToggleOutline, workspace::CopyPath, workspace::CopyRelativePath}; +use zed_actions::{ + OpenSettingsAt, outline::ToggleOutline, workspace::CopyPath, workspace::CopyRelativePath, +}; use ui::{ BASE_REM_SIZE_IN_PX, IconButtonShape, PlatformStyle, TextSize, Tooltip, prelude::*, @@ -110,96 +112,97 @@ impl Render for BufferSearchBar { .as_ref() .and_then(|weak| weak.upgrade()) .map(|splittable_editor| { - let is_split = splittable_editor.read(cx).is_split(); + let editor_ref = splittable_editor.read(cx); + let diff_view_style = editor_ref.diff_view_style(); + let is_split = editor_ref.is_split(); + let min_columns = + EditorSettings::get_global(cx).minimum_split_diff_width as u32; + + let mut split_button = IconButton::new("diff-split", IconName::DiffSplit) + .shape(IconButtonShape::Square) + .tooltip(Tooltip::element(move |_, cx| { + let message = if min_columns == 0 { + SharedString::from("Split") + } else { + format!("Split when wider than {} columns", min_columns).into() + }; + + v_flex() + .child(message) + .child( + h_flex() + .gap_0p5() + .text_ui_sm(cx) + .text_color(Color::Muted.color(cx)) + .children(render_modifiers( + &gpui::Modifiers::secondary_key(), + PlatformStyle::platform(), + None, + Some(TextSize::Small.rems(cx).into()), + false, + )) + .child("click to change min width"), + ) + .into_any() + })) + .on_click({ + let splittable_editor = splittable_editor.downgrade(); + move |_, window, cx| { + if window.modifiers().secondary() { + window.dispatch_action( + OpenSettingsAt { + path: "minimum_split_diff_width".to_string(), + } + .boxed_clone(), + cx, + ); + } else { + update_settings_file( + ::global(cx), + cx, + |settings, _| { + settings.editor.diff_view_style = + Some(DiffViewStyle::Split); + }, + ); + if diff_view_style == DiffViewStyle::Unified { + splittable_editor + .update(cx, |editor, cx| { + editor.toggle_split(&ToggleSplitDiff, window, cx); + }) + .ok(); + } + } + } + }); + + if diff_view_style == DiffViewStyle::Split { + if !is_split { + split_button = split_button.icon_color(Color::Disabled) + } else { + split_button = split_button.toggle_state(true) + } + } + h_flex() .gap_1() .child( IconButton::new("diff-unified", IconName::DiffUnified) .shape(IconButtonShape::Square) - .toggle_state(!is_split) - .tooltip(Tooltip::element(move |_, cx| { - v_flex() - .child("Unified") - .child( - h_flex() - .gap_0p5() - .text_ui_sm(cx) - .text_color(Color::Muted.color(cx)) - .children(render_modifiers( - &gpui::Modifiers::secondary_key(), - PlatformStyle::platform(), - None, - Some(TextSize::Small.rems(cx).into()), - false, - )) - .child("click to set as default"), - ) - .into_any() - })) - .on_click({ - let splittable_editor = splittable_editor.downgrade(); - move |_, window, cx| { - if window.modifiers().secondary() { - update_settings_file( - ::global(cx), - cx, - |settings, _| { - settings.editor.diff_view_style = - Some(DiffViewStyle::Unified); - }, - ); - } - if is_split { - splittable_editor - .update(cx, |editor, cx| { - editor.toggle_split( - &ToggleSplitDiff, - window, - cx, - ); - }) - .ok(); - } - } - }), - ) - .child( - IconButton::new("diff-split", IconName::DiffSplit) - .shape(IconButtonShape::Square) - .toggle_state(is_split) - .tooltip(Tooltip::element(move |_, cx| { - v_flex() - .child("Split") - .child( - h_flex() - .gap_0p5() - .text_ui_sm(cx) - .text_color(Color::Muted.color(cx)) - .children(render_modifiers( - &gpui::Modifiers::secondary_key(), - PlatformStyle::platform(), - None, - Some(TextSize::Small.rems(cx).into()), - false, - )) - .child("click to set as default"), - ) - .into_any() - })) + .toggle_state(diff_view_style == DiffViewStyle::Unified) + .tooltip(Tooltip::text("Unified")) .on_click({ let splittable_editor = splittable_editor.downgrade(); move |_, window, cx| { - if window.modifiers().secondary() { - update_settings_file( - ::global(cx), - cx, - |settings, _| { - settings.editor.diff_view_style = - Some(DiffViewStyle::Split); - }, - ); - } - if !is_split { + update_settings_file( + ::global(cx), + cx, + |settings, _| { + settings.editor.diff_view_style = + Some(DiffViewStyle::Unified); + }, + ); + if diff_view_style == DiffViewStyle::Split { splittable_editor .update(cx, |editor, cx| { editor.toggle_split( @@ -213,6 +216,7 @@ impl Render for BufferSearchBar { } }), ) + .child(split_button) }) } else { None @@ -1906,7 +1910,7 @@ mod tests { cx.set_global(store); editor::init(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); crate::init(cx); }); } diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index 97c6cbad52e00d991dca3cb41d118815d335e5ae..991f8d1076a985e1413b0045aa42d424f094cd9c 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -936,6 +936,7 @@ impl ProjectSearchView { let query_editor = cx.new(|cx| { let mut editor = Editor::auto_height(1, 4, window, cx); editor.set_placeholder_text("Search all files…", window, cx); + editor.set_use_autoclose(false); editor.set_text(query_text, window, cx); editor }); @@ -5143,7 +5144,7 @@ pub mod tests { let settings = SettingsStore::test(cx); cx.set_global(settings); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); crate::init(cx); diff --git a/crates/search/src/search.rs b/crates/search/src/search.rs index d2104492bebf529821f8ad8571fd3fbb8bdbc69e..8edcdd600bd352d4e33c0c8c1ec9aed3f427c71c 100644 --- a/crates/search/src/search.rs +++ b/crates/search/src/search.rs @@ -85,7 +85,7 @@ pub enum SearchOption { Backwards, } -pub(crate) enum SearchSource<'a, 'b> { +pub enum SearchSource<'a, 'b> { Buffer, Project(&'a Context<'b, ProjectSearchBar>), } @@ -126,7 +126,7 @@ impl SearchOption { } } - pub(crate) fn as_button( + pub fn as_button( &self, active: SearchOptions, search_source: SearchSource, diff --git a/crates/search/src/search_bar.rs b/crates/search/src/search_bar.rs index 436f70d6545a7eaaee23564058fb600fe387b739..a4757631a188752aed7cc631d987a22cd57b06c6 100644 --- a/crates/search/src/search_bar.rs +++ b/crates/search/src/search_bar.rs @@ -1,7 +1,7 @@ use editor::{Editor, EditorElement, EditorStyle, MultiBufferOffset, ToOffset}; use gpui::{Action, App, Entity, FocusHandle, Hsla, IntoElement, TextStyle}; use settings::Settings; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{IconButton, IconButtonShape}; use ui::{Tooltip, prelude::*}; diff --git a/crates/settings/Cargo.toml b/crates/settings/Cargo.toml index 27e8182d37ba1c67700d3a41dbdfc1c4ce27e4d6..a0d75e5b76fd4a0066ff606585088f61a23d19a1 100644 --- a/crates/settings/Cargo.toml +++ b/crates/settings/Cargo.toml @@ -27,7 +27,7 @@ log.workspace = true migrator.workspace = true paths.workspace = true release_channel.workspace = true -rust-embed = { workspace = true, features = ["debug-embed"] } +rust-embed.workspace = true schemars.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/settings/src/keymap_file.rs b/crates/settings/src/keymap_file.rs index 79713bdb5a20250a7b98b81bf73408cd63f55c60..f4529e305a4428b1ab9ead8671542108b963216b 100644 --- a/crates/settings/src/keymap_file.rs +++ b/crates/settings/src/keymap_file.rs @@ -858,41 +858,32 @@ impl KeymapFile { tab_size: usize, keyboard_mapper: &dyn gpui::PlatformKeyboardMapper, ) -> Result { - // When replacing a non-user binding's keystroke, we need to also suppress the old - // default so it doesn't continue showing under the old keystroke. - let mut old_keystroke_suppression: Option<(Option, String)> = None; + // When replacing or removing a non-user binding, we may need to write an unbind entry + // to suppress the original default binding. + let mut suppression_unbind: Option> = None; - match operation { + match &operation { // if trying to replace a keybinding that is not user-defined, treat it as an add operation KeybindUpdateOperation::Replace { target_keybind_source: target_source, source, target, - } if target_source != KeybindSource::User => { + } if *target_source != KeybindSource::User => { if target.keystrokes_unparsed() != source.keystrokes_unparsed() { - old_keystroke_suppression = Some(( - target.context.map(String::from), - target.keystrokes_unparsed(), - )); + suppression_unbind = Some(target.clone()); } operation = KeybindUpdateOperation::Add { - source, - from: Some(target), + source: source.clone(), + from: Some(target.clone()), }; } - // if trying to remove a keybinding that is not user-defined, treat it as creating a binding - // that binds it to `zed::NoAction` + // if trying to remove a keybinding that is not user-defined, treat it as creating an + // unbind entry for the removed action KeybindUpdateOperation::Remove { target, target_keybind_source, - } if target_keybind_source != KeybindSource::User => { - let mut source = target.clone(); - source.action_name = gpui::NoAction.name(); - source.action_arguments.take(); - operation = KeybindUpdateOperation::Add { - source, - from: Some(target), - }; + } if *target_keybind_source != KeybindSource::User => { + suppression_unbind = Some(target.clone()); } _ => {} } @@ -901,34 +892,41 @@ impl KeymapFile { // We don't want to modify the file if it's invalid. let keymap = Self::parse(&keymap_contents).context("Failed to parse keymap")?; - if let KeybindUpdateOperation::Remove { target, .. } = operation { - let target_action_value = target - .action_value() - .context("Failed to generate target action JSON value")?; - let Some((index, keystrokes_str)) = - find_binding(&keymap, &target, &target_action_value, keyboard_mapper) - else { - anyhow::bail!("Failed to find keybinding to remove"); - }; - let is_only_binding = keymap.0[index] - .bindings - .as_ref() - .is_none_or(|bindings| bindings.len() == 1); - let key_path: &[&str] = if is_only_binding { - &[] - } else { - &["bindings", keystrokes_str] - }; - let (replace_range, replace_value) = replace_top_level_array_value_in_json_text( - &keymap_contents, - key_path, - None, - None, - index, - tab_size, - ); - keymap_contents.replace_range(replace_range, &replace_value); - return Ok(keymap_contents); + if let KeybindUpdateOperation::Remove { + target, + target_keybind_source, + } = &operation + { + if *target_keybind_source == KeybindSource::User { + let target_action_value = target + .action_value() + .context("Failed to generate target action JSON value")?; + let Some(binding_location) = + find_binding(&keymap, target, &target_action_value, keyboard_mapper) + else { + anyhow::bail!("Failed to find keybinding to remove"); + }; + let is_only_binding = binding_location.is_only_entry_in_section(&keymap); + let key_path: &[&str] = if is_only_binding { + &[] + } else { + &[ + binding_location.kind.key_path(), + binding_location.keystrokes_str, + ] + }; + let (replace_range, replace_value) = replace_top_level_array_value_in_json_text( + &keymap_contents, + key_path, + None, + None, + binding_location.index, + tab_size, + ); + keymap_contents.replace_range(replace_range, &replace_value); + + return Ok(keymap_contents); + } } if let KeybindUpdateOperation::Replace { source, target, .. } = operation { @@ -939,7 +937,7 @@ impl KeymapFile { .action_value() .context("Failed to generate source action JSON value")?; - if let Some((index, keystrokes_str)) = + if let Some(binding_location) = find_binding(&keymap, &target, &target_action_value, keyboard_mapper) { if target.context == source.context { @@ -948,30 +946,32 @@ impl KeymapFile { let (replace_range, replace_value) = replace_top_level_array_value_in_json_text( &keymap_contents, - &["bindings", keystrokes_str], + &[ + binding_location.kind.key_path(), + binding_location.keystrokes_str, + ], Some(&source_action_value), Some(&source.keystrokes_unparsed()), - index, + binding_location.index, tab_size, ); keymap_contents.replace_range(replace_range, &replace_value); return Ok(keymap_contents); - } else if keymap.0[index] - .bindings - .as_ref() - .is_none_or(|bindings| bindings.len() == 1) - { + } else if binding_location.is_only_entry_in_section(&keymap) { // if we are replacing the only binding in the section, // just update the section in place, updating the context // and the binding let (replace_range, replace_value) = replace_top_level_array_value_in_json_text( &keymap_contents, - &["bindings", keystrokes_str], + &[ + binding_location.kind.key_path(), + binding_location.keystrokes_str, + ], Some(&source_action_value), Some(&source.keystrokes_unparsed()), - index, + binding_location.index, tab_size, ); keymap_contents.replace_range(replace_range, &replace_value); @@ -981,7 +981,7 @@ impl KeymapFile { &["context"], source.context.map(Into::into).as_ref(), None, - index, + binding_location.index, tab_size, ); keymap_contents.replace_range(replace_range, &replace_value); @@ -994,10 +994,13 @@ impl KeymapFile { let (replace_range, replace_value) = replace_top_level_array_value_in_json_text( &keymap_contents, - &["bindings", keystrokes_str], + &[ + binding_location.kind.key_path(), + binding_location.keystrokes_str, + ], None, None, - index, + binding_location.index, tab_size, ); keymap_contents.replace_range(replace_range, &replace_value); @@ -1032,8 +1035,9 @@ impl KeymapFile { } let use_key_equivalents = from.and_then(|from| { let action_value = from.action_value().context("Failed to serialize action value. `use_key_equivalents` on new keybinding may be incorrect.").log_err()?; - let (index, _) = find_binding(&keymap, &from, &action_value, keyboard_mapper)?; - Some(keymap.0[index].use_key_equivalents) + let binding_location = + find_binding(&keymap, &from, &action_value, keyboard_mapper)?; + Some(keymap.0[binding_location.index].use_key_equivalents) }).unwrap_or(false); if use_key_equivalents { value.insert("use_key_equivalents".to_string(), true.into()); @@ -1054,18 +1058,18 @@ impl KeymapFile { keymap_contents.replace_range(replace_range, &replace_value); } - // If we converted a Replace to Add because the target was a non-user binding, - // and the keystroke changed, suppress the old default keystroke with a NoAction - // binding so it doesn't continue appearing under the old keystroke. - if let Some((context, old_keystrokes)) = old_keystroke_suppression { + if let Some(suppression_unbind) = suppression_unbind { let mut value = serde_json::Map::with_capacity(2); - if let Some(context) = context { + if let Some(context) = suppression_unbind.context { value.insert("context".to_string(), context.into()); } - value.insert("bindings".to_string(), { - let mut bindings = serde_json::Map::new(); - bindings.insert(old_keystrokes, Value::Null); - bindings.into() + value.insert("unbind".to_string(), { + let mut unbind = serde_json::Map::new(); + unbind.insert( + suppression_unbind.keystrokes_unparsed(), + suppression_unbind.action_value()?, + ); + unbind.into() }); let (replace_range, replace_value) = append_top_level_array_value_in_json_text( &keymap_contents, @@ -1082,7 +1086,7 @@ impl KeymapFile { target: &KeybindUpdateTarget<'a>, target_action_value: &Value, keyboard_mapper: &dyn gpui::PlatformKeyboardMapper, - ) -> Option<(usize, &'b str)> { + ) -> Option> { let target_context_parsed = KeyBindingContextPredicate::parse(target.context.unwrap_or("")).ok(); for (index, section) in keymap.sections().enumerate() { @@ -1091,40 +1095,108 @@ impl KeymapFile { if section_context_parsed != target_context_parsed { continue; } - let Some(bindings) = §ion.bindings else { + + if let Some(binding_location) = find_binding_in_entries( + section.bindings.as_ref(), + BindingKind::Binding, + index, + target, + target_action_value, + keyboard_mapper, + |action| &action.0, + ) { + return Some(binding_location); + } + + if let Some(binding_location) = find_binding_in_entries( + section.unbind.as_ref(), + BindingKind::Unbind, + index, + target, + target_action_value, + keyboard_mapper, + |action| &action.0, + ) { + return Some(binding_location); + } + } + None + } + + fn find_binding_in_entries<'a, 'b, T>( + entries: Option<&'b IndexMap>, + kind: BindingKind, + index: usize, + target: &KeybindUpdateTarget<'a>, + target_action_value: &Value, + keyboard_mapper: &dyn gpui::PlatformKeyboardMapper, + action_value: impl Fn(&T) -> &Value, + ) -> Option> { + let entries = entries?; + for (keystrokes_str, action) in entries { + let Ok(keystrokes) = keystrokes_str + .split_whitespace() + .map(|source| { + let keystroke = Keystroke::parse(source)?; + Ok(KeybindingKeystroke::new_with_mapper( + keystroke, + false, + keyboard_mapper, + )) + }) + .collect::, InvalidKeystrokeError>>() + else { continue; }; - for (keystrokes_str, action) in bindings { - let Ok(keystrokes) = keystrokes_str - .split_whitespace() - .map(|source| { - let keystroke = Keystroke::parse(source)?; - Ok(KeybindingKeystroke::new_with_mapper( - keystroke, - false, - keyboard_mapper, - )) - }) - .collect::, InvalidKeystrokeError>>() - else { - continue; - }; - if keystrokes.len() != target.keystrokes.len() - || !keystrokes - .iter() - .zip(target.keystrokes) - .all(|(a, b)| a.inner().should_match(b)) - { - continue; - } - if &action.0 != target_action_value { - continue; - } - return Some((index, keystrokes_str)); + if keystrokes.len() != target.keystrokes.len() + || !keystrokes + .iter() + .zip(target.keystrokes) + .all(|(a, b)| a.inner().should_match(b)) + { + continue; + } + if action_value(action) != target_action_value { + continue; } + return Some(BindingLocation { + index, + kind, + keystrokes_str, + }); } None } + + #[derive(Copy, Clone)] + enum BindingKind { + Binding, + Unbind, + } + + impl BindingKind { + fn key_path(self) -> &'static str { + match self { + Self::Binding => "bindings", + Self::Unbind => "unbind", + } + } + } + + struct BindingLocation<'a> { + index: usize, + kind: BindingKind, + keystrokes_str: &'a str, + } + + impl BindingLocation<'_> { + fn is_only_entry_in_section(&self, keymap: &KeymapFile) -> bool { + let section = &keymap.0[self.index]; + let binding_count = section.bindings.as_ref().map_or(0, IndexMap::len); + let unbind_count = section.unbind.as_ref().map_or(0, IndexMap::len); + binding_count + unbind_count == 1 + } + } } } @@ -1858,8 +1930,8 @@ mod tests { } }, { - "bindings": { - "ctrl-a": null + "unbind": { + "ctrl-a": "zed::SomeAction" } } ]"# @@ -1867,7 +1939,7 @@ mod tests { ); // Replacing a non-user binding without changing the keystroke should - // not produce a NoAction suppression entry. + // not produce an unbind suppression entry. check_keymap_update( r#"[ { @@ -1949,8 +2021,8 @@ mod tests { }, { "context": "SomeContext", - "bindings": { - "ctrl-a": null + "unbind": { + "ctrl-a": "zed::SomeAction" } } ]"# @@ -2447,8 +2519,11 @@ mod tests { }, { "context": "SomeContext", - "bindings": { - "a": null + "unbind": { + "a": [ + "foo::baz", + true + ] } } ]"# diff --git a/crates/settings/src/settings_store.rs b/crates/settings/src/settings_store.rs index 26425faf113a9dc0f52ad04809dc71c2f89eeb69..f16f59390939171394684c9fc51e011a8f77a956 100644 --- a/crates/settings/src/settings_store.rs +++ b/crates/settings/src/settings_store.rs @@ -370,6 +370,10 @@ impl SettingsStore { setting_value.set_global_value(value); } + pub fn merged_settings(&self) -> &SettingsContent { + &self.merged_settings + } + /// Get the value of a setting. /// /// Panics if the given setting type has not been registered, or if there is no @@ -542,9 +546,9 @@ impl SettingsStore { update: impl 'static + Send + FnOnce(&mut SettingsContent, &App), ) { _ = self.update_settings_file_inner(fs, move |old_text: String, cx: AsyncApp| { - Ok(cx.read_global(|store: &SettingsStore, cx| { + cx.read_global(|store: &SettingsStore, cx| { store.new_text_for_update(old_text, |content| update(content, cx)) - })) + }) }); } @@ -554,9 +558,9 @@ impl SettingsStore { vscode_settings: VsCodeSettings, ) -> oneshot::Receiver> { self.update_settings_file_inner(fs, move |old_text: String, cx: AsyncApp| { - Ok(cx.read_global(|store: &SettingsStore, _cx| { + cx.read_global(|store: &SettingsStore, _cx| { store.get_vscode_edits(old_text, &vscode_settings) - })) + }) }) } @@ -747,16 +751,16 @@ impl SettingsStore { &self, old_text: String, update: impl FnOnce(&mut SettingsContent), - ) -> String { - let edits = self.edits_for_update(&old_text, update); + ) -> Result { + let edits = self.edits_for_update(&old_text, update)?; let mut new_text = old_text; for (range, replacement) in edits.into_iter() { new_text.replace_range(range, &replacement); } - new_text + Ok(new_text) } - pub fn get_vscode_edits(&self, old_text: String, vscode: &VsCodeSettings) -> String { + pub fn get_vscode_edits(&self, old_text: String, vscode: &VsCodeSettings) -> Result { self.new_text_for_update(old_text, |content| { content.merge_from(&vscode.settings_content()) }) @@ -768,10 +772,17 @@ impl SettingsStore { &self, text: &str, update: impl FnOnce(&mut SettingsContent), - ) -> Vec<(Range, String)> { - let old_content = UserSettingsContent::parse_json_with_comments(text) - .log_err() - .unwrap_or_default(); + ) -> Result, String)>> { + let old_content = if text.trim().is_empty() { + UserSettingsContent::default() + } else { + let (old_content, parse_status) = UserSettingsContent::parse_json(text); + if let ParseStatus::Failed { error } = &parse_status { + log::error!("Failed to parse settings for update: {error}"); + } + old_content + .context("Settings file could not be parsed. Fix syntax errors before updating.")? + }; let mut new_content = old_content.clone(); update(&mut new_content.content); @@ -790,7 +801,18 @@ impl SettingsStore { &new_value, &mut edits, ); - edits + Ok(edits) + } + + /// Mutates the default settings in place and recomputes all setting values. + pub fn update_default_settings( + &mut self, + cx: &mut App, + update: impl FnOnce(&mut SettingsContent), + ) { + let default_settings = Rc::make_mut(&mut self.default_settings); + update(default_settings); + self.recompute_values(None, cx); } /// Sets the default settings via a JSON string. @@ -1688,7 +1710,7 @@ mod tests { cx: &mut App, ) { store.set_user_settings(&old_json, cx).ok(); - let edits = store.edits_for_update(&old_json, update); + let edits = store.edits_for_update(&old_json, update).unwrap(); let mut new_json = old_json; for (range, replacement) in edits.into_iter() { new_json.replace_range(range, &replacement); @@ -1876,6 +1898,39 @@ mod tests { ); } + #[gpui::test] + fn test_edits_for_update_preserves_unknown_keys(cx: &mut App) { + let mut store = SettingsStore::new(cx, &test_settings()); + store.register_setting::(); + + let old_json = r#"{ + "some_unknown_key": "should_be_preserved", + "auto_update": false + }"# + .unindent(); + + check_settings_update( + &mut store, + old_json, + |settings| settings.auto_update = Some(true), + r#"{ + "some_unknown_key": "should_be_preserved", + "auto_update": true + }"# + .unindent(), + cx, + ); + } + + #[gpui::test] + fn test_edits_for_update_returns_error_on_invalid_json(cx: &mut App) { + let store = SettingsStore::new(cx, &test_settings()); + + let invalid_json = r#"{ this is not valid json at all !!!"#; + let result = store.edits_for_update(invalid_json, |_| {}); + assert!(result.is_err()); + } + #[gpui::test] fn test_vscode_import(cx: &mut App) { let mut store = SettingsStore::new(cx, &test_settings()); @@ -1996,10 +2051,12 @@ mod tests { cx: &mut App, ) { store.set_user_settings(&old, cx).ok(); - let new = store.get_vscode_edits( - old, - &VsCodeSettings::from_str(&vscode, VsCodeSettingsSource::VsCode).unwrap(), - ); + let new = store + .get_vscode_edits( + old, + &VsCodeSettings::from_str(&vscode, VsCodeSettingsSource::VsCode).unwrap(), + ) + .unwrap(); pretty_assertions::assert_eq!(new, expected); } @@ -2007,14 +2064,16 @@ mod tests { fn test_update_git_settings(cx: &mut App) { let store = SettingsStore::new(cx, &test_settings()); - let actual = store.new_text_for_update("{}".to_string(), |current| { - current - .git - .get_or_insert_default() - .inline_blame - .get_or_insert_default() - .enabled = Some(true); - }); + let actual = store + .new_text_for_update("{}".to_string(), |current| { + current + .git + .get_or_insert_default() + .inline_blame + .get_or_insert_default() + .enabled = Some(true); + }) + .unwrap(); pretty_assertions::assert_str_eq!( actual, r#"{ diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index abfe0ec727c7388a612c38f5bb0b0c4d0dbf5682..8cb596d46e0bd21358043553252c187c6bbd1202 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -219,6 +219,7 @@ impl VsCodeSettings { vim_mode: None, workspace: self.workspace_settings_content(), which_key: None, + modeline_lines: None, } } @@ -307,6 +308,7 @@ impl VsCodeSettings { completion_menu_scrollbar: None, completion_detail_alignment: None, diff_view_style: None, + minimum_split_diff_width: None, } } @@ -768,6 +770,7 @@ impl VsCodeSettings { fn status_bar_settings_content(&self) -> Option { skip_default(StatusBarSettingsContent { show: self.read_bool("workbench.statusBar.visible"), + show_active_file: None, active_language_button: None, cursor_position_button: None, line_endings_button: None, @@ -807,6 +810,7 @@ impl VsCodeSettings { sticky_scroll: None, auto_open: None, diagnostic_badges: None, + git_status_indicator: None, }; if let (Some(false), Some(false)) = ( @@ -878,6 +882,7 @@ impl VsCodeSettings { scroll_multiplier: None, toolbar: None, show_count_badge: None, + flexible: None, }) } diff --git a/crates/settings_content/Cargo.toml b/crates/settings_content/Cargo.toml index 1908e6623be5766c1ab8b8a9bb91c67906e7b76c..b3599e9eef3b7ac5680f441369a7cbdc98a5d043 100644 --- a/crates/settings_content/Cargo.toml +++ b/crates/settings_content/Cargo.toml @@ -28,9 +28,3 @@ settings_json.workspace = true settings_macros.workspace = true strum.workspace = true util.workspace = true - -# Uncomment other workspace dependencies as needed -# assistant.workspace = true -# client.workspace = true -# project.workspace = true -# settings.workspace = true diff --git a/crates/settings_content/src/agent.rs b/crates/settings_content/src/agent.rs index 1b71f9b33c58b6980431d25f2af51007ae861a1c..716e5fea3d48b969345f2d60bdd1cb84e9ce4d58 100644 --- a/crates/settings_content/src/agent.rs +++ b/crates/settings_content/src/agent.rs @@ -33,6 +33,65 @@ pub enum NewThreadLocation { NewWorktree, } +/// Where to position the sidebar. +#[derive( + Clone, + Copy, + Debug, + Default, + PartialEq, + Eq, + Serialize, + Deserialize, + JsonSchema, + MergeFrom, + strum::VariantArray, + strum::VariantNames, +)] +#[serde(rename_all = "snake_case")] +pub enum SidebarDockPosition { + /// Always show the sidebar on the left side. + #[default] + Left, + /// Always show the sidebar on the right side. + Right, +} + +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub enum SidebarSide { + #[default] + Left, + Right, +} + +/// How thinking blocks should be displayed by default in the agent panel. +#[derive( + Clone, + Copy, + Debug, + Default, + PartialEq, + Eq, + Serialize, + Deserialize, + JsonSchema, + MergeFrom, + strum::VariantArray, + strum::VariantNames, +)] +#[serde(rename_all = "snake_case")] +pub enum ThinkingBlockDisplay { + /// Thinking blocks auto-expand with a height constraint during streaming, + /// then remain in their constrained state when complete. Users can click + /// to fully expand or collapse. + #[default] + Automatic, + /// Thinking blocks are always fully expanded by default (no height constraint). + AlwaysExpanded, + /// Thinking blocks are always collapsed by default. + AlwaysCollapsed, +} + #[with_fallible_options] #[derive(Clone, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom, Debug, Default)] pub struct AgentSettingsContent { @@ -48,6 +107,14 @@ pub struct AgentSettingsContent { /// /// Default: right pub dock: Option, + /// Whether the agent panel should use flexible (proportional) sizing. + /// + /// Default: true + pub flexible: Option, + /// Where to position the sidebar. + /// + /// Default: left + pub sidebar_side: Option, /// Default width in pixels when the agent panel is docked to the left or right. /// /// Default: 640 @@ -122,6 +189,10 @@ pub struct AgentSettingsContent { /// /// Default: true pub expand_terminal_card: Option, + /// How thinking blocks should be displayed by default in the agent panel. + /// + /// Default: automatic + pub thinking_display: Option, /// Whether clicking the stop button on a running terminal tool should also cancel the agent's generation. /// Note that this only applies to the stop button, not to ctrl+c inside the terminal. /// @@ -157,6 +228,14 @@ impl AgentSettingsContent { self.dock = Some(dock); } + pub fn set_sidebar_side(&mut self, position: SidebarDockPosition) { + self.sidebar_side = Some(position); + } + + pub fn set_flexible_size(&mut self, flexible: bool) { + self.flexible = Some(flexible); + } + pub fn set_model(&mut self, language_model: LanguageModelSelection) { self.default_model = Some(language_model) } diff --git a/crates/settings_content/src/editor.rs b/crates/settings_content/src/editor.rs index 4d824e85e0e2ee020f48cdddb530bf494b2ce800..b37192882694f999a5e7f3180e5a7899a8732393 100644 --- a/crates/settings_content/src/editor.rs +++ b/crates/settings_content/src/editor.rs @@ -226,6 +226,14 @@ pub struct EditorSettingsContent { /// /// Default: split pub diff_view_style: Option, + + /// The minimum width (in em-widths) at which the split diff view is used. + /// When the editor is narrower than this, the diff view automatically + /// switches to unified mode and switches back when the editor is wide + /// enough. Set to 0 to disable automatic switching. + /// + /// Default: 100 + pub minimum_split_diff_width: Option, } #[derive( diff --git a/crates/settings_content/src/language.rs b/crates/settings_content/src/language.rs index 30a1e7a3179988071784e94a8a9b8b60b13df468..4578d2eb589313e57688d7c604beb4eced83de29 100644 --- a/crates/settings_content/src/language.rs +++ b/crates/settings_content/src/language.rs @@ -85,7 +85,6 @@ pub enum EditPredictionProvider { Codestral, Ollama, OpenAiCompatibleApi, - Sweep, Mercury, Experimental(&'static str), } @@ -106,7 +105,6 @@ impl<'de> Deserialize<'de> for EditPredictionProvider { Codestral, Ollama, OpenAiCompatibleApi, - Sweep, Mercury, Experimental(String), } @@ -118,7 +116,6 @@ impl<'de> Deserialize<'de> for EditPredictionProvider { Content::Codestral => EditPredictionProvider::Codestral, Content::Ollama => EditPredictionProvider::Ollama, Content::OpenAiCompatibleApi => EditPredictionProvider::OpenAiCompatibleApi, - Content::Sweep => EditPredictionProvider::Sweep, Content::Mercury => EditPredictionProvider::Mercury, Content::Experimental(name) if name == EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME => @@ -144,7 +141,6 @@ impl EditPredictionProvider { | EditPredictionProvider::Codestral | EditPredictionProvider::Ollama | EditPredictionProvider::OpenAiCompatibleApi - | EditPredictionProvider::Sweep | EditPredictionProvider::Mercury | EditPredictionProvider::Experimental(_) => false, } @@ -155,7 +151,6 @@ impl EditPredictionProvider { EditPredictionProvider::Zed => Some("Zed AI"), EditPredictionProvider::Copilot => Some("GitHub Copilot"), EditPredictionProvider::Codestral => Some("Codestral"), - EditPredictionProvider::Sweep => Some("Sweep"), EditPredictionProvider::Mercury => Some("Mercury"), EditPredictionProvider::Experimental(_) | EditPredictionProvider::None => None, EditPredictionProvider::Ollama => Some("Ollama"), @@ -181,8 +176,6 @@ pub struct EditPredictionSettingsContent { pub copilot: Option, /// Settings specific to Codestral. pub codestral: Option, - /// Settings specific to Sweep. - pub sweep: Option, /// Settings specific to Ollama. pub ollama: Option, /// Settings specific to using custom OpenAI-compatible servers for edit prediction. @@ -209,8 +202,7 @@ pub struct CustomEditPredictionProviderSettingsContent { /// /// Default: "" pub model: Option, - /// Maximum tokens to generate for FIM models. - /// This setting does not apply to sweep models. + /// Maximum tokens to generate. /// /// Default: 256 pub max_output_tokens: Option, @@ -283,18 +275,6 @@ pub struct CodestralSettingsContent { pub api_url: Option, } -#[with_fallible_options] -#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq)] -pub struct SweepSettingsContent { - /// When enabled, Sweep will not store edit prediction inputs or outputs. - /// When disabled, Sweep may collect data including buffer contents, - /// diagnostics, file paths, repository names, and generated predictions - /// to improve the service. - /// - /// Default: false - pub privacy_mode: Option, -} - /// Ollama model name for edit predictions. #[with_fallible_options] #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, Eq)] @@ -327,7 +307,6 @@ pub struct OllamaEditPredictionSettingsContent { /// Default: none pub model: Option, /// Maximum tokens to generate for FIM models. - /// This setting does not apply to sweep models. /// /// Default: 256 pub max_output_tokens: Option, diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs index 19ffca06e131c177656e229d2101eb259256f318..861b6fee454edc4d18b8248b42315287a33c572c 100644 --- a/crates/settings_content/src/settings_content.rs +++ b/crates/settings_content/src/settings_content.rs @@ -9,6 +9,7 @@ mod project; mod serde_helper; mod terminal; mod theme; +mod title_bar; mod workspace; pub use agent::*; @@ -26,6 +27,7 @@ pub use serde_helper::{ use settings_json::parse_json_with_comments; pub use terminal::*; pub use theme::*; +pub use title_bar::*; pub use workspace::*; use collections::{HashMap, IndexMap}; @@ -202,6 +204,13 @@ pub struct SettingsContent { /// Settings related to Vim mode in Zed. pub vim: Option, + + /// Number of lines to search for modelines at the beginning and end of files. + /// Modelines contain editor directives (e.g., vim/emacs settings) that configure + /// the editor behavior for specific files. + /// + /// Default: 5 + pub modeline_lines: Option, } impl SettingsContent { @@ -316,54 +325,10 @@ impl strum::VariantNames for BaseKeymapContent { ]; } -#[with_fallible_options] -#[derive(Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema, MergeFrom, Debug)] -pub struct TitleBarSettingsContent { - /// Whether to show the branch icon beside branch switcher in the title bar. - /// - /// Default: false - pub show_branch_icon: Option, - /// Whether to show onboarding banners in the title bar. - /// - /// Default: true - pub show_onboarding_banner: Option, - /// Whether to show user avatar in the title bar. - /// - /// Default: true - pub show_user_picture: Option, - /// Whether to show the branch name button in the titlebar. - /// - /// Default: true - pub show_branch_name: Option, - /// Whether to show the project host and name in the titlebar. - /// - /// Default: true - pub show_project_items: Option, - /// Whether to show the sign in button in the title bar. - /// - /// 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 - pub show_menus: Option, -} - /// Configuration of audio in Zed. #[with_fallible_options] #[derive(Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema, MergeFrom, Debug)] pub struct AudioSettingsContent { - /// Opt into the new audio system. - /// - /// You need to rejoin a call for this setting to apply - #[serde(rename = "experimental.rodio_audio")] - pub rodio_audio: Option, // default is false - /// Requires 'rodio_audio: true' - /// /// Automatically increase or decrease you microphone's volume. This affects how /// loud you sound to others. /// @@ -373,35 +338,11 @@ pub struct AudioSettingsContent { /// compared to other speakers. #[serde(rename = "experimental.auto_microphone_volume")] pub auto_microphone_volume: Option, - /// Requires 'rodio_audio: true' - /// - /// Automatically increate or decrease the volume of other call members. - /// This only affects how things sound for you. - #[serde(rename = "experimental.auto_speaker_volume")] - pub auto_speaker_volume: Option, - /// Requires 'rodio_audio: true' - /// /// Remove background noises. Works great for typing, cars, dogs, AC. Does /// not work well on music. - #[serde(rename = "experimental.denoise")] - pub denoise: Option, - /// Requires 'rodio_audio: true' - /// - /// Use audio parameters compatible with the previous versions of - /// experimental audio and non-experimental audio. When this is false you - /// will sound strange to anyone not on the latest experimental audio. In - /// the future we will migrate by setting this to false - /// - /// You need to rejoin a call for this setting to apply - #[serde(rename = "experimental.legacy_audio_compatible")] - pub legacy_audio_compatible: Option, - /// Requires 'rodio_audio: true' - /// /// Select specific output audio device. #[serde(rename = "experimental.output_audio_device")] pub output_audio_device: Option, - /// Requires 'rodio_audio: true' - /// /// Select specific input audio device. #[serde(rename = "experimental.input_audio_device")] pub input_audio_device: Option, @@ -1192,15 +1133,15 @@ pub struct WhichKeySettingsContent { pub delay_ms: Option, } +// An ExtendingVec in the settings can only accumulate new values. +// +// This is useful for things like private files where you only want +// to allow new values to be added. +// +// Consider using a HashMap instead of this type +// (like auto_install_extensions) so that user settings files can both add +// and remove values from the set. #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] -/// An ExtendingVec in the settings can only accumulate new values. -/// -/// This is useful for things like private files where you only want -/// to allow new values to be added. -/// -/// Consider using a HashMap instead of this type -/// (like auto_install_extensions) so that user settings files can both add -/// and remove values from the set. pub struct ExtendingVec(pub Vec); impl Into> for ExtendingVec { @@ -1220,10 +1161,10 @@ impl merge_from::MergeFrom for ExtendingVec { } } -/// A SaturatingBool in the settings can only ever be set to true, -/// later attempts to set it to false will be ignored. -/// -/// Used by `disable_ai`. +// A SaturatingBool in the settings can only ever be set to true, +// later attempts to set it to false will be ignored. +// +// Used by `disable_ai`. #[derive(Debug, Default, Copy, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct SaturatingBool(pub bool); diff --git a/crates/settings_content/src/terminal.rs b/crates/settings_content/src/terminal.rs index 83f3b32fdd14a6ee693f775b74022af4841af0a5..643dea18d106906d242ff21d0aadbc27492fd09b 100644 --- a/crates/settings_content/src/terminal.rs +++ b/crates/settings_content/src/terminal.rs @@ -129,6 +129,10 @@ pub struct TerminalSettingsContent { /// Default: true pub button: Option, pub dock: Option, + /// Whether the terminal panel should use flexible (proportional) sizing. + /// + /// Default: true + pub flexible: Option, /// Default width when the terminal is docked to the left or right. /// /// Default: 640 diff --git a/crates/settings_content/src/title_bar.rs b/crates/settings_content/src/title_bar.rs new file mode 100644 index 0000000000000000000000000000000000000000..af5e30f361c7603aba72de3b5734ae78ab366171 --- /dev/null +++ b/crates/settings_content/src/title_bar.rs @@ -0,0 +1,124 @@ +use gpui::WindowButtonLayout; +use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema}; +use serde::{Deserialize, Serialize}; +use settings_macros::{MergeFrom, with_fallible_options}; + +/// The layout of window control buttons as represented by user settings. +/// +/// Custom layout strings use the GNOME `button-layout` format (e.g. +/// `"close:minimize,maximize"`). +#[derive( + Clone, + PartialEq, + Debug, + Serialize, + Deserialize, + JsonSchema, + MergeFrom, + Default, + strum::EnumDiscriminants, +)] +#[strum_discriminants(derive(strum::VariantArray, strum::VariantNames, strum::FromRepr))] +#[schemars(schema_with = "window_button_layout_schema")] +#[serde(from = "String", into = "String")] +pub enum WindowButtonLayoutContent { + /// Follow the system/desktop configuration. + #[default] + PlatformDefault, + /// Use Zed's built-in standard layout, regardless of system config. + Standard, + /// A raw GNOME-style layout string. + Custom(String), +} + +impl WindowButtonLayoutContent { + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + pub fn into_layout(self) -> Option { + use util::ResultExt; + + match self { + Self::PlatformDefault => None, + Self::Standard => Some(WindowButtonLayout::linux_default()), + Self::Custom(layout) => WindowButtonLayout::parse(&layout).log_err(), + } + } + + #[cfg(not(any(target_os = "linux", target_os = "freebsd")))] + pub fn into_layout(self) -> Option { + None + } +} + +fn window_button_layout_schema(_: &mut SchemaGenerator) -> Schema { + json_schema!({ + "anyOf": [ + { "enum": ["platform_default", "standard"] }, + { "type": "string" } + ] + }) +} + +impl From for String { + fn from(value: WindowButtonLayoutContent) -> Self { + match value { + WindowButtonLayoutContent::PlatformDefault => "platform_default".to_string(), + WindowButtonLayoutContent::Standard => "standard".to_string(), + WindowButtonLayoutContent::Custom(s) => s, + } + } +} + +impl From for WindowButtonLayoutContent { + fn from(layout_string: String) -> Self { + match layout_string.as_str() { + "platform_default" => Self::PlatformDefault, + "standard" => Self::Standard, + _ => Self::Custom(layout_string), + } + } +} + +#[with_fallible_options] +#[derive(Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema, MergeFrom, Debug)] +pub struct TitleBarSettingsContent { + /// Whether to show the branch icon beside branch switcher in the title bar. + /// + /// Default: false + pub show_branch_icon: Option, + /// Whether to show onboarding banners in the title bar. + /// + /// Default: true + pub show_onboarding_banner: Option, + /// Whether to show user avatar in the title bar. + /// + /// Default: true + pub show_user_picture: Option, + /// Whether to show the branch name button in the titlebar. + /// + /// Default: true + pub show_branch_name: Option, + /// Whether to show the project host and name in the titlebar. + /// + /// Default: true + pub show_project_items: Option, + /// Whether to show the sign in button in the title bar. + /// + /// 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 + pub show_menus: Option, + /// The layout of window control buttons in the title bar (Linux only). + /// + /// This can be set to "platform_default" to follow the system configuration, or + /// "standard" to use Zed's built-in layout. For custom layouts, use a + /// GNOME-style layout string like "close:minimize,maximize". + /// + /// Default: "platform_default" + pub button_layout: Option, +} diff --git a/crates/settings_content/src/workspace.rs b/crates/settings_content/src/workspace.rs index 92dc6679e60fc5d54b24afafa4daa00600c066f2..ef00a44790fd10b8c56278362a2f552a40f52cbb 100644 --- a/crates/settings_content/src/workspace.rs +++ b/crates/settings_content/src/workspace.rs @@ -434,6 +434,10 @@ pub struct StatusBarSettingsContent { /// Default: true #[serde(rename = "experimental.show")] pub show: Option, + /// Whether to show the name of the active file in the status bar. + /// + /// Default: false + pub show_active_file: Option, /// Whether to display the active language button in the status bar. /// /// Default: true @@ -741,8 +745,12 @@ pub struct ProjectPanelSettingsContent { pub sort_mode: Option, /// Whether to show error and warning count badges next to file names in the project panel. /// - /// Default: true + /// Default: false pub diagnostic_badges: Option, + /// Whether to show a git status indicator next to file names in the project panel. + /// + /// Default: false + pub git_status_indicator: Option, } #[derive( diff --git a/crates/settings_json/Cargo.toml b/crates/settings_json/Cargo.toml index 2ba9887ca016b645bafa2974bbd9029373348838..aeaf5ec3c16a9c0d0fc6e9be047fb33a4ab74373 100644 --- a/crates/settings_json/Cargo.toml +++ b/crates/settings_json/Cargo.toml @@ -27,9 +27,3 @@ serde_path_to_error.workspace = true [dev-dependencies] unindent.workspace = true pretty_assertions.workspace = true - -# Uncomment other workspace dependencies as needed -# assistant.workspace = true -# client.workspace = true -# project.workspace = true -# settings.workspace = true diff --git a/crates/settings_profile_selector/Cargo.toml b/crates/settings_profile_selector/Cargo.toml index 9fcce14b0434386068a9c94f47c9ed675210abbb..2e4608672847b608e2f6b0c48c5122bf76f3b5e7 100644 --- a/crates/settings_profile_selector/Cargo.toml +++ b/crates/settings_profile_selector/Cargo.toml @@ -29,4 +29,5 @@ project = { workspace = true, features = ["test-support"] } serde_json.workspace = true settings = { workspace = true, features = ["test-support"] } theme = { workspace = true, features = ["test-support"] } +theme_settings.workspace = true workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/settings_profile_selector/src/settings_profile_selector.rs b/crates/settings_profile_selector/src/settings_profile_selector.rs index 7ca91e3767efb6b550af7887e70a0187fed6daad..a948b603e04c43a6740853b7c37aebb2ba8d7ee9 100644 --- a/crates/settings_profile_selector/src/settings_profile_selector.rs +++ b/crates/settings_profile_selector/src/settings_profile_selector.rs @@ -286,7 +286,7 @@ mod tests { use project::{FakeFs, Project}; use serde_json::json; use settings::Settings; - use theme::{self, ThemeSettings}; + use theme_settings::ThemeSettings; use workspace::{self, AppState, MultiWorkspace}; use zed_actions::settings_profile_selector; @@ -299,7 +299,7 @@ mod tests { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); settings::init(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); super::init(cx); editor::init(cx); state diff --git a/crates/settings_ui/Cargo.toml b/crates/settings_ui/Cargo.toml index 7632c2857a41ba43fe7d2b2d517752f53b8f694d..9d79481596f4b4259760ff6c2f19f8f5cf709d1e 100644 --- a/crates/settings_ui/Cargo.toml +++ b/crates/settings_ui/Cargo.toml @@ -54,6 +54,7 @@ shell_command_parser.workspace = true strum.workspace = true telemetry.workspace = true theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true diff --git a/crates/settings_ui/src/components/input_field.rs b/crates/settings_ui/src/components/input_field.rs index e0acfe486d31db373a5de43aa64e1b6e28ce78cf..35e63078c154dd324c8dd622b8d98c2de36beb68 100644 --- a/crates/settings_ui/src/components/input_field.rs +++ b/crates/settings_ui/src/components/input_field.rs @@ -3,7 +3,7 @@ use std::rc::Rc; use editor::Editor; use gpui::{AnyElement, ElementId, Focusable, TextStyleRefinement}; use settings::Settings as _; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{Tooltip, prelude::*, rems}; #[derive(IntoElement)] diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index f5398b60fe528153c3a6d146fcf1eb9b105f713f..e4eac81b067b2ead2e89153c9a444b4ebd016f64 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -411,9 +411,9 @@ fn appearance_page() -> SettingsPage { settings::ThemeSelection::Static(_) => return, settings::ThemeSelection::Dynamic { mode, light, dark } => { match mode { - theme::ThemeAppearanceMode::Light => light.clone(), - theme::ThemeAppearanceMode::Dark => dark.clone(), - theme::ThemeAppearanceMode::System => dark.clone(), // no cx, can't determine correct choice + theme_settings::ThemeAppearanceMode::Light => light.clone(), + theme_settings::ThemeAppearanceMode::Dark => dark.clone(), + theme_settings::ThemeAppearanceMode::System => dark.clone(), // no cx, can't determine correct choice } }, }; @@ -581,9 +581,9 @@ fn appearance_page() -> SettingsPage { settings::IconThemeSelection::Static(_) => return, settings::IconThemeSelection::Dynamic { mode, light, dark } => { match mode { - theme::ThemeAppearanceMode::Light => light.clone(), - theme::ThemeAppearanceMode::Dark => dark.clone(), - theme::ThemeAppearanceMode::System => dark.clone(), // no cx, can't determine correct choice + theme_settings::ThemeAppearanceMode::Light => light.clone(), + theme_settings::ThemeAppearanceMode::Dark => dark.clone(), + theme_settings::ThemeAppearanceMode::System => dark.clone(), // no cx, can't determine correct choice } }, }; @@ -802,7 +802,8 @@ fn appearance_page() -> SettingsPage { } settings::BufferLineHeightDiscriminants::Custom => { let custom_value = - theme::BufferLineHeight::from(*settings_value).value(); + theme_settings::BufferLineHeight::from(*settings_value) + .value(); settings::BufferLineHeight::Custom(custom_value) } }; @@ -1294,17 +1295,13 @@ fn keymap_page() -> SettingsPage { fn modal_editing_section() -> [SettingsPageItem; 3] { [ SettingsPageItem::SectionHeader("Modal Editing"), - // todo(settings_ui): Vim/Helix Mode should be apart of one type because it's undefined - // behavior to have them both enabled at the same time SettingsPageItem::SettingItem(SettingItem { title: "Vim Mode", description: "Enable Vim mode and key bindings.", field: Box::new(SettingField { json_path: Some("vim_mode"), pick: |settings_content| settings_content.vim_mode.as_ref(), - write: |settings_content, value| { - settings_content.vim_mode = value; - }, + write: write_vim_mode, }), metadata: None, files: USER, @@ -1315,9 +1312,7 @@ fn keymap_page() -> SettingsPage { field: Box::new(SettingField { json_path: Some("helix_mode"), pick: |settings_content| settings_content.helix_mode.as_ref(), - write: |settings_content, value| { - settings_content.helix_mode = value; - }, + write: write_helix_mode, }), metadata: None, files: USER, @@ -1479,7 +1474,7 @@ fn editor_page() -> SettingsPage { ] } - fn multibuffer_section() -> [SettingsPageItem; 6] { + fn multibuffer_section() -> [SettingsPageItem; 7] { [ SettingsPageItem::SectionHeader("Multibuffer"), SettingsPageItem::SettingItem(SettingItem { @@ -1559,6 +1554,21 @@ fn editor_page() -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Minimum Split Diff Width", + description: "The minimum width (in columns) at which the split diff view is used. When the editor is narrower, the diff view automatically switches to unified mode. Set to 0 to disable.", + field: Box::new(SettingField { + json_path: Some("minimum_split_diff_width"), + pick: |settings_content| { + settings_content.editor.minimum_split_diff_width.as_ref() + }, + write: |settings_content, value| { + settings_content.editor.minimum_split_diff_width = value; + }, + }), + metadata: None, + files: USER, + }), ] } @@ -3333,7 +3343,7 @@ fn search_and_files_page() -> SettingsPage { } fn window_and_layout_page() -> SettingsPage { - fn status_bar_section() -> [SettingsPageItem; 9] { + fn status_bar_section() -> [SettingsPageItem; 10] { [ SettingsPageItem::SectionHeader("Status Bar"), SettingsPageItem::SettingItem(SettingItem { @@ -3478,10 +3488,32 @@ fn window_and_layout_page() -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Active File Name", + description: "Show the name of the active file in the status bar.", + field: Box::new(SettingField { + json_path: Some("status_bar.show_active_file"), + pick: |settings_content| { + settings_content + .status_bar + .as_ref()? + .show_active_file + .as_ref() + }, + write: |settings_content, value| { + settings_content + .status_bar + .get_or_insert_default() + .show_active_file = value; + }, + }), + metadata: None, + files: USER, + }), ] } - fn title_bar_section() -> [SettingsPageItem; 9] { + fn title_bar_section() -> [SettingsPageItem; 10] { [ SettingsPageItem::SectionHeader("Title Bar"), SettingsPageItem::SettingItem(SettingItem { @@ -3648,6 +3680,122 @@ fn window_and_layout_page() -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::DynamicItem(DynamicItem { + discriminant: SettingItem { + files: USER, + title: "Button Layout", + description: + "(Linux only) choose how window control buttons are laid out in the titlebar.", + field: Box::new(SettingField { + json_path: Some("title_bar.button_layout$"), + pick: |settings_content| { + Some( + &dynamic_variants::()[settings_content + .title_bar + .as_ref()? + .button_layout + .as_ref()? + .discriminant() + as usize], + ) + }, + write: |settings_content, value| { + let Some(value) = value else { + settings_content + .title_bar + .get_or_insert_default() + .button_layout = None; + return; + }; + + let current_custom_layout = settings_content + .title_bar + .as_ref() + .and_then(|title_bar| title_bar.button_layout.as_ref()) + .and_then(|button_layout| match button_layout { + settings::WindowButtonLayoutContent::Custom(layout) => { + Some(layout.clone()) + } + _ => None, + }); + + let button_layout = match value { + settings::WindowButtonLayoutContentDiscriminants::PlatformDefault => { + settings::WindowButtonLayoutContent::PlatformDefault + } + settings::WindowButtonLayoutContentDiscriminants::Standard => { + settings::WindowButtonLayoutContent::Standard + } + settings::WindowButtonLayoutContentDiscriminants::Custom => { + settings::WindowButtonLayoutContent::Custom( + current_custom_layout.unwrap_or_else(|| { + "close:minimize,maximize".to_string() + }), + ) + } + }; + + settings_content + .title_bar + .get_or_insert_default() + .button_layout = Some(button_layout); + }, + }), + metadata: None, + }, + pick_discriminant: |settings_content| { + Some( + settings_content + .title_bar + .as_ref()? + .button_layout + .as_ref()? + .discriminant() as usize, + ) + }, + fields: dynamic_variants::() + .into_iter() + .map(|variant| match variant { + settings::WindowButtonLayoutContentDiscriminants::PlatformDefault => { + vec![] + } + settings::WindowButtonLayoutContentDiscriminants::Standard => vec![], + settings::WindowButtonLayoutContentDiscriminants::Custom => vec![ + SettingItem { + files: USER, + title: "Custom Button Layout", + description: + "GNOME-style layout string such as \"close:minimize,maximize\".", + field: Box::new(SettingField { + json_path: Some("title_bar.button_layout"), + pick: |settings_content| match settings_content + .title_bar + .as_ref()? + .button_layout + .as_ref()? + { + settings::WindowButtonLayoutContent::Custom(layout) => { + Some(layout) + } + _ => DEFAULT_EMPTY_STRING, + }, + write: |settings_content, value| { + settings_content + .title_bar + .get_or_insert_default() + .button_layout = value + .map(settings::WindowButtonLayoutContent::Custom); + }, + }), + metadata: Some(Box::new(SettingsFieldMetadata { + placeholder: Some("close:minimize,maximize"), + ..Default::default() + })), + }, + ], + }) + .collect(), + }), ] } @@ -4239,7 +4387,7 @@ fn window_and_layout_page() -> SettingsPage { } fn panels_page() -> SettingsPage { - fn project_panel_section() -> [SettingsPageItem; 23] { + fn project_panel_section() -> [SettingsPageItem; 24] { [ SettingsPageItem::SectionHeader("Project Panel"), SettingsPageItem::SettingItem(SettingItem { @@ -4587,6 +4735,28 @@ fn panels_page() -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Git Status Indicator", + description: "Show a git status indicator next to file names in the project panel.", + field: Box::new(SettingField { + json_path: Some("project_panel.git_status_indicator"), + pick: |settings_content| { + settings_content + .project_panel + .as_ref()? + .git_status_indicator + .as_ref() + }, + write: |settings_content, value| { + settings_content + .project_panel + .get_or_insert_default() + .git_status_indicator = value; + }, + }), + metadata: None, + files: USER, + }), SettingsPageItem::SettingItem(SettingItem { title: "Sticky Scroll", description: "Whether to stick parent directories at top of the project panel.", @@ -4661,7 +4831,7 @@ fn panels_page() -> SettingsPage { title: "Hide Root", description: "Whether to hide the root entry when only one folder is open in the window.", field: Box::new(SettingField { - json_path: Some("project_panel.drag_and_drop"), + json_path: Some("project_panel.hide_root"), pick: |settings_content| { settings_content.project_panel.as_ref()?.hide_root.as_ref() }, @@ -6922,101 +7092,8 @@ fn collaboration_page() -> SettingsPage { ] } - fn experimental_section() -> [SettingsPageItem; 9] { + fn audio_settings() -> [SettingsPageItem; 3] { [ - SettingsPageItem::SectionHeader("Experimental"), - SettingsPageItem::SettingItem(SettingItem { - title: "Rodio Audio", - description: "Opt into the new audio system.", - field: Box::new(SettingField { - json_path: Some("audio.experimental.rodio_audio"), - pick: |settings_content| settings_content.audio.as_ref()?.rodio_audio.as_ref(), - write: |settings_content, value| { - settings_content.audio.get_or_insert_default().rodio_audio = value; - }, - }), - metadata: None, - files: USER, - }), - SettingsPageItem::SettingItem(SettingItem { - title: "Auto Microphone Volume", - description: "Automatically adjust microphone volume (requires rodio audio).", - field: Box::new(SettingField { - json_path: Some("audio.experimental.auto_microphone_volume"), - pick: |settings_content| { - settings_content - .audio - .as_ref()? - .auto_microphone_volume - .as_ref() - }, - write: |settings_content, value| { - settings_content - .audio - .get_or_insert_default() - .auto_microphone_volume = value; - }, - }), - metadata: None, - files: USER, - }), - SettingsPageItem::SettingItem(SettingItem { - title: "Auto Speaker Volume", - description: "Automatically adjust volume of other call members (requires rodio audio).", - field: Box::new(SettingField { - json_path: Some("audio.experimental.auto_speaker_volume"), - pick: |settings_content| { - settings_content - .audio - .as_ref()? - .auto_speaker_volume - .as_ref() - }, - write: |settings_content, value| { - settings_content - .audio - .get_or_insert_default() - .auto_speaker_volume = value; - }, - }), - metadata: None, - files: USER, - }), - SettingsPageItem::SettingItem(SettingItem { - title: "Denoise", - description: "Remove background noises (requires rodio audio).", - field: Box::new(SettingField { - json_path: Some("audio.experimental.denoise"), - pick: |settings_content| settings_content.audio.as_ref()?.denoise.as_ref(), - write: |settings_content, value| { - settings_content.audio.get_or_insert_default().denoise = value; - }, - }), - metadata: None, - files: USER, - }), - SettingsPageItem::SettingItem(SettingItem { - title: "Legacy Audio Compatible", - description: "Use audio parameters compatible with previous versions (requires rodio audio).", - field: Box::new(SettingField { - json_path: Some("audio.experimental.legacy_audio_compatible"), - pick: |settings_content| { - settings_content - .audio - .as_ref()? - .legacy_audio_compatible - .as_ref() - }, - write: |settings_content, value| { - settings_content - .audio - .get_or_insert_default() - .legacy_audio_compatible = value; - }, - }), - metadata: None, - files: USER, - }), SettingsPageItem::ActionLink(ActionLink { title: "Test Audio".into(), description: Some("Test your microphone and speaker setup".into()), @@ -7077,7 +7154,7 @@ fn collaboration_page() -> SettingsPage { SettingsPage { title: "Collaboration", - items: concat_sections![calls_section(), experimental_section()], + items: concat_sections![calls_section(), audio_settings()], } } @@ -7261,6 +7338,28 @@ fn ai_page(cx: &App) -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Thinking Display", + description: "How thinking blocks should be displayed by default. 'Automatic' auto-expands with a height constraint during streaming. 'Always Expanded' shows full content. 'Always Collapsed' keeps them collapsed.", + field: Box::new(SettingField { + json_path: Some("agent.thinking_display"), + pick: |settings_content| { + settings_content + .agent + .as_ref()? + .thinking_display + .as_ref() + }, + write: |settings_content, value| { + settings_content + .agent + .get_or_insert_default() + .thinking_display = value; + }, + }), + metadata: None, + files: USER, + }), SettingsPageItem::SettingItem(SettingItem { title: "Cancel Generation On Terminal Stop", description: "Whether clicking the stop button on a running terminal tool should also cancel the agent's generation. Note that this only applies to the stop button, not to ctrl+c inside the terminal.", @@ -8526,7 +8625,7 @@ fn language_settings_data() -> Box<[SettingsPageItem]> { ] } - fn miscellaneous_section() -> [SettingsPageItem; 6] { + fn miscellaneous_section() -> [SettingsPageItem; 7] { [ SettingsPageItem::SectionHeader("Miscellaneous"), SettingsPageItem::SettingItem(SettingItem { @@ -8625,6 +8724,19 @@ fn language_settings_data() -> Box<[SettingsPageItem]> { metadata: None, files: USER | PROJECT, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Vim/Emacs Modeline Support", + description: "Number of lines to search for modelines (set to 0 to disable).", + field: Box::new(SettingField { + json_path: Some("modeline_lines"), + pick: |settings_content| settings_content.modeline_lines.as_ref(), + write: |settings_content, value| { + settings_content.modeline_lines = value; + }, + }), + metadata: None, + files: USER | PROJECT, + }), ] } @@ -9171,3 +9283,67 @@ where { <::Discriminant as strum::VariantArray>::VARIANTS } + +/// Updates the `vim_mode` setting, disabling `helix_mode` if present and +/// `vim_mode` is being enabled. +fn write_vim_mode(settings: &mut SettingsContent, value: Option) { + if value == Some(true) && settings.helix_mode == Some(true) { + settings.helix_mode = Some(false); + } + settings.vim_mode = value; +} + +/// Updates the `helix_mode` setting, disabling `vim_mode` if present and +/// `helix_mode` is being enabled. +fn write_helix_mode(settings: &mut SettingsContent, value: Option) { + if value == Some(true) && settings.vim_mode == Some(true) { + settings.vim_mode = Some(false); + } + settings.helix_mode = value; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_write_vim_helix_mode() { + // Enabling vim mode while `vim_mode` and `helix_mode` are not yet set + // should only update the `vim_mode` setting. + let mut settings = SettingsContent::default(); + write_vim_mode(&mut settings, Some(true)); + assert_eq!(settings.vim_mode, Some(true)); + assert_eq!(settings.helix_mode, None); + + // Enabling helix mode while `vim_mode` and `helix_mode` are not yet set + // should only update the `helix_mode` setting. + let mut settings = SettingsContent::default(); + write_helix_mode(&mut settings, Some(true)); + assert_eq!(settings.helix_mode, Some(true)); + assert_eq!(settings.vim_mode, None); + + // Disabling helix mode should only touch `helix_mode` setting when + // `vim_mode` is not set. + write_helix_mode(&mut settings, Some(false)); + assert_eq!(settings.helix_mode, Some(false)); + assert_eq!(settings.vim_mode, None); + + // Enabling vim mode should update `vim_mode` but leave `helix_mode` + // untouched. + write_vim_mode(&mut settings, Some(true)); + assert_eq!(settings.vim_mode, Some(true)); + assert_eq!(settings.helix_mode, Some(false)); + + // Enabling helix mode should update `helix_mode` and disable + // `vim_mode`. + write_helix_mode(&mut settings, Some(true)); + assert_eq!(settings.helix_mode, Some(true)); + assert_eq!(settings.vim_mode, Some(false)); + + // Enabling vim mode should update `vim_mode` and disable + // `helix_mode`. + write_vim_mode(&mut settings, Some(true)); + assert_eq!(settings.vim_mode, Some(true)); + assert_eq!(settings.helix_mode, Some(false)); + } +} diff --git a/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs b/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs index 736a8e83e34339b3aab18d865938a49f31ba7783..193be67aad4760763637f116fad23066438b5b61 100644 --- a/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs +++ b/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs @@ -3,7 +3,6 @@ use edit_prediction::{ ApiKeyState, mercury::{MERCURY_CREDENTIALS_URL, mercury_api_token}, open_ai_compatible::{open_ai_compatible_api_token, open_ai_compatible_api_url}, - sweep_ai::{SWEEP_CREDENTIALS_URL, sweep_api_token}, }; use edit_prediction_ui::{get_available_providers, set_completion_provider}; use gpui::{Entity, ScrollHandle, prelude::*}; @@ -45,30 +44,6 @@ pub(crate) fn render_edit_prediction_setup_page( ) .into_any_element(), ), - Some( - render_api_key_provider( - IconName::SweepAi, - "Sweep", - ApiKeyDocs::Link { - dashboard_url: "https://app.sweep.dev/".into(), - }, - sweep_api_token(cx), - |_cx| SWEEP_CREDENTIALS_URL, - Some( - settings_window - .render_sub_page_items_section( - sweep_settings().iter().enumerate(), - true, - window, - cx, - ) - .into_any_element(), - ), - window, - cx, - ) - .into_any_element(), - ), Some( render_api_key_provider( IconName::AiMistral, @@ -345,39 +320,6 @@ fn render_api_key_provider( }) } -fn sweep_settings() -> Box<[SettingsPageItem]> { - Box::new([SettingsPageItem::SettingItem(SettingItem { - title: "Privacy Mode", - description: "When enabled, Sweep will not store edit prediction inputs or outputs. When disabled, Sweep may collect data including buffer contents, diagnostics, file paths, and generated predictions to improve the service.", - field: Box::new(SettingField { - pick: |settings| { - settings - .project - .all_languages - .edit_predictions - .as_ref()? - .sweep - .as_ref()? - .privacy_mode - .as_ref() - }, - write: |settings, value| { - settings - .project - .all_languages - .edit_predictions - .get_or_insert_default() - .sweep - .get_or_insert_default() - .privacy_mode = value; - }, - json_path: Some("edit_predictions.sweep.privacy_mode"), - }), - metadata: None, - files: USER, - })]) -} - fn render_ollama_provider( settings_window: &SettingsWindow, window: &mut Window, @@ -768,12 +710,9 @@ fn render_github_copilot_provider(window: &mut Window, cx: &mut App) -> Option(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) + .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) @@ -545,6 +546,7 @@ fn init_renderers(cx: &mut App) { .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) + .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_editable_number_field) .add_basic_renderer::(render_ollama_model_picker) .add_basic_renderer::(render_dropdown) @@ -638,7 +640,9 @@ pub fn open_settings_editor( // We have to defer this to get the workspace off the stack. let path = path.map(ToOwned::to_owned); cx.defer(move |cx| { - let current_rem_size: f32 = theme::ThemeSettings::get_global(cx).ui_font_size(cx).into(); + let current_rem_size: f32 = theme_settings::ThemeSettings::get_global(cx) + .ui_font_size(cx) + .into(); let default_bounds = DEFAULT_ADDITIONAL_WINDOW_SIZE; let default_rem_size = 16.0; @@ -1391,17 +1395,14 @@ impl PartialEq for ActionLink { } fn all_language_names(cx: &App) -> Vec { - workspace::AppState::global(cx) - .upgrade() - .map_or(vec![], |state| { - state - .languages - .language_names() - .into_iter() - .filter(|name| name.as_ref() != "Zed Keybind Context") - .map(Into::into) - .collect() - }) + let state = workspace::AppState::global(cx); + state + .languages + .language_names() + .into_iter() + .filter(|name| name.as_ref() != "Zed Keybind Context") + .map(Into::into) + .collect() } #[allow(unused)] @@ -1515,7 +1516,7 @@ impl SettingsWindow { }) .detach(); - cx.on_window_closed(|cx| { + cx.on_window_closed(|cx, _window_id| { if let Some(existing_window) = cx .windows() .into_iter() @@ -1532,29 +1533,26 @@ impl SettingsWindow { }) .detach(); - if let Some(app_state) = AppState::global(cx).upgrade() { - let workspaces: Vec> = app_state - .workspace_store - .read(cx) - .workspaces() - .filter_map(|weak| weak.upgrade()) - .collect(); + let app_state = AppState::global(cx); + let workspaces: Vec> = app_state + .workspace_store + .read(cx) + .workspaces() + .filter_map(|weak| weak.upgrade()) + .collect(); - for workspace in workspaces { - let project = workspace.read(cx).project().clone(); - cx.observe_release_in(&project, window, |this, _, window, cx| { - this.fetch_files(window, cx) - }) - .detach(); - cx.subscribe_in(&project, window, Self::handle_project_event) - .detach(); - cx.observe_release_in(&workspace, window, |this, _, window, cx| { - this.fetch_files(window, cx) - }) + for workspace in workspaces { + let project = workspace.read(cx).project().clone(); + cx.observe_release_in(&project, window, |this, _, window, cx| { + this.fetch_files(window, cx) + }) + .detach(); + cx.subscribe_in(&project, window, Self::handle_project_event) .detach(); - } - } else { - log::error!("App state doesn't exist when creating a new settings window"); + cx.observe_release_in(&workspace, window, |this, _, window, cx| { + this.fetch_files(window, cx) + }) + .detach(); } let this_weak = cx.weak_entity(); @@ -3359,9 +3357,7 @@ impl SettingsWindow { } SettingsUiFile::Project((worktree_id, path)) => { let settings_path = path.join(paths::local_settings_file_relative_path()); - let Some(app_state) = workspace::AppState::global(cx).upgrade() else { - return; - }; + let app_state = workspace::AppState::global(cx); let Some((workspace_window, worktree, corresponding_workspace)) = app_state .workspace_store @@ -3649,7 +3645,7 @@ impl SettingsWindow { impl Render for SettingsWindow { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let ui_font = theme::setup_ui_font(window, cx); + let ui_font = theme_settings::setup_ui_font(window, cx); client_side_decorations( v_flex() @@ -3742,31 +3738,26 @@ fn all_projects( cx: &App, ) -> impl Iterator> { let mut seen_project_ids = std::collections::HashSet::new(); - workspace::AppState::global(cx) - .upgrade() - .map(|app_state| { - app_state - .workspace_store - .read(cx) - .workspaces() - .filter_map(|weak| weak.upgrade()) - .map(|workspace: Entity| workspace.read(cx).project().clone()) - .chain( - window - .and_then(|handle| handle.read(cx).ok()) - .into_iter() - .flat_map(|multi_workspace| { - multi_workspace - .workspaces() - .iter() - .map(|workspace| workspace.read(cx).project().clone()) - .collect::>() - }), - ) - .filter(move |project| seen_project_ids.insert(project.entity_id())) - }) - .into_iter() - .flatten() + let app_state = workspace::AppState::global(cx); + app_state + .workspace_store + .read(cx) + .workspaces() + .filter_map(|weak| weak.upgrade()) + .map(|workspace: Entity| workspace.read(cx).project().clone()) + .chain( + window + .and_then(|handle| handle.read(cx).ok()) + .into_iter() + .flat_map(|multi_workspace| { + multi_workspace + .workspaces() + .iter() + .map(|workspace| workspace.read(cx).project().clone()) + .collect::>() + }), + ) + .filter(move |project| seen_project_ids.insert(project.entity_id())) } fn open_user_settings_in_workspace( @@ -3941,10 +3932,13 @@ impl ProjectSettingsUpdateQueue { buffer.update(cx, |buffer, cx| { let current_text = buffer.text(); - let new_text = cx + if let Some(new_text) = cx .global::() - .new_text_for_update(current_text, |settings| update(settings, cx)); - buffer.edit([(0..buffer.len(), new_text)], None, cx); + .new_text_for_update(current_text, |settings| update(settings, cx)) + .log_err() + { + buffer.edit([(0..buffer.len(), new_text)], None, cx); + } }); buffer_store @@ -4409,7 +4403,7 @@ pub mod test { pub fn register_settings(cx: &mut App) { settings::init(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); menu::init(); } @@ -4720,7 +4714,7 @@ pub mod test { let app_state = cx.update(|cx| { let app_state = AppState::test(cx); - AppState::set_global(Arc::downgrade(&app_state), cx); + AppState::set_global(app_state.clone(), cx); app_state }); @@ -4894,7 +4888,7 @@ pub mod test { let app_state = cx.update(|cx| { let app_state = AppState::test(cx); - AppState::set_global(Arc::downgrade(&app_state), cx); + AppState::set_global(app_state.clone(), cx); app_state }); @@ -5074,7 +5068,7 @@ mod project_settings_update_tests { cx.update(|cx| { let store = settings::SettingsStore::test(cx); cx.set_global(store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); menu::init(); let queue = ProjectSettingsUpdateQueue::new(cx); diff --git a/crates/sidebar/Cargo.toml b/crates/sidebar/Cargo.toml index 6b4d93790236f32b0533374626e337f5c05ab75b..e367d7dc676994cb7facfa1d43ecae6349a2b88e 100644 --- a/crates/sidebar/Cargo.toml +++ b/crates/sidebar/Cargo.toml @@ -19,19 +19,24 @@ acp_thread.workspace = true action_log.workspace = true agent.workspace = true agent-client-protocol.workspace = true -agent_ui.workspace = true +agent_settings.workspace = true +agent_ui = { workspace = true, features = ["audio"] } anyhow.workspace = true chrono.workspace = true +collections.workspace = true editor.workspace = true feature_flags.workspace = true fs.workspace = true git.workspace = true gpui.workspace = true menu.workspace = true +platform_title_bar.workspace = true project.workspace = true recent_projects.workspace = true +remote.workspace = true settings.workspace = true theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true vim_mode_setting.workspace = true diff --git a/crates/sidebar/src/project_group_builder.rs b/crates/sidebar/src/project_group_builder.rs new file mode 100644 index 0000000000000000000000000000000000000000..318dfac0a839e28ceb27c6036b87e6a13d9bc992 --- /dev/null +++ b/crates/sidebar/src/project_group_builder.rs @@ -0,0 +1,328 @@ +//! The sidebar groups threads by a canonical path list. +//! +//! Threads have a path list associated with them, but this is the absolute path +//! of whatever worktrees they were associated with. In the sidebar, we want to +//! group all threads by their main worktree, and then we add a worktree chip to +//! the sidebar entry when that thread is in another worktree. +//! +//! This module is provides the functions and structures necessary to do this +//! lookup and mapping. + +use collections::{HashMap, HashSet, vecmap::VecMap}; +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; + +use gpui::{App, Entity}; +use ui::SharedString; +use workspace::{MultiWorkspace, PathList, Workspace}; + +/// Identifies a project group by a set of paths the workspaces in this group +/// have. +/// +/// Paths are mapped to their main worktree path first so we can group +/// workspaces by main repos. +#[derive(PartialEq, Eq, Hash, Clone)] +pub struct ProjectGroupName { + path_list: PathList, +} + +impl ProjectGroupName { + pub fn display_name(&self) -> SharedString { + let mut names = Vec::with_capacity(self.path_list.paths().len()); + for abs_path in self.path_list.paths() { + if let Some(name) = abs_path.file_name() { + names.push(name.to_string_lossy().to_string()); + } + } + if names.is_empty() { + // TODO: Can we do something better in this case? + "Empty Workspace".into() + } else { + names.join(", ").into() + } + } + + pub fn path_list(&self) -> &PathList { + &self.path_list + } +} + +#[derive(Default)] +pub struct ProjectGroup { + pub workspaces: Vec>, + /// Root paths of all open workspaces in this group. Used to skip + /// redundant thread-store queries for linked worktrees that already + /// have an open workspace. + covered_paths: HashSet>, +} + +impl ProjectGroup { + fn add_workspace(&mut self, workspace: &Entity, cx: &App) { + if !self.workspaces.contains(workspace) { + self.workspaces.push(workspace.clone()); + } + for path in workspace.read(cx).root_paths(cx) { + self.covered_paths.insert(path); + } + } + + pub fn first_workspace(&self) -> &Entity { + self.workspaces + .first() + .expect("groups always have at least one workspace") + } + + pub fn main_workspace(&self, cx: &App) -> &Entity { + self.workspaces + .iter() + .find(|ws| { + !crate::root_repository_snapshots(ws, cx) + .any(|snapshot| snapshot.is_linked_worktree()) + }) + .unwrap_or_else(|| self.first_workspace()) + } +} + +pub struct ProjectGroupBuilder { + /// Maps git repositories' work_directory_abs_path to their original_repo_abs_path + directory_mappings: HashMap, + project_groups: VecMap, +} + +impl ProjectGroupBuilder { + fn new() -> Self { + Self { + directory_mappings: HashMap::default(), + project_groups: VecMap::new(), + } + } + + pub fn from_multiworkspace(mw: &MultiWorkspace, cx: &App) -> Self { + let mut builder = Self::new(); + // First pass: collect all directory mappings from every workspace + // so we know how to canonicalize any path (including linked + // worktree paths discovered by the main repo's workspace). + for workspace in mw.workspaces() { + builder.add_workspace_mappings(workspace.read(cx), cx); + } + + // Second pass: group each workspace using canonical paths derived + // from the full set of mappings. + for workspace in mw.workspaces() { + let group_name = builder.canonical_workspace_paths(workspace, cx); + builder + .project_group_entry(&group_name) + .add_workspace(workspace, cx); + } + builder + } + + fn project_group_entry(&mut self, name: &ProjectGroupName) -> &mut ProjectGroup { + self.project_groups.entry_ref(name).or_insert_default() + } + + fn add_mapping(&mut self, work_directory: &Path, original_repo: &Path) { + let old = self + .directory_mappings + .insert(PathBuf::from(work_directory), PathBuf::from(original_repo)); + if let Some(old) = old { + debug_assert_eq!( + &old, original_repo, + "all worktrees should map to the same main worktree" + ); + } + } + + pub fn add_workspace_mappings(&mut self, workspace: &Workspace, cx: &App) { + for repo in workspace.project().read(cx).repositories(cx).values() { + let snapshot = repo.read(cx).snapshot(); + + self.add_mapping( + &snapshot.work_directory_abs_path, + &snapshot.original_repo_abs_path, + ); + + for worktree in snapshot.linked_worktrees.iter() { + self.add_mapping(&worktree.path, &snapshot.original_repo_abs_path); + } + } + } + + /// Derives the canonical group name for a workspace by canonicalizing + /// each of its root paths using the builder's directory mappings. + fn canonical_workspace_paths( + &self, + workspace: &Entity, + cx: &App, + ) -> ProjectGroupName { + let root_paths = workspace.read(cx).root_paths(cx); + let paths: Vec<_> = root_paths + .iter() + .map(|p| self.canonicalize_path(p).to_path_buf()) + .collect(); + ProjectGroupName { + path_list: PathList::new(&paths), + } + } + + pub fn canonicalize_path<'a>(&'a self, path: &'a Path) -> &'a Path { + self.directory_mappings + .get(path) + .map(AsRef::as_ref) + .unwrap_or(path) + } + + /// Whether the given group should load threads for a linked worktree + /// at `worktree_path`. Returns `false` if the worktree already has an + /// open workspace in the group (its threads are loaded via the + /// workspace loop) or if the worktree's canonical path list doesn't + /// match `group_path_list`. + pub fn group_owns_worktree( + &self, + group: &ProjectGroup, + group_path_list: &PathList, + worktree_path: &Path, + ) -> bool { + if group.covered_paths.contains(worktree_path) { + return false; + } + let canonical = self.canonicalize_path_list(&PathList::new(&[worktree_path])); + canonical == *group_path_list + } + + /// Canonicalizes every path in a [`PathList`] using the builder's + /// directory mappings. + fn canonicalize_path_list(&self, path_list: &PathList) -> PathList { + let paths: Vec<_> = path_list + .paths() + .iter() + .map(|p| self.canonicalize_path(p).to_path_buf()) + .collect(); + PathList::new(&paths) + } + + pub fn groups(&self) -> impl Iterator { + self.project_groups.iter() + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use fs::FakeFs; + use gpui::TestAppContext; + use settings::SettingsStore; + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + theme_settings::init(theme::LoadThemes::JustBase, cx); + }); + } + + async fn create_fs_with_main_and_worktree(cx: &mut TestAppContext) -> Arc { + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/wt/feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt/feature-a"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "abc".into(), + }); + }) + .expect("git state should be set"); + fs + } + + #[gpui::test] + async fn test_main_repo_maps_to_itself(cx: &mut TestAppContext) { + init_test(cx); + let fs = create_fs_with_main_and_worktree(cx).await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = cx.add_window_view(|window, cx| { + workspace::MultiWorkspace::test_new(project.clone(), window, cx) + }); + + multi_workspace.read_with(cx, |mw, cx| { + let mut canonicalizer = ProjectGroupBuilder::new(); + for workspace in mw.workspaces() { + canonicalizer.add_workspace_mappings(workspace.read(cx), cx); + } + + // The main repo path should canonicalize to itself. + assert_eq!( + canonicalizer.canonicalize_path(Path::new("/project")), + Path::new("/project"), + ); + + // An unknown path returns None. + assert_eq!( + canonicalizer.canonicalize_path(Path::new("/something/else")), + Path::new("/something/else"), + ); + }); + } + + #[gpui::test] + async fn test_worktree_checkout_canonicalizes_to_main_repo(cx: &mut TestAppContext) { + init_test(cx); + let fs = create_fs_with_main_and_worktree(cx).await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + // Open the worktree checkout as its own project. + let project = project::Project::test(fs.clone(), ["/wt/feature-a".as_ref()], cx).await; + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = cx.add_window_view(|window, cx| { + workspace::MultiWorkspace::test_new(project.clone(), window, cx) + }); + + multi_workspace.read_with(cx, |mw, cx| { + let mut canonicalizer = ProjectGroupBuilder::new(); + for workspace in mw.workspaces() { + canonicalizer.add_workspace_mappings(workspace.read(cx), cx); + } + + // The worktree checkout path should canonicalize to the main repo. + assert_eq!( + canonicalizer.canonicalize_path(Path::new("/wt/feature-a")), + Path::new("/project"), + ); + }); + } +} diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index e21ad1dbce74284cf1f76db7e1c8f413f6d63657..f65a05a1e5fb7abc967c7290f15fe90a6a8600d6 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -1,49 +1,62 @@ +mod thread_switcher; + use acp_thread::ThreadStatus; use action_log::DiffStats; use agent_client_protocol::{self as acp}; -use agent_ui::thread_metadata_store::{SidebarThreadMetadataStore, ThreadMetadata}; +use agent_settings::AgentSettings; +use agent_ui::thread_metadata_store::{ThreadMetadata, ThreadMetadataStore}; use agent_ui::threads_archive_view::{ ThreadsArchiveView, ThreadsArchiveViewEvent, format_history_entry_timestamp, }; use agent_ui::{ Agent, AgentPanel, AgentPanelEvent, DEFAULT_THREAD_TITLE, NewThread, RemoveSelectedThread, }; -use chrono::Utc; +use chrono::{DateTime, Utc}; use editor::Editor; use feature_flags::{AgentV2FeatureFlag, FeatureFlagViewExt as _}; use gpui::{ - Action as _, AnyElement, App, Context, Entity, FocusHandle, Focusable, ListState, Pixels, - Render, SharedString, WeakEntity, Window, WindowHandle, list, prelude::*, px, + Action as _, AnyElement, App, Context, Entity, FocusHandle, Focusable, KeyContext, ListState, + Pixels, Render, SharedString, WeakEntity, Window, WindowHandle, list, prelude::*, px, }; use menu::{ Cancel, Confirm, SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious, }; -use project::{AgentId, Event as ProjectEvent, linked_worktree_short_name}; +use project::{AgentId, AgentRegistryStore, Event as ProjectEvent, linked_worktree_short_name}; use recent_projects::sidebar_recent_projects::SidebarRecentProjects; +use remote::RemoteConnectionOptions; use ui::utils::platform_title_bar_height; use settings::Settings as _; use std::collections::{HashMap, HashSet}; use std::mem; -use std::path::Path; use std::rc::Rc; -use std::sync::Arc; use theme::ActiveTheme; use ui::{ AgentThreadStatus, CommonAnimationExt, ContextMenu, Divider, HighlightedLabel, KeyBinding, - PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, TintColor, Tooltip, WithScrollbar, prelude::*, + PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, ThreadItemWorktreeInfo, TintColor, Tooltip, + WithScrollbar, prelude::*, }; use util::ResultExt as _; use util::path_list::PathList; use workspace::{ - AddFolderToProject, FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent, Open, - Sidebar as WorkspaceSidebar, ToggleWorkspaceSidebar, Workspace, WorkspaceId, + AddFolderToProject, CloseWindow, FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent, + Open, Sidebar as WorkspaceSidebar, SidebarSide, ToggleWorkspaceSidebar, Workspace, WorkspaceId, + sidebar_side_context_menu, }; use zed_actions::OpenRecent; use zed_actions::editor::{MoveDown, MoveUp}; -use zed_actions::agents_sidebar::FocusSidebarFilter; +use zed_actions::agents_sidebar::{FocusSidebarFilter, ToggleThreadSwitcher}; + +use crate::thread_switcher::{ThreadSwitcher, ThreadSwitcherEntry, ThreadSwitcherEvent}; + +use crate::project_group_builder::ProjectGroupBuilder; + +mod project_group_builder; + +#[cfg(test)] +mod sidebar_tests; gpui::actions!( agents_sidebar, @@ -55,7 +68,15 @@ gpui::actions!( ] ); -const DEFAULT_WIDTH: Pixels = px(320.0); +gpui::actions!( + dev, + [ + /// Dumps multi-workspace state (projects, worktrees, active threads) into a new buffer. + DumpWorkspaceInfo, + ] +); + +const DEFAULT_WIDTH: Pixels = px(300.0); const MIN_WIDTH: Pixels = px(200.0); const MAX_WIDTH: Pixels = px(800.0); const DEFAULT_THREADS_SHOWN: usize = 5; @@ -79,29 +100,22 @@ struct ActiveThreadInfo { diff_stats: DiffStats, } -impl From<&ActiveThreadInfo> for acp_thread::AgentSessionInfo { - fn from(info: &ActiveThreadInfo) -> Self { - Self { - session_id: info.session_id.clone(), - work_dirs: None, - title: Some(info.title.clone()), - updated_at: Some(Utc::now()), - created_at: Some(Utc::now()), - meta: None, - } - } -} - #[derive(Clone)] enum ThreadEntryWorkspace { Open(Entity), Closed(PathList), } +#[derive(Clone)] +struct WorktreeInfo { + name: SharedString, + full_path: SharedString, + highlight_positions: Vec, +} + #[derive(Clone)] struct ThreadEntry { - agent: Agent, - session_info: acp_thread::AgentSessionInfo, + metadata: ThreadMetadata, icon: IconName, icon_from_external_svg: Option, status: AgentThreadStatus, @@ -110,12 +124,28 @@ struct ThreadEntry { is_background: bool, is_title_generating: bool, highlight_positions: Vec, - worktree_name: Option, - worktree_full_path: Option, - worktree_highlight_positions: Vec, + worktrees: Vec, diff_stats: DiffStats, } +impl ThreadEntry { + /// Updates this thread entry with active thread information. + /// + /// The existing [`ThreadEntry`] was likely deserialized from the database + /// but if we have a correspond thread already loaded we want to apply the + /// live information. + fn apply_active_info(&mut self, info: &ActiveThreadInfo) { + self.metadata.title = info.title.clone(); + self.status = info.status; + self.icon = info.icon; + self.icon_from_external_svg = info.icon_from_external_svg.clone(); + self.is_live = true; + self.is_background = info.is_background; + self.is_title_generating = info.is_title_generating; + self.diff_stats = info.diff_stats; + } +} + #[derive(Clone)] enum ListEntry { ProjectHeader { @@ -125,6 +155,7 @@ enum ListEntry { highlight_positions: Vec, has_running_threads: bool, waiting_thread_count: usize, + is_active: bool, }, Thread(ThreadEntry), ViewMore { @@ -135,9 +166,32 @@ enum ListEntry { path_list: PathList, workspace: Entity, is_active_draft: bool, + worktrees: Vec, }, } +#[cfg(test)] +impl ListEntry { + fn workspace(&self) -> Option> { + match self { + ListEntry::ProjectHeader { workspace, .. } => Some(workspace.clone()), + ListEntry::Thread(thread_entry) => match &thread_entry.workspace { + ThreadEntryWorkspace::Open(workspace) => Some(workspace.clone()), + ThreadEntryWorkspace::Closed(_) => None, + }, + ListEntry::ViewMore { .. } => None, + ListEntry::NewThread { workspace, .. } => Some(workspace.clone()), + } + } + + fn session_id(&self) -> Option<&acp::SessionId> { + match self { + ListEntry::Thread(thread_entry) => Some(&thread_entry.metadata.session_id), + _ => None, + } + } +} + impl From for ListEntry { fn from(thread: ThreadEntry) -> Self { ListEntry::Thread(thread) @@ -189,40 +243,48 @@ fn fuzzy_match_positions(query: &str, candidate: &str) -> Option> { fn root_repository_snapshots( workspace: &Entity, cx: &App, -) -> Vec { +) -> impl Iterator { let path_list = workspace_path_list(workspace, cx); let project = workspace.read(cx).project().read(cx); - project - .repositories(cx) - .values() - .filter_map(|repo| { - let snapshot = repo.read(cx).snapshot(); - let is_root = path_list - .paths() - .iter() - .any(|p| p.as_path() == snapshot.work_directory_abs_path.as_ref()); - is_root.then_some(snapshot) - }) - .collect() + project.repositories(cx).values().filter_map(move |repo| { + let snapshot = repo.read(cx).snapshot(); + let is_root = path_list + .paths() + .iter() + .any(|p| p.as_path() == snapshot.work_directory_abs_path.as_ref()); + is_root.then_some(snapshot) + }) } fn workspace_path_list(workspace: &Entity, cx: &App) -> PathList { PathList::new(&workspace.read(cx).root_paths(cx)) } -fn workspace_label_from_path_list(path_list: &PathList) -> SharedString { - let mut names = Vec::with_capacity(path_list.paths().len()); - for abs_path in path_list.paths() { - if let Some(name) = abs_path.file_name() { - names.push(name.to_string_lossy().to_string()); - } - } - if names.is_empty() { - // TODO: Can we do something better in this case? - "Empty Workspace".into() - } else { - names.join(", ").into() - } +/// Derives worktree display info from a thread's stored path list. +/// +/// For each path in the thread's `folder_paths` that canonicalizes to a +/// different path (i.e. it's a git worktree), produces a [`WorktreeInfo`] +/// with the short worktree name and full path. +fn worktree_info_from_thread_paths( + folder_paths: &PathList, + project_groups: &ProjectGroupBuilder, +) -> Vec { + folder_paths + .paths() + .iter() + .filter_map(|path| { + let canonical = project_groups.canonicalize_path(path); + if canonical != path.as_path() { + Some(WorktreeInfo { + name: linked_worktree_short_name(canonical, path).unwrap_or_default(), + full_path: SharedString::from(path.display().to_string()), + highlight_positions: Vec::new(), + }) + } else { + None + } + }) + .collect() } /// The sidebar re-derives its entire entry list from scratch on every @@ -245,11 +307,19 @@ pub struct Sidebar { /// derivation, since the panel may transiently return `None` while /// loading. User actions may write directly for immediate feedback. focused_thread: Option, - agent_panel_visible: bool, - active_thread_is_draft: bool, hovered_thread_index: Option, collapsed_groups: HashSet, expanded_groups: HashMap, + /// Updated only in response to explicit user actions (clicking a + /// thread, confirming in the thread switcher, etc.) — never from + /// background data changes. Used to sort the thread switcher popup. + thread_last_accessed: HashMap>, + /// Updated when the user presses a key to send or queue a message. + /// Used for sorting threads in the sidebar and as a secondary sort + /// key in the thread switcher. + thread_last_message_sent_or_queued: HashMap>, + thread_switcher: Option>, + _thread_switcher_subscriptions: Vec, view: SidebarView, recent_projects_popover_handle: PopoverMenuHandle, project_header_menu_ix: Option, @@ -308,12 +378,9 @@ impl Sidebar { }) .detach(); - cx.observe( - &SidebarThreadMetadataStore::global(cx), - |this, _store, cx| { - this.update_entries(cx); - }, - ) + cx.observe(&ThreadMetadataStore::global(cx), |this, _store, cx| { + this.update_entries(cx); + }) .detach(); cx.observe_flag::(window, |_is_enabled, this, _window, cx| { @@ -338,11 +405,13 @@ impl Sidebar { contents: SidebarContents::default(), selection: None, focused_thread: None, - agent_panel_visible: false, - active_thread_is_draft: false, hovered_thread_index: None, collapsed_groups: HashSet::new(), expanded_groups: HashMap::new(), + thread_last_accessed: HashMap::new(), + thread_last_message_sent_or_queued: HashMap::new(), + thread_switcher: None, + _thread_switcher_subscriptions: Vec::new(), view: SidebarView::default(), recent_projects_popover_handle: PopoverMenuHandle::default(), project_header_menu_ix: None, @@ -357,6 +426,23 @@ impl Sidebar { .map_or(false, |mw| mw.read(cx).workspace() == workspace) } + fn agent_panel_visible(&self, cx: &App) -> bool { + self.multi_workspace.upgrade().map_or(false, |mw| { + let workspace = mw.read(cx).workspace(); + AgentPanel::is_visible(&workspace, cx) + }) + } + + fn active_thread_is_draft(&self, cx: &App) -> bool { + self.multi_workspace + .upgrade() + .and_then(|mw| { + let workspace = mw.read(cx).workspace(); + workspace.read(cx).panel::(cx) + }) + .map_or(false, |panel| panel.read(cx).active_thread_is_draft(cx)) + } + fn subscribe_to_workspace( &mut self, workspace: &Entity, @@ -382,7 +468,7 @@ impl Sidebar { cx.subscribe_in( &git_store, window, - |this, _, event: &project::git_store::GitStoreEvent, window, cx| { + |this, _, event: &project::git_store::GitStoreEvent, _window, cx| { if matches!( event, project::git_store::GitStoreEvent::RepositoryUpdated( @@ -391,7 +477,6 @@ impl Sidebar { _, ) ) { - this.prune_stale_worktree_workspaces(window, cx); this.update_entries(cx); } }, @@ -415,9 +500,6 @@ impl Sidebar { if let Some(agent_panel) = workspace.read(cx).panel::(cx) { self.subscribe_to_agent_panel(&agent_panel, window, cx); - if self.is_active_workspace(workspace, cx) { - self.agent_panel_visible = AgentPanel::is_visible(workspace, cx); - } self.observe_draft_editor(cx); } } @@ -446,6 +528,10 @@ impl Sidebar { AgentPanelEvent::ThreadFocused | AgentPanelEvent::BackgroundThreadChanged => { this.update_entries(cx); } + AgentPanelEvent::MessageSentOrQueued { session_id } => { + this.record_thread_message_sent(session_id); + this.update_entries(cx); + } }, ) .detach(); @@ -469,12 +555,7 @@ impl Sidebar { return; } - let is_visible = AgentPanel::is_visible(&workspace, cx); - - if this.agent_panel_visible != is_visible { - this.agent_panel_visible = is_visible; - cx.notify(); - } + cx.notify(); }) .detach(); } @@ -544,61 +625,21 @@ impl Sidebar { result } - fn all_thread_infos_for_workspace( - workspace: &Entity, - cx: &App, - ) -> Vec { - let Some(agent_panel) = workspace.read(cx).panel::(cx) else { - return Vec::new(); - }; - let agent_panel_ref = agent_panel.read(cx); - - agent_panel_ref - .parent_threads(cx) - .into_iter() - .map(|thread_view| { - let thread_view_ref = thread_view.read(cx); - let thread = thread_view_ref.thread.read(cx); - - let icon = thread_view_ref.agent_icon; - let icon_from_external_svg = thread_view_ref.agent_icon_from_external_svg.clone(); - let title = thread - .title() - .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into()); - let is_native = thread_view_ref.as_native_thread(cx).is_some(); - let is_title_generating = is_native && thread.has_provisional_title(); - let session_id = thread.session_id().clone(); - let is_background = agent_panel_ref.is_background_thread(&session_id); - - let status = if thread.is_waiting_for_confirmation() { - AgentThreadStatus::WaitingForConfirmation - } else if thread.had_error() { - AgentThreadStatus::Error - } else { - match thread.status() { - ThreadStatus::Generating => AgentThreadStatus::Running, - ThreadStatus::Idle => AgentThreadStatus::Completed, - } - }; - - let diff_stats = thread.action_log().read(cx).diff_stats(cx); - - ActiveThreadInfo { - session_id, - title, - status, - icon, - icon_from_external_svg, - is_background, - is_title_generating, - diff_stats, - } - }) - .collect() - } - - /// When modifying this thread, aim for a single forward pass over workspaces - /// and threads plus an O(T log T) sort. Avoid adding extra scans over the data. + /// Rebuilds the sidebar contents from current workspace and thread state. + /// + /// Uses [`ProjectGroupBuilder`] to group workspaces by their main git + /// repository, then populates thread entries from the metadata store and + /// merges live thread info from active agent panels. + /// + /// Aim for a single forward pass over workspaces and threads plus an + /// O(T log T) sort. Avoid adding extra scans over the data. + /// + /// Properties: + /// + /// - Should always show every workspace in the multiworkspace + /// - If you have no threads, and two workspaces for the worktree and the main workspace, make sure at least one is shown + /// - Should always show every thread, associated with each workspace in the multiworkspace + /// - After every build_contents, our "active" state should exactly match the current workspace's, current agent panel's current thread. fn rebuild_contents(&mut self, cx: &App) { let Some(multi_workspace) = self.multi_workspace.upgrade() else { return; @@ -607,25 +648,14 @@ impl Sidebar { let workspaces = mw.workspaces().to_vec(); let active_workspace = mw.workspaces().get(mw.active_workspace_index()).cloned(); - // Build a lookup for agent icons from the first workspace's AgentServerStore. let agent_server_store = workspaces .first() .map(|ws| ws.read(cx).project().read(cx).agent_server_store().clone()); let query = self.filter_editor.read(cx).text(cx); - // Re-derive agent_panel_visible from the active workspace so it stays - // correct after workspace switches. - self.agent_panel_visible = active_workspace - .as_ref() - .map_or(false, |ws| AgentPanel::is_visible(ws, cx)); - - // Derive active_thread_is_draft BEFORE focused_thread so we can - // use it as a guard below. - self.active_thread_is_draft = active_workspace - .as_ref() - .and_then(|ws| ws.read(cx).panel::(cx)) - .map_or(false, |panel| panel.read(cx).active_thread_is_draft(cx)); + let agent_panel_visible = self.agent_panel_visible(cx); + let active_thread_is_draft = self.active_thread_is_draft(cx); // Derive focused_thread from the active workspace's agent panel. // Only update when the panel gives us a positive signal — if the @@ -640,7 +670,7 @@ impl Sidebar { .active_conversation_view() .and_then(|cv| cv.read(cx).parent_id(cx)) }); - if panel_focused.is_some() && !self.active_thread_is_draft { + if panel_focused.is_some() && !active_thread_is_draft { self.focused_thread = panel_focused; } @@ -651,7 +681,7 @@ impl Sidebar { .iter() .filter_map(|entry| match entry { ListEntry::Thread(thread) if thread.is_live => { - Some((thread.session_info.session_id.clone(), thread.status)) + Some((thread.metadata.session_id.clone(), thread.status)) } _ => None, }) @@ -662,215 +692,140 @@ impl Sidebar { let mut current_session_ids: HashSet = HashSet::new(); let mut project_header_indices: Vec = Vec::new(); - // Identify absorbed workspaces in a single pass. A workspace is - // "absorbed" when it points at a git worktree checkout whose main - // repo is open as another workspace — its threads appear under the - // main repo's header instead of getting their own. - let mut main_repo_workspace: HashMap, usize> = HashMap::new(); - let mut absorbed: HashMap = HashMap::new(); - let mut pending: HashMap, Vec<(usize, SharedString, Arc)>> = HashMap::new(); - let mut absorbed_workspace_by_path: HashMap, usize> = HashMap::new(); - - for (i, workspace) in workspaces.iter().enumerate() { - for snapshot in root_repository_snapshots(workspace, cx) { - if snapshot.work_directory_abs_path == snapshot.original_repo_abs_path { - main_repo_workspace - .entry(snapshot.work_directory_abs_path.clone()) - .or_insert(i); - if let Some(waiting) = pending.remove(&snapshot.work_directory_abs_path) { - for (ws_idx, name, ws_path) in waiting { - absorbed.insert(ws_idx, (i, name)); - absorbed_workspace_by_path.insert(ws_path, ws_idx); - } - } - } else { - let name: SharedString = snapshot - .work_directory_abs_path - .file_name() - .unwrap_or_default() - .to_string_lossy() - .to_string() - .into(); - if let Some(&main_idx) = - main_repo_workspace.get(&snapshot.original_repo_abs_path) - { - absorbed.insert(i, (main_idx, name)); - absorbed_workspace_by_path - .insert(snapshot.work_directory_abs_path.clone(), i); - } else { - pending - .entry(snapshot.original_repo_abs_path.clone()) - .or_default() - .push((i, name, snapshot.work_directory_abs_path.clone())); - } - } - } - } + // Use ProjectGroupBuilder to canonically group workspaces by their + // main git repository. This replaces the manual absorbed-workspace + // detection that was here before. + let project_groups = ProjectGroupBuilder::from_multiworkspace(mw, cx); let has_open_projects = workspaces .iter() .any(|ws| !workspace_path_list(ws, cx).paths().is_empty()); - let active_ws_index = active_workspace - .as_ref() - .and_then(|active| workspaces.iter().position(|ws| ws == active)); - - for (ws_index, workspace) in workspaces.iter().enumerate() { - if absorbed.contains_key(&ws_index) { - continue; - } + let resolve_agent_icon = |agent_id: &AgentId| -> (IconName, Option) { + let agent = Agent::from(agent_id.clone()); + let icon = match agent { + Agent::NativeAgent => IconName::ZedAgent, + Agent::Custom { .. } => IconName::Terminal, + }; + let icon_from_external_svg = agent_server_store + .as_ref() + .and_then(|store| store.read(cx).agent_icon(&agent_id)); + (icon, icon_from_external_svg) + }; - let path_list = workspace_path_list(workspace, cx); + for (group_name, group) in project_groups.groups() { + let path_list = group_name.path_list().clone(); if path_list.paths().is_empty() { continue; } - let label = workspace_label_from_path_list(&path_list); + let label = group_name.display_name(); let is_collapsed = self.collapsed_groups.contains(&path_list); let should_load_threads = !is_collapsed || !query.is_empty(); - let mut live_infos = Self::all_thread_infos_for_workspace(workspace, cx); + let is_active = active_workspace + .as_ref() + .is_some_and(|active| group.workspaces.contains(active)); + + // Pick a representative workspace for the group: prefer the active + // workspace if it belongs to this group, otherwise use the main + // repo workspace (not a linked worktree). + let representative_workspace = active_workspace + .as_ref() + .filter(|_| is_active) + .unwrap_or_else(|| group.main_workspace(cx)); + + // Collect live thread infos from all workspaces in this group. + let live_infos: Vec<_> = group + .workspaces + .iter() + .flat_map(|ws| all_thread_infos_for_workspace(ws, cx)) + .collect(); let mut threads: Vec = Vec::new(); + let mut threadless_workspaces: Vec<(Entity, Vec)> = Vec::new(); let mut has_running_threads = false; let mut waiting_thread_count: usize = 0; if should_load_threads { let mut seen_session_ids: HashSet = HashSet::new(); + let thread_store = ThreadMetadataStore::global(cx); - // Read threads from the store cache for this workspace's path list. - let thread_store = SidebarThreadMetadataStore::global(cx); - let workspace_rows: Vec<_> = - thread_store.read(cx).entries_for_path(&path_list).collect(); - for row in workspace_rows { - seen_session_ids.insert(row.session_id.clone()); - let (agent, icon, icon_from_external_svg) = match &row.agent_id { - None => (Agent::NativeAgent, IconName::ZedAgent, None), - Some(id) => { - let custom_icon = agent_server_store - .as_ref() - .and_then(|store| store.read(cx).agent_icon(&id)); - ( - Agent::Custom { id: id.clone() }, - IconName::Terminal, - custom_icon, - ) - } - }; - threads.push(ThreadEntry { - agent, - session_info: acp_thread::AgentSessionInfo { - session_id: row.session_id.clone(), - work_dirs: None, - title: Some(row.title.clone()), - updated_at: Some(row.updated_at), - created_at: row.created_at, - meta: None, - }, - icon, - icon_from_external_svg, - status: AgentThreadStatus::default(), - workspace: ThreadEntryWorkspace::Open(workspace.clone()), - is_live: false, - is_background: false, - is_title_generating: false, - highlight_positions: Vec::new(), - worktree_name: None, - worktree_full_path: None, - worktree_highlight_positions: Vec::new(), - diff_stats: DiffStats::default(), - }); - } - - // Load threads from linked git worktrees of this workspace's repos. - { - let mut linked_worktree_queries: Vec<(PathList, SharedString, Arc)> = - Vec::new(); - for snapshot in root_repository_snapshots(workspace, cx) { - if snapshot.work_directory_abs_path != snapshot.original_repo_abs_path { + // Load threads from each workspace in the group. + for workspace in &group.workspaces { + let ws_path_list = workspace_path_list(workspace, cx); + let mut workspace_rows = thread_store + .read(cx) + .entries_for_path(&ws_path_list) + .peekable(); + if workspace_rows.peek().is_none() { + let worktrees = + worktree_info_from_thread_paths(&ws_path_list, &project_groups); + threadless_workspaces.push((workspace.clone(), worktrees)); + } + for row in workspace_rows { + if !seen_session_ids.insert(row.session_id.clone()) { continue; } - - let main_worktree_path = snapshot.original_repo_abs_path.clone(); - - for git_worktree in snapshot.linked_worktrees() { - let worktree_name = - linked_worktree_short_name(&main_worktree_path, &git_worktree.path) - .unwrap_or_default(); - linked_worktree_queries.push(( - PathList::new(std::slice::from_ref(&git_worktree.path)), - worktree_name, - Arc::from(git_worktree.path.as_path()), - )); - } + let (icon, icon_from_external_svg) = resolve_agent_icon(&row.agent_id); + let worktrees = + worktree_info_from_thread_paths(&row.folder_paths, &project_groups); + threads.push(ThreadEntry { + metadata: row, + icon, + icon_from_external_svg, + status: AgentThreadStatus::default(), + workspace: ThreadEntryWorkspace::Open(workspace.clone()), + is_live: false, + is_background: false, + is_title_generating: false, + highlight_positions: Vec::new(), + worktrees, + diff_stats: DiffStats::default(), + }); } + } - for (worktree_path_list, worktree_name, worktree_path) in - &linked_worktree_queries - { - let target_workspace = - match absorbed_workspace_by_path.get(worktree_path.as_ref()) { - Some(&idx) => { - live_infos.extend(Self::all_thread_infos_for_workspace( - &workspaces[idx], - cx, - )); - ThreadEntryWorkspace::Open(workspaces[idx].clone()) - } - None => ThreadEntryWorkspace::Closed(worktree_path_list.clone()), - }; + // Load threads from linked git worktrees whose + // canonical paths belong to this group. + let linked_worktree_queries = group + .workspaces + .iter() + .flat_map(|ws| root_repository_snapshots(ws, cx)) + .filter(|snapshot| !snapshot.is_linked_worktree()) + .flat_map(|snapshot| { + snapshot + .linked_worktrees() + .iter() + .filter(|wt| { + project_groups.group_owns_worktree(group, &path_list, &wt.path) + }) + .map(|wt| PathList::new(std::slice::from_ref(&wt.path))) + .collect::>() + }); - let worktree_rows: Vec<_> = thread_store - .read(cx) - .entries_for_path(worktree_path_list) - .collect(); - for row in worktree_rows { - if !seen_session_ids.insert(row.session_id.clone()) { - continue; - } - let (agent, icon, icon_from_external_svg) = match &row.agent_id { - None => (Agent::NativeAgent, IconName::ZedAgent, None), - Some(name) => { - let custom_icon = - agent_server_store.as_ref().and_then(|store| { - store.read(cx).agent_icon(&AgentId(name.clone().into())) - }); - ( - Agent::Custom { - id: AgentId::new(name.clone()), - }, - IconName::Terminal, - custom_icon, - ) - } - }; - threads.push(ThreadEntry { - agent, - session_info: acp_thread::AgentSessionInfo { - session_id: row.session_id.clone(), - work_dirs: None, - title: Some(row.title.clone()), - updated_at: Some(row.updated_at), - created_at: row.created_at, - meta: None, - }, - icon, - icon_from_external_svg, - status: AgentThreadStatus::default(), - workspace: target_workspace.clone(), - is_live: false, - is_background: false, - is_title_generating: false, - highlight_positions: Vec::new(), - worktree_name: Some(worktree_name.clone()), - worktree_full_path: Some( - worktree_path.display().to_string().into(), - ), - worktree_highlight_positions: Vec::new(), - diff_stats: DiffStats::default(), - }); + for worktree_path_list in linked_worktree_queries { + for row in thread_store.read(cx).entries_for_path(&worktree_path_list) { + if !seen_session_ids.insert(row.session_id.clone()) { + continue; } + let (icon, icon_from_external_svg) = resolve_agent_icon(&row.agent_id); + let worktrees = + worktree_info_from_thread_paths(&row.folder_paths, &project_groups); + threads.push(ThreadEntry { + metadata: row, + icon, + icon_from_external_svg, + status: AgentThreadStatus::default(), + workspace: ThreadEntryWorkspace::Closed(worktree_path_list.clone()), + is_live: false, + is_background: false, + is_title_generating: false, + highlight_positions: Vec::new(), + worktrees, + diff_stats: DiffStats::default(), + }); } } @@ -891,19 +846,12 @@ impl Sidebar { // Merge live info into threads and update notification state // in a single pass. for thread in &mut threads { - let session_id = &thread.session_info.session_id; - - if let Some(info) = live_info_by_session.get(session_id) { - thread.session_info.title = Some(info.title.clone()); - thread.status = info.status; - thread.icon = info.icon; - thread.icon_from_external_svg = info.icon_from_external_svg.clone(); - thread.is_live = true; - thread.is_background = info.is_background; - thread.is_title_generating = info.is_title_generating; - thread.diff_stats = info.diff_stats; + if let Some(info) = live_info_by_session.get(&thread.metadata.session_id) { + thread.apply_active_info(info); } + let session_id = &thread.metadata.session_id; + let is_thread_workspace_active = match &thread.workspace { ThreadEntryWorkspace::Open(thread_workspace) => active_workspace .as_ref() @@ -924,12 +872,22 @@ impl Sidebar { } threads.sort_by(|a, b| { - let a_time = a.session_info.created_at.or(a.session_info.updated_at); - let b_time = b.session_info.created_at.or(b.session_info.updated_at); + let a_time = self + .thread_last_message_sent_or_queued + .get(&a.metadata.session_id) + .copied() + .or(a.metadata.created_at) + .or(Some(a.metadata.updated_at)); + let b_time = self + .thread_last_message_sent_or_queued + .get(&b.metadata.session_id) + .copied() + .or(b.metadata.created_at) + .or(Some(b.metadata.updated_at)); b_time.cmp(&a_time) }); } else { - for info in &live_infos { + for info in live_infos { if info.status == AgentThreadStatus::Running { has_running_threads = true; } @@ -946,21 +904,17 @@ impl Sidebar { let mut matched_threads: Vec = Vec::new(); for mut thread in threads { - let title = thread - .session_info - .title - .as_ref() - .map(|s| s.as_ref()) - .unwrap_or(""); + let title: &str = &thread.metadata.title; if let Some(positions) = fuzzy_match_positions(&query, title) { thread.highlight_positions = positions; } - if let Some(worktree_name) = &thread.worktree_name { - if let Some(positions) = fuzzy_match_positions(&query, worktree_name) { - thread.worktree_highlight_positions = positions; + let mut worktree_matched = false; + for worktree in &mut thread.worktrees { + if let Some(positions) = fuzzy_match_positions(&query, &worktree.name) { + worktree.highlight_positions = positions; + worktree_matched = true; } } - let worktree_matched = !thread.worktree_highlight_positions.is_empty(); if workspace_matched || !thread.highlight_positions.is_empty() || worktree_matched @@ -977,49 +931,61 @@ impl Sidebar { entries.push(ListEntry::ProjectHeader { path_list: path_list.clone(), label, - workspace: workspace.clone(), + workspace: representative_workspace.clone(), highlight_positions: workspace_highlight_positions, has_running_threads, waiting_thread_count, + is_active, }); for thread in matched_threads { - current_session_ids.insert(thread.session_info.session_id.clone()); + current_session_ids.insert(thread.metadata.session_id.clone()); entries.push(thread.into()); } } else { - let thread_count = threads.len(); - let is_draft_for_workspace = self.agent_panel_visible - && self.active_thread_is_draft + let is_draft_for_workspace = agent_panel_visible + && active_thread_is_draft && self.focused_thread.is_none() - && active_ws_index.is_some_and(|active_idx| { - active_idx == ws_index - || absorbed - .get(&active_idx) - .is_some_and(|(main_idx, _)| *main_idx == ws_index) - }); - - let show_new_thread_entry = thread_count == 0 || is_draft_for_workspace; + && is_active; project_header_indices.push(entries.len()); entries.push(ListEntry::ProjectHeader { path_list: path_list.clone(), label, - workspace: workspace.clone(), + workspace: representative_workspace.clone(), highlight_positions: Vec::new(), has_running_threads, waiting_thread_count, + is_active, }); if is_collapsed { continue; } - if show_new_thread_entry { + // Emit "New Thread" entries for threadless workspaces + // and active drafts, right after the header. + for (workspace, worktrees) in &threadless_workspaces { + let is_draft = is_draft_for_workspace && workspace == representative_workspace; entries.push(ListEntry::NewThread { path_list: path_list.clone(), workspace: workspace.clone(), - is_active_draft: is_draft_for_workspace, + is_active_draft: is_draft, + worktrees: worktrees.clone(), + }); + } + if is_draft_for_workspace + && !threadless_workspaces + .iter() + .any(|(ws, _)| ws == representative_workspace) + { + let ws_path_list = workspace_path_list(representative_workspace, cx); + let worktrees = worktree_info_from_thread_paths(&ws_path_list, &project_groups); + entries.push(ListEntry::NewThread { + path_list: path_list.clone(), + workspace: representative_workspace.clone(), + is_active_draft: true, + worktrees, }); } @@ -1039,7 +1005,7 @@ impl Sidebar { for (index, thread) in threads.into_iter().enumerate() { let is_hidden = index >= count; - let session_id = &thread.session_info.session_id; + let session_id = &thread.metadata.session_id; if is_hidden { let is_promoted = thread.status == AgentThreadStatus::Running || thread.status == AgentThreadStatus::WaitingForConfirmation @@ -1076,6 +1042,11 @@ impl Sidebar { // the build pass (no extra scan needed). notified_threads.retain(|id| current_session_ids.contains(id)); + self.thread_last_accessed + .retain(|id, _| current_session_ids.contains(id)); + self.thread_last_message_sent_or_queued + .retain(|id, _| current_session_ids.contains(id)); + self.contents = SidebarContents { entries, notified_threads, @@ -1149,6 +1120,7 @@ impl Sidebar { highlight_positions, has_running_threads, waiting_thread_count, + is_active, } => self.render_project_header( ix, false, @@ -1158,6 +1130,7 @@ impl Sidebar { highlight_positions, *has_running_threads, *waiting_thread_count, + *is_active, is_selected, cx, ), @@ -1170,9 +1143,16 @@ impl Sidebar { path_list, workspace, is_active_draft, - } => { - self.render_new_thread(ix, path_list, workspace, *is_active_draft, is_selected, cx) - } + worktrees, + } => self.render_new_thread( + ix, + path_list, + workspace, + *is_active_draft, + worktrees, + is_selected, + cx, + ), }; if is_group_header_after_first { @@ -1187,6 +1167,34 @@ impl Sidebar { } } + fn render_remote_project_icon( + &self, + ix: usize, + workspace: &Entity, + cx: &mut Context, + ) -> Option { + let project = workspace.read(cx).project().read(cx); + let remote_connection_options = project.remote_connection_options(cx)?; + + let remote_icon_per_type = match remote_connection_options { + RemoteConnectionOptions::Wsl(_) => IconName::Linux, + RemoteConnectionOptions::Docker(_) => IconName::Box, + _ => IconName::Server, + }; + + Some( + div() + .id(format!("remote-project-icon-{}", ix)) + .child( + Icon::new(remote_icon_per_type) + .size(IconSize::XSmall) + .color(Color::Muted), + ) + .tooltip(Tooltip::text("Remote Project")) + .into_any_element(), + ) + } + fn render_project_header( &self, ix: usize, @@ -1197,6 +1205,7 @@ impl Sidebar { highlight_positions: &[usize], has_running_threads: bool, waiting_thread_count: usize, + is_active: bool, is_selected: bool, cx: &mut Context, ) -> AnyElement { @@ -1220,16 +1229,12 @@ impl Sidebar { let workspace_for_remove = workspace.clone(); let workspace_for_menu = workspace.clone(); + let workspace_for_open = workspace.clone(); let path_list_for_toggle = path_list.clone(); let path_list_for_collapse = path_list.clone(); let view_more_expanded = self.expanded_groups.contains_key(path_list); - let multi_workspace = self.multi_workspace.upgrade(); - let workspace_count = multi_workspace - .as_ref() - .map_or(0, |mw| mw.read(cx).workspaces().len()); - let label = if highlight_positions.is_empty() { Label::new(label.clone()) .color(Color::Muted) @@ -1250,7 +1255,8 @@ impl Sidebar { .group(&group_name) .h(Tab::content_height(cx)) .w_full() - .px_1p5() + .pl_1p5() + .pr_1() .border_1() .map(|this| { if is_selected { @@ -1275,30 +1281,38 @@ impl Sidebar { ), ) .child(label) - .when(is_collapsed && has_running_threads, |this| { - this.child( - Icon::new(IconName::LoadCircle) - .size(IconSize::XSmall) - .color(Color::Muted) - .with_rotate_animation(2), - ) - }) - .when(is_collapsed && waiting_thread_count > 0, |this| { - let tooltip_text = if waiting_thread_count == 1 { - "1 thread is waiting for confirmation".to_string() - } else { - format!("{waiting_thread_count} threads are waiting for confirmation",) - }; - this.child( - div() - .id(format!("{id_prefix}waiting-indicator-{ix}")) - .child( - Icon::new(IconName::Warning) - .size(IconSize::XSmall) - .color(Color::Warning), + .when_some( + self.render_remote_project_icon(ix, workspace, cx), + |this, icon| this.child(icon), + ) + .when(is_collapsed, |this| { + this.when(has_running_threads, |this| { + this.child( + Icon::new(IconName::LoadCircle) + .size(IconSize::XSmall) + .color(Color::Muted) + .with_rotate_animation(2), + ) + }) + .when(waiting_thread_count > 0, |this| { + let tooltip_text = if waiting_thread_count == 1 { + "1 thread is waiting for confirmation".to_string() + } else { + format!( + "{waiting_thread_count} threads are waiting for confirmation", ) - .tooltip(Tooltip::text(tooltip_text)), - ) + }; + this.child( + div() + .id(format!("{id_prefix}waiting-indicator-{ix}")) + .child( + Icon::new(IconName::Warning) + .size(IconSize::XSmall) + .color(Color::Warning), + ) + .tooltip(Tooltip::text(tooltip_text)), + ) + }) }), ) .child({ @@ -1340,23 +1354,36 @@ impl Sidebar { })), ) }) - .when(workspace_count > 1, |this| { - let workspace_for_remove_btn = workspace_for_remove.clone(); + .when(!is_active, |this| { this.child( IconButton::new( SharedString::from(format!( - "{id_prefix}project-header-remove-{ix}", + "{id_prefix}project-header-open-workspace-{ix}", )), - IconName::Close, + IconName::Focus, ) .icon_size(IconSize::Small) .icon_color(Color::Muted) - .tooltip(Tooltip::text("Remove Project")) - .on_click(cx.listener( + .tooltip(Tooltip::text("Activate Workspace")) + .on_click(cx.listener({ move |this, _, window, cx| { - this.remove_workspace(&workspace_for_remove_btn, window, cx); - }, - )), + this.focused_thread = None; + if let Some(multi_workspace) = this.multi_workspace.upgrade() { + multi_workspace.update(cx, |multi_workspace, cx| { + multi_workspace.activate( + workspace_for_open.clone(), + window, + cx, + ); + }); + } + if AgentPanel::is_visible(&workspace_for_open, cx) { + workspace_for_open.update(cx, |workspace, cx| { + workspace.focus_panel::(window, cx); + }); + } + } + })), ) }) .when(show_new_thread_button, |this| { @@ -1388,11 +1415,6 @@ impl Sidebar { this.selection = None; this.toggle_collapse(&path_list_for_toggle, window, cx); })) - // TODO: Decide if we really want the header to be activating different workspaces - // .on_click(cx.listener(move |this, _, window, cx| { - // this.selection = None; - // this.activate_workspace(&workspace_for_activate, window, cx); - // })) .into_any_element() } @@ -1455,13 +1477,7 @@ impl Sidebar { if let Some(mw) = multi_workspace_for_worktree.upgrade() { let ws = workspace_for_remove_worktree.clone(); mw.update(cx, |multi_workspace, cx| { - if let Some(index) = multi_workspace - .workspaces() - .iter() - .position(|w| *w == ws) - { - multi_workspace.remove_workspace(index, window, cx); - } + multi_workspace.remove(&ws, window, cx); }); } } else { @@ -1485,20 +1501,59 @@ impl Sidebar { let workspace_for_add = workspace.clone(); let multi_workspace_for_add = multi_workspace.clone(); - menu.separator().entry( + let menu = menu.separator().entry( "Add Folder to Project", Some(Box::new(AddFolderToProject)), move |window, cx| { if let Some(mw) = multi_workspace_for_add.upgrade() { mw.update(cx, |mw, cx| { - mw.activate(workspace_for_add.clone(), cx); + mw.activate(workspace_for_add.clone(), window, cx); }); } workspace_for_add.update(cx, |workspace, cx| { workspace.add_folder_to_project(&AddFolderToProject, window, cx); }); }, - ) + ); + + let workspace_count = multi_workspace + .upgrade() + .map_or(0, |mw| mw.read(cx).workspaces().len()); + let menu = if workspace_count > 1 { + let workspace_for_move = workspace.clone(); + let multi_workspace_for_move = multi_workspace.clone(); + menu.entry( + "Move to New Window", + Some(Box::new( + zed_actions::agents_sidebar::MoveWorkspaceToNewWindow, + )), + move |window, cx| { + if let Some(mw) = multi_workspace_for_move.upgrade() { + mw.update(cx, |multi_workspace, cx| { + multi_workspace.move_workspace_to_new_window( + &workspace_for_move, + window, + cx, + ); + }); + } + }, + ) + } else { + menu + }; + + let workspace_for_remove = workspace_for_remove.clone(); + let multi_workspace_for_remove = multi_workspace.clone(); + menu.separator() + .entry("Remove Project", None, move |window, cx| { + if let Some(mw) = multi_workspace_for_remove.upgrade() { + let ws = workspace_for_remove.clone(); + mw.update(cx, |multi_workspace, cx| { + multi_workspace.remove(&ws, window, cx); + }); + } + }) }); let this = this.clone(); @@ -1558,6 +1613,7 @@ impl Sidebar { highlight_positions, has_running_threads, waiting_thread_count, + is_active, } = self.contents.entries.get(header_idx)? else { return None; @@ -1571,10 +1627,11 @@ impl Sidebar { true, &path_list, &label, - &workspace, + workspace, &highlight_positions, *has_running_threads, *waiting_thread_count, + *is_active, is_selected, cx, ); @@ -1643,12 +1700,10 @@ impl Sidebar { if path_list.paths().len() != 1 { continue; } - let should_prune = root_repository_snapshots(workspace, cx) - .iter() - .any(|snapshot| { - snapshot.work_directory_abs_path != snapshot.original_repo_abs_path - && !known_worktree_paths.contains(snapshot.work_directory_abs_path.as_ref()) - }); + let should_prune = root_repository_snapshots(workspace, cx).any(|snapshot| { + snapshot.work_directory_abs_path != snapshot.original_repo_abs_path + && !known_worktree_paths.contains(snapshot.work_directory_abs_path.as_ref()) + }); if should_prune { to_remove.push(workspace.clone()); } @@ -1665,32 +1720,14 @@ impl Sidebar { } for workspace in &to_remove { - self.remove_workspace(workspace, window, cx); + if let Some(multi_workspace) = self.multi_workspace.upgrade() { + multi_workspace.update(cx, |multi_workspace, cx| { + multi_workspace.remove(workspace, window, cx); + }); + } } } - fn remove_workspace( - &mut self, - workspace: &Entity, - window: &mut Window, - cx: &mut Context, - ) { - let Some(multi_workspace) = self.multi_workspace.upgrade() else { - return; - }; - - multi_workspace.update(cx, |multi_workspace, cx| { - let Some(index) = multi_workspace - .workspaces() - .iter() - .position(|w| w == workspace) - else { - return; - }; - multi_workspace.remove_workspace(index, window, cx); - }); - } - fn toggle_collapse( &mut self, path_list: &PathList, @@ -1705,6 +1742,21 @@ impl Sidebar { self.update_entries(cx); } + fn dispatch_context(&self, window: &Window, cx: &Context) -> KeyContext { + let mut dispatch_context = KeyContext::new_with_defaults(); + dispatch_context.add("ThreadsSidebar"); + dispatch_context.add("menu"); + + let identifier = if self.filter_editor.focus_handle(cx).is_focused(window) { + "searching" + } else { + "not_searching" + }; + + dispatch_context.add(identifier); + dispatch_context + } + fn focus_in(&mut self, window: &mut Window, cx: &mut Context) { if !self.focus_handle.is_focused(window) { return; @@ -1858,22 +1910,15 @@ impl Sidebar { self.toggle_collapse(&path_list, window, cx); } ListEntry::Thread(thread) => { - let session_info = thread.session_info.clone(); + let metadata = thread.metadata.clone(); match &thread.workspace { ThreadEntryWorkspace::Open(workspace) => { let workspace = workspace.clone(); - self.activate_thread( - thread.agent.clone(), - session_info, - &workspace, - window, - cx, - ); + self.activate_thread(metadata, &workspace, window, cx); } ThreadEntryWorkspace::Closed(path_list) => { self.open_workspace_and_activate_thread( - thread.agent.clone(), - session_info, + metadata, path_list.clone(), window, cx, @@ -1939,8 +1984,8 @@ impl Sidebar { fn load_agent_thread_in_workspace( workspace: &Entity, - agent: Agent, - session_info: acp_thread::AgentSessionInfo, + metadata: &ThreadMetadata, + focus: bool, window: &mut Window, cx: &mut App, ) { @@ -1951,11 +1996,11 @@ impl Sidebar { if let Some(agent_panel) = workspace.read(cx).panel::(cx) { agent_panel.update(cx, |panel, cx| { panel.load_agent_thread( - agent, - session_info.session_id, - session_info.work_dirs, - session_info.title, - true, + Agent::from(metadata.agent_id.clone()), + metadata.session_id.clone(), + Some(metadata.folder_paths.clone()), + Some(metadata.title.clone()), + focus, window, cx, ); @@ -1965,8 +2010,7 @@ impl Sidebar { fn activate_thread_locally( &mut self, - agent: Agent, - session_info: acp_thread::AgentSessionInfo, + metadata: &ThreadMetadata, workspace: &Entity, window: &mut Window, cx: &mut Context, @@ -1978,32 +2022,32 @@ impl Sidebar { // Set focused_thread eagerly so the sidebar highlight updates // immediately, rather than waiting for a deferred AgentPanel // event which can race with ActiveWorkspaceChanged clearing it. - self.focused_thread = Some(session_info.session_id.clone()); + self.focused_thread = Some(metadata.session_id.clone()); + self.record_thread_access(&metadata.session_id); multi_workspace.update(cx, |multi_workspace, cx| { - multi_workspace.activate(workspace.clone(), cx); + multi_workspace.activate(workspace.clone(), window, cx); }); - Self::load_agent_thread_in_workspace(workspace, agent, session_info, window, cx); + Self::load_agent_thread_in_workspace(workspace, metadata, true, window, cx); self.update_entries(cx); } fn activate_thread_in_other_window( &self, - agent: Agent, - session_info: acp_thread::AgentSessionInfo, + metadata: ThreadMetadata, workspace: Entity, target_window: WindowHandle, cx: &mut Context, ) { - let target_session_id = session_info.session_id.clone(); + let target_session_id = metadata.session_id.clone(); let activated = target_window .update(cx, |multi_workspace, window, cx| { window.activate_window(); - multi_workspace.activate(workspace.clone(), cx); - Self::load_agent_thread_in_workspace(&workspace, agent, session_info, window, cx); + multi_workspace.activate(workspace.clone(), window, cx); + Self::load_agent_thread_in_workspace(&workspace, &metadata, true, window, cx); }) .log_err() .is_some(); @@ -2018,7 +2062,8 @@ impl Sidebar { .and_then(|sidebar| sidebar.downcast::().ok()) { target_sidebar.update(cx, |sidebar, cx| { - sidebar.focused_thread = Some(target_session_id); + sidebar.focused_thread = Some(target_session_id.clone()); + sidebar.record_thread_access(&target_session_id); sidebar.update_entries(cx); }); } @@ -2027,8 +2072,7 @@ impl Sidebar { fn activate_thread( &mut self, - agent: Agent, - session_info: acp_thread::AgentSessionInfo, + metadata: ThreadMetadata, workspace: &Entity, window: &mut Window, cx: &mut Context, @@ -2037,7 +2081,7 @@ impl Sidebar { .find_workspace_in_current_window(cx, |candidate, _| candidate == workspace) .is_some() { - self.activate_thread_locally(agent, session_info, &workspace, window, cx); + self.activate_thread_locally(&metadata, &workspace, window, cx); return; } @@ -2047,13 +2091,12 @@ impl Sidebar { return; }; - self.activate_thread_in_other_window(agent, session_info, workspace, target_window, cx); + self.activate_thread_in_other_window(metadata, workspace, target_window, cx); } fn open_workspace_and_activate_thread( &mut self, - agent: Agent, - session_info: acp_thread::AgentSessionInfo, + metadata: ThreadMetadata, path_list: PathList, window: &mut Window, cx: &mut Context, @@ -2065,12 +2108,15 @@ impl Sidebar { let paths: Vec = path_list.paths().iter().map(|p| p.to_path_buf()).collect(); - let open_task = multi_workspace.update(cx, |mw, cx| mw.open_project(paths, window, cx)); + let open_task = multi_workspace.update(cx, |mw, cx| { + mw.open_project(paths, workspace::OpenMode::Activate, window, cx) + }); cx.spawn_in(window, async move |this, cx| { let workspace = open_task.await?; + this.update_in(cx, |this, window, cx| { - this.activate_thread(agent, session_info, &workspace, window, cx); + this.activate_thread(metadata, &workspace, window, cx); })?; anyhow::Ok(()) }) @@ -2099,37 +2145,23 @@ impl Sidebar { fn activate_archived_thread( &mut self, - agent: Agent, - session_info: acp_thread::AgentSessionInfo, + metadata: ThreadMetadata, window: &mut Window, cx: &mut Context, ) { - // Eagerly save thread metadata so that the sidebar is updated immediately - SidebarThreadMetadataStore::global(cx) - .update(cx, |store, cx| { - store.save( - ThreadMetadata::from_session_info(agent.id(), &session_info), - cx, - ) - }) - .detach_and_log_err(cx); + ThreadMetadataStore::global(cx) + .update(cx, |store, cx| store.unarchive(&metadata.session_id, cx)); - if let Some(path_list) = &session_info.work_dirs { - if let Some(workspace) = self.find_current_workspace_for_path_list(path_list, cx) { - self.activate_thread_locally(agent, session_info, &workspace, window, cx); + if !metadata.folder_paths.paths().is_empty() { + let path_list = metadata.folder_paths.clone(); + if let Some(workspace) = self.find_current_workspace_for_path_list(&path_list, cx) { + self.activate_thread_locally(&metadata, &workspace, window, cx); } else if let Some((target_window, workspace)) = - self.find_open_workspace_for_path_list(path_list, cx) + self.find_open_workspace_for_path_list(&path_list, cx) { - self.activate_thread_in_other_window( - agent, - session_info, - workspace, - target_window, - cx, - ); + self.activate_thread_in_other_window(metadata, workspace, target_window, cx); } else { - let path_list = path_list.clone(); - self.open_workspace_and_activate_thread(agent, session_info, path_list, window, cx); + self.open_workspace_and_activate_thread(metadata, path_list, window, cx); } return; } @@ -2142,7 +2174,7 @@ impl Sidebar { }); if let Some(workspace) = active_workspace { - self.activate_thread_locally(agent, session_info, &workspace, window, cx); + self.activate_thread_locally(&metadata, &workspace, window, cx); } } @@ -2290,13 +2322,15 @@ impl Sidebar { window: &mut Window, cx: &mut Context, ) { + ThreadMetadataStore::global(cx).update(cx, |store, cx| store.archive(session_id, cx)); + // If we're archiving the currently focused thread, move focus to the // nearest thread within the same project group. We never cross group // boundaries — if the group has no other threads, clear focus and open // a blank new thread in the panel instead. if self.focused_thread.as_ref() == Some(session_id) { let current_pos = self.contents.entries.iter().position(|entry| { - matches!(entry, ListEntry::Thread(t) if &t.session_info.session_id == session_id) + matches!(entry, ListEntry::Thread(t) if &t.metadata.session_id == session_id) }); // Find the workspace that owns this thread's project group by @@ -2348,16 +2382,27 @@ impl Sidebar { }); if let Some(next) = next_thread { - self.focused_thread = Some(next.session_info.session_id.clone()); + let next_metadata = next.metadata.clone(); + // Use the thread's own workspace when it has one open (e.g. an absorbed + // linked worktree thread that appears under the main workspace's header + // but belongs to its own workspace). Loading into the wrong panel binds + // the thread to the wrong project, which corrupts its stored folder_paths + // when metadata is saved via ThreadMetadata::from_thread. + let target_workspace = match &next.workspace { + ThreadEntryWorkspace::Open(ws) => Some(ws.clone()), + ThreadEntryWorkspace::Closed(_) => group_workspace, + }; + self.focused_thread = Some(next_metadata.session_id.clone()); + self.record_thread_access(&next_metadata.session_id); - if let Some(workspace) = &group_workspace { + if let Some(workspace) = target_workspace { if let Some(agent_panel) = workspace.read(cx).panel::(cx) { agent_panel.update(cx, |panel, cx| { panel.load_agent_thread( - next.agent.clone(), - next.session_info.session_id.clone(), - next.session_info.work_dirs.clone(), - next.session_info.title.clone(), + Agent::from(next_metadata.agent_id.clone()), + next_metadata.session_id.clone(), + Some(next_metadata.folder_paths.clone()), + Some(next_metadata.title.clone()), true, window, cx, @@ -2376,10 +2421,6 @@ impl Sidebar { } } } - - SidebarThreadMetadataStore::global(cx) - .update(cx, |store, cx| store.delete(session_id.clone(), cx)) - .detach_and_log_err(cx); } fn remove_selected_thread( @@ -2394,11 +2435,260 @@ impl Sidebar { let Some(ListEntry::Thread(thread)) = self.contents.entries.get(ix) else { return; }; - if thread.agent != Agent::NativeAgent { + match thread.status { + AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation => return, + AgentThreadStatus::Completed | AgentThreadStatus::Error => {} + } + + let session_id = thread.metadata.session_id.clone(); + self.archive_thread(&session_id, window, cx) + } + + fn record_thread_access(&mut self, session_id: &acp::SessionId) { + self.thread_last_accessed + .insert(session_id.clone(), Utc::now()); + } + + fn record_thread_message_sent(&mut self, session_id: &acp::SessionId) { + self.thread_last_message_sent_or_queued + .insert(session_id.clone(), Utc::now()); + } + + fn mru_threads_for_switcher(&self, _cx: &App) -> Vec { + let mut current_header_workspace: Option> = None; + let mut entries: Vec = self + .contents + .entries + .iter() + .filter_map(|entry| match entry { + ListEntry::ProjectHeader { workspace, .. } => { + current_header_workspace = Some(workspace.clone()); + None + } + ListEntry::Thread(thread) => { + let workspace = match &thread.workspace { + ThreadEntryWorkspace::Open(workspace) => workspace.clone(), + ThreadEntryWorkspace::Closed(_) => { + current_header_workspace.as_ref()?.clone() + } + }; + let notified = self + .contents + .is_thread_notified(&thread.metadata.session_id); + let timestamp: SharedString = format_history_entry_timestamp( + self.thread_last_message_sent_or_queued + .get(&thread.metadata.session_id) + .copied() + .or(thread.metadata.created_at) + .unwrap_or(thread.metadata.updated_at), + ) + .into(); + Some(ThreadSwitcherEntry { + session_id: thread.metadata.session_id.clone(), + title: thread.metadata.title.clone(), + icon: thread.icon, + icon_from_external_svg: thread.icon_from_external_svg.clone(), + status: thread.status, + metadata: thread.metadata.clone(), + workspace, + worktree_name: thread.worktrees.first().map(|wt| wt.name.clone()), + + diff_stats: thread.diff_stats, + is_title_generating: thread.is_title_generating, + notified, + timestamp, + }) + } + _ => None, + }) + .collect(); + + entries.sort_by(|a, b| { + let a_accessed = self.thread_last_accessed.get(&a.session_id); + let b_accessed = self.thread_last_accessed.get(&b.session_id); + + match (a_accessed, b_accessed) { + (Some(a_time), Some(b_time)) => b_time.cmp(a_time), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => { + let a_sent = self.thread_last_message_sent_or_queued.get(&a.session_id); + let b_sent = self.thread_last_message_sent_or_queued.get(&b.session_id); + + match (a_sent, b_sent) { + (Some(a_time), Some(b_time)) => b_time.cmp(a_time), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => { + let a_time = a.metadata.created_at.or(Some(a.metadata.updated_at)); + let b_time = b.metadata.created_at.or(Some(b.metadata.updated_at)); + b_time.cmp(&a_time) + } + } + } + } + }); + + entries + } + + fn dismiss_thread_switcher(&mut self, cx: &mut Context) { + self.thread_switcher = None; + self._thread_switcher_subscriptions.clear(); + if let Some(mw) = self.multi_workspace.upgrade() { + mw.update(cx, |mw, cx| { + mw.set_sidebar_overlay(None, cx); + }); + } + } + + fn on_toggle_thread_switcher( + &mut self, + action: &ToggleThreadSwitcher, + window: &mut Window, + cx: &mut Context, + ) { + self.toggle_thread_switcher_impl(action.select_last, window, cx); + } + + fn toggle_thread_switcher_impl( + &mut self, + select_last: bool, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(thread_switcher) = &self.thread_switcher { + thread_switcher.update(cx, |switcher, cx| { + if select_last { + switcher.select_last(cx); + } else { + switcher.cycle_selection(cx); + } + }); return; } - let session_id = thread.session_info.session_id.clone(); - self.archive_thread(&session_id, window, cx); + + let entries = self.mru_threads_for_switcher(cx); + if entries.len() < 2 { + return; + } + + let weak_multi_workspace = self.multi_workspace.clone(); + + let original_metadata = self + .focused_thread + .as_ref() + .and_then(|focused_id| entries.iter().find(|e| &e.session_id == focused_id)) + .map(|e| e.metadata.clone()); + let original_workspace = self + .multi_workspace + .upgrade() + .map(|mw| mw.read(cx).workspace().clone()); + + let thread_switcher = cx.new(|cx| ThreadSwitcher::new(entries, select_last, window, cx)); + + let mut subscriptions = Vec::new(); + + subscriptions.push(cx.subscribe_in(&thread_switcher, window, { + let thread_switcher = thread_switcher.clone(); + move |this, _emitter, event: &ThreadSwitcherEvent, window, cx| match event { + ThreadSwitcherEvent::Preview { + metadata, + workspace, + } => { + if let Some(mw) = weak_multi_workspace.upgrade() { + mw.update(cx, |mw, cx| { + mw.activate(workspace.clone(), window, cx); + }); + } + this.focused_thread = Some(metadata.session_id.clone()); + this.update_entries(cx); + Self::load_agent_thread_in_workspace(workspace, metadata, false, window, cx); + let focus = thread_switcher.focus_handle(cx); + window.focus(&focus, cx); + } + ThreadSwitcherEvent::Confirmed { + metadata, + workspace, + } => { + if let Some(mw) = weak_multi_workspace.upgrade() { + mw.update(cx, |mw, cx| { + mw.activate(workspace.clone(), window, cx); + }); + } + this.record_thread_access(&metadata.session_id); + this.focused_thread = Some(metadata.session_id.clone()); + this.update_entries(cx); + Self::load_agent_thread_in_workspace(workspace, metadata, false, window, cx); + this.dismiss_thread_switcher(cx); + workspace.update(cx, |workspace, cx| { + workspace.focus_panel::(window, cx); + }); + } + ThreadSwitcherEvent::Dismissed => { + if let Some(mw) = weak_multi_workspace.upgrade() { + if let Some(original_ws) = &original_workspace { + mw.update(cx, |mw, cx| { + mw.activate(original_ws.clone(), window, cx); + }); + } + } + if let Some(metadata) = &original_metadata { + this.focused_thread = Some(metadata.session_id.clone()); + this.update_entries(cx); + if let Some(original_ws) = &original_workspace { + Self::load_agent_thread_in_workspace( + original_ws, + metadata, + false, + window, + cx, + ); + } + } + this.dismiss_thread_switcher(cx); + } + } + })); + + subscriptions.push(cx.subscribe_in( + &thread_switcher, + window, + |this, _emitter, _event: &gpui::DismissEvent, _window, cx| { + this.dismiss_thread_switcher(cx); + }, + )); + + let focus = thread_switcher.focus_handle(cx); + let overlay_view = gpui::AnyView::from(thread_switcher.clone()); + + // Replay the initial preview that was emitted during construction + // before subscriptions were wired up. + let initial_preview = thread_switcher + .read(cx) + .selected_entry() + .map(|entry| (entry.metadata.clone(), entry.workspace.clone())); + + self.thread_switcher = Some(thread_switcher); + self._thread_switcher_subscriptions = subscriptions; + if let Some(mw) = self.multi_workspace.upgrade() { + mw.update(cx, |mw, cx| { + mw.set_sidebar_overlay(Some(overlay_view), cx); + }); + } + + if let Some((metadata, workspace)) = initial_preview { + if let Some(mw) = self.multi_workspace.upgrade() { + mw.update(cx, |mw, cx| { + mw.activate(workspace.clone(), window, cx); + }); + } + self.focused_thread = Some(metadata.session_id.clone()); + self.update_entries(cx); + Self::load_agent_thread_in_workspace(&workspace, &metadata, false, window, cx); + } + + window.focus(&focus, cx); } fn render_thread( @@ -2410,34 +2700,32 @@ impl Sidebar { ) -> AnyElement { let has_notification = self .contents - .is_thread_notified(&thread.session_info.session_id); - - let title: SharedString = thread - .session_info - .title - .clone() - .unwrap_or_else(|| "Untitled".into()); - let session_info = thread.session_info.clone(); + .is_thread_notified(&thread.metadata.session_id); + + let title: SharedString = thread.metadata.title.clone(); + let metadata = thread.metadata.clone(); let thread_workspace = thread.workspace.clone(); let is_hovered = self.hovered_thread_index == Some(ix); - let is_selected = self.agent_panel_visible - && self.focused_thread.as_ref() == Some(&session_info.session_id); + let is_selected = self.agent_panel_visible(cx) + && self.focused_thread.as_ref() == Some(&metadata.session_id); let is_running = matches!( thread.status, AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation ); - let session_id_for_delete = thread.session_info.session_id.clone(); + let session_id_for_delete = thread.metadata.session_id.clone(); let focus_handle = self.focus_handle.clone(); let id = SharedString::from(format!("thread-entry-{}", ix)); - let timestamp = thread - .session_info - .created_at - .or(thread.session_info.updated_at) - .map(format_history_entry_timestamp); + let timestamp = format_history_entry_timestamp( + self.thread_last_message_sent_or_queued + .get(&thread.metadata.session_id) + .copied() + .or(thread.metadata.created_at) + .unwrap_or(thread.metadata.updated_at), + ); ThreadItem::new(id, title) .icon(thread.icon) @@ -2445,15 +2733,18 @@ impl Sidebar { .when_some(thread.icon_from_external_svg.clone(), |this, svg| { this.custom_icon_from_external_svg(svg) }) - .when_some(thread.worktree_name.clone(), |this, name| { - let this = this.worktree(name); - match thread.worktree_full_path.clone() { - Some(path) => this.worktree_full_path(path), - None => this, - } - }) - .worktree_highlight_positions(thread.worktree_highlight_positions.clone()) - .when_some(timestamp, |this, ts| this.timestamp(ts)) + .worktrees( + thread + .worktrees + .iter() + .map(|wt| ThreadItemWorktreeInfo { + name: wt.name.clone(), + full_path: wt.full_path.clone(), + highlight_positions: wt.highlight_positions.clone(), + }) + .collect(), + ) + .timestamp(timestamp) .highlight_positions(thread.highlight_positions.to_vec()) .title_generating(thread.is_title_generating) .notified(has_notification) @@ -2514,23 +2805,15 @@ impl Sidebar { ) }) .on_click({ - let agent = thread.agent.clone(); cx.listener(move |this, _, window, cx| { this.selection = None; match &thread_workspace { ThreadEntryWorkspace::Open(workspace) => { - this.activate_thread( - agent.clone(), - session_info.clone(), - workspace, - window, - cx, - ); + this.activate_thread(metadata.clone(), workspace, window, cx); } ThreadEntryWorkspace::Closed(path_list) => { this.open_workspace_and_activate_thread( - agent.clone(), - session_info.clone(), + metadata.clone(), path_list.clone(), window, cx, @@ -2698,7 +2981,7 @@ impl Sidebar { self.focused_thread = None; multi_workspace.update(cx, |multi_workspace, cx| { - multi_workspace.activate(workspace.clone(), cx); + multi_workspace.activate(workspace.clone(), window, cx); }); workspace.update(cx, |workspace, cx| { @@ -2717,10 +3000,12 @@ impl Sidebar { _path_list: &PathList, workspace: &Entity, is_active_draft: bool, + worktrees: &[WorktreeInfo], is_selected: bool, cx: &mut Context, ) -> AnyElement { - let is_active = is_active_draft && self.agent_panel_visible && self.active_thread_is_draft; + let is_active = + is_active_draft && self.agent_panel_visible(cx) && self.active_thread_is_draft(cx); let label: SharedString = if is_active { self.active_draft_text(cx) @@ -2735,6 +3020,16 @@ impl Sidebar { let thread_item = ThreadItem::new(id, label) .icon(IconName::Plus) .icon_color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.8))) + .worktrees( + worktrees + .iter() + .map(|wt| ThreadItemWorktreeInfo { + name: wt.name.clone(), + full_path: wt.full_path.clone(), + highlight_positions: wt.highlight_positions.clone(), + }) + .collect(), + ) .selected(is_active) .focused(is_selected) .when(!is_active, |this| { @@ -2824,22 +3119,39 @@ impl Sidebar { cx: &mut Context, ) -> impl IntoElement { let has_query = self.has_filter_query(cx); - let traffic_lights = cfg!(target_os = "macos") && !window.is_fullscreen(); + let sidebar_on_left = self.side(cx) == SidebarSide::Left; + let sidebar_on_right = self.side(cx) == SidebarSide::Right; + let not_fullscreen = !window.is_fullscreen(); + let traffic_lights = cfg!(target_os = "macos") && not_fullscreen && sidebar_on_left; + let left_window_controls = !cfg!(target_os = "macos") && not_fullscreen && sidebar_on_left; + let right_window_controls = + !cfg!(target_os = "macos") && not_fullscreen && sidebar_on_right; let header_height = platform_title_bar_height(window); h_flex() .h(header_height) .mt_px() .pb_px() - .when(traffic_lights, |this| { - this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING)) + .when(left_window_controls, |this| { + this.children(Self::render_left_window_controls(window, cx)) + }) + .map(|this| { + if traffic_lights { + this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING)) + } else if !left_window_controls { + this.pl_1p5() + } else { + this + } }) - .pr_1p5() + .when(!right_window_controls, |this| this.pr_1p5()) .gap_1() .when(!no_open_projects, |this| { this.border_b_1() .border_color(cx.theme().colors().border) - .child(Divider::vertical().color(ui::DividerColor::Border)) + .when(traffic_lights, |this| { + this.child(Divider::vertical().color(ui::DividerColor::Border)) + }) .child( div().ml_1().child( Icon::new(IconName::MagnifyingGlass) @@ -2869,40 +3181,115 @@ impl Sidebar { }), ) }) + .when(right_window_controls, |this| { + this.children(Self::render_right_window_controls(window, cx)) + }) + } + + fn render_left_window_controls(window: &Window, cx: &mut App) -> Option { + platform_title_bar::render_left_window_controls( + cx.button_layout(), + Box::new(CloseWindow), + window, + ) + } + + fn render_right_window_controls(window: &Window, cx: &mut App) -> Option { + platform_title_bar::render_right_window_controls( + cx.button_layout(), + Box::new(CloseWindow), + window, + ) } fn render_sidebar_toggle_button(&self, _cx: &mut Context) -> impl IntoElement { - IconButton::new("sidebar-close-toggle", IconName::ThreadsSidebarLeftOpen) - .icon_size(IconSize::Small) - .tooltip(Tooltip::element(move |_window, cx| { - v_flex() - .gap_1() - .child( - h_flex() - .gap_2() - .justify_between() - .child(Label::new("Toggle Sidebar")) - .child(KeyBinding::for_action(&ToggleWorkspaceSidebar, cx)), - ) - .child( - h_flex() - .pt_1() - .gap_2() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .justify_between() - .child(Label::new("Focus Sidebar")) - .child(KeyBinding::for_action(&FocusWorkspaceSidebar, cx)), - ) - .into_any_element() - })) - .on_click(|_, window, cx| { - window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx); + let on_right = AgentSettings::get_global(_cx).sidebar_side() == SidebarSide::Right; + + sidebar_side_context_menu("sidebar-toggle-menu", _cx) + .anchor(if on_right { + gpui::Corner::BottomRight + } else { + gpui::Corner::BottomLeft + }) + .attach(if on_right { + gpui::Corner::TopRight + } else { + gpui::Corner::TopLeft + }) + .trigger(move |_is_active, _window, _cx| { + let icon = if on_right { + IconName::ThreadsSidebarRightOpen + } else { + IconName::ThreadsSidebarLeftOpen + }; + IconButton::new("sidebar-close-toggle", icon) + .icon_size(IconSize::Small) + .tooltip(Tooltip::element(move |_window, cx| { + v_flex() + .gap_1() + .child( + h_flex() + .gap_2() + .justify_between() + .child(Label::new("Toggle Sidebar")) + .child(KeyBinding::for_action(&ToggleWorkspaceSidebar, cx)), + ) + .child( + h_flex() + .pt_1() + .gap_2() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .justify_between() + .child(Label::new("Focus Sidebar")) + .child(KeyBinding::for_action(&FocusWorkspaceSidebar, cx)), + ) + .into_any_element() + })) + .on_click(|_, window, cx| { + if let Some(multi_workspace) = window.root::().flatten() { + multi_workspace.update(cx, |multi_workspace, cx| { + multi_workspace.close_sidebar(window, cx); + }); + } + }) }) } -} -impl Sidebar { + fn render_sidebar_bottom_bar(&mut self, cx: &mut Context) -> impl IntoElement { + let on_right = self.side(cx) == SidebarSide::Right; + let is_archive = matches!(self.view, SidebarView::Archive(..)); + let action_buttons = h_flex() + .gap_1() + .child( + IconButton::new("archive", IconName::Archive) + .icon_size(IconSize::Small) + .toggle_state(is_archive) + .tooltip(move |_, cx| { + Tooltip::for_action("Toggle Archived Threads", &ToggleArchive, cx) + }) + .on_click(cx.listener(|this, _, window, cx| { + this.toggle_archive(&ToggleArchive, window, cx); + })), + ) + .child(self.render_recent_projects_button(cx)); + let border_color = cx.theme().colors().border; + let toggle_button = self.render_sidebar_toggle_button(cx); + + let bar = h_flex() + .p_1() + .gap_1() + .justify_between() + .border_t_1() + .border_color(border_color); + + if on_right { + bar.child(action_buttons).child(toggle_button) + } else { + bar.child(toggle_button).child(action_buttons) + } + } + fn toggle_archive(&mut self, _: &ToggleArchive, window: &mut Window, cx: &mut Context) { match &self.view { SidebarView::ThreadList => self.show_archive(window, cx), @@ -2919,31 +3306,34 @@ impl Sidebar { }) else { return; }; - let Some(agent_panel) = active_workspace.read(cx).panel::(cx) else { return; }; + let Some(agent_registry_store) = AgentRegistryStore::try_global(cx) else { + return; + }; - let thread_store = agent_panel.read(cx).thread_store().clone(); - let fs = active_workspace.read(cx).project().read(cx).fs().clone(); - let agent_connection_store = agent_panel.read(cx).connection_store().clone(); let agent_server_store = active_workspace .read(cx) .project() .read(cx) .agent_server_store() - .clone(); + .downgrade(); + + let agent_connection_store = agent_panel.read(cx).connection_store().downgrade(); let archive_view = cx.new(|cx| { ThreadsArchiveView::new( - agent_connection_store, - agent_server_store, - thread_store, - fs, + agent_connection_store.clone(), + agent_server_store.clone(), + agent_registry_store.downgrade(), + active_workspace.downgrade(), + self.multi_workspace.clone(), window, cx, ) }); + let subscription = cx.subscribe_in( &archive_view, window, @@ -2951,12 +3341,9 @@ impl Sidebar { ThreadsArchiveViewEvent::Close => { this.show_thread_list(window, cx); } - ThreadsArchiveViewEvent::Unarchive { - agent, - session_info, - } => { + ThreadsArchiveViewEvent::Unarchive { thread } => { this.show_thread_list(window, cx); - this.activate_archived_thread(agent.clone(), session_info.clone(), window, cx); + this.activate_archived_thread(thread.clone(), window, cx); } }, ); @@ -2994,10 +3381,23 @@ impl WorkspaceSidebar for Sidebar { matches!(self.view, SidebarView::ThreadList) } + fn side(&self, cx: &App) -> SidebarSide { + AgentSettings::get_global(cx).sidebar_side() + } + fn prepare_for_focus(&mut self, _window: &mut Window, cx: &mut Context) { self.selection = None; cx.notify(); } + + fn toggle_thread_switcher( + &mut self, + select_last: bool, + window: &mut Window, + cx: &mut Context, + ) { + self.toggle_thread_switcher_impl(select_last, window, cx); + } } impl Focusable for Sidebar { @@ -3009,7 +3409,7 @@ impl Focusable for Sidebar { impl Render for Sidebar { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let _titlebar_height = ui::utils::platform_title_bar_height(window); - let ui_font = theme::setup_ui_font(window, cx); + let ui_font = theme_settings::setup_ui_font(window, cx); let sticky_header = self.render_sticky_header(window, cx); let color = cx.theme().colors(); @@ -3022,7 +3422,7 @@ impl Render for Sidebar { v_flex() .id("workspace-sidebar") - .key_context("ThreadsSidebar") + .key_context(self.dispatch_context(window, cx)) .track_focus(&self.focus_handle) .on_action(cx.listener(Self::select_next)) .on_action(cx.listener(Self::select_previous)) @@ -3041,6 +3441,7 @@ impl Render for Sidebar { .on_action(cx.listener(Self::new_thread_in_group)) .on_action(cx.listener(Self::toggle_archive)) .on_action(cx.listener(Self::focus_sidebar_filter)) + .on_action(cx.listener(Self::on_toggle_thread_switcher)) .on_action(cx.listener(|this, _: &OpenRecent, window, cx| { this.recent_projects_popover_handle.toggle(window, cx); })) @@ -3048,7 +3449,8 @@ impl Render for Sidebar { .h_full() .w(self.width) .bg(bg) - .border_r_1() + .when(self.side(cx) == SidebarSide::Left, |el| el.border_r_1()) + .when(self.side(cx) == SidebarSide::Right, |el| el.border_l_1()) .border_color(color.border) .map(|this| match &self.view { SidebarView::ThreadList => this @@ -3080,3683 +3482,254 @@ impl Render for Sidebar { }), SidebarView::Archive(archive_view) => this.child(archive_view.clone()), }) - .child( - h_flex() - .p_1() - .gap_1() - .justify_between() - .border_t_1() - .border_color(cx.theme().colors().border) - .child(self.render_sidebar_toggle_button(cx)) - .child( - h_flex() - .gap_1() - .child(self.render_recent_projects_button(cx)) - .child( - IconButton::new("archive", IconName::Archive) - .icon_size(IconSize::Small) - .toggle_state(matches!(self.view, SidebarView::Archive(..))) - .tooltip(move |_, cx| { - Tooltip::for_action( - "Toggle Archived Threads", - &ToggleArchive, - cx, - ) - }) - .on_click(cx.listener(|this, _, window, cx| { - this.toggle_archive(&ToggleArchive, window, cx); - })), - ), - ), - ) + .child(self.render_sidebar_bottom_bar(cx)) } } -#[cfg(test)] -mod tests { - use super::*; - use acp_thread::StubAgentConnection; - use agent::ThreadStore; - use agent_ui::test_support::{active_session_id, open_thread_with_connection, send_message}; - use assistant_text_thread::TextThreadStore; - use chrono::DateTime; - use feature_flags::FeatureFlagAppExt as _; - use fs::FakeFs; - use gpui::TestAppContext; - use pretty_assertions::assert_eq; - use settings::SettingsStore; - use std::{path::PathBuf, sync::Arc}; - use util::path_list::PathList; - - fn init_test(cx: &mut TestAppContext) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); - editor::init(cx); - cx.update_flags(false, vec!["agent-v2".into()]); - ThreadStore::init_global(cx); - SidebarThreadMetadataStore::init_global(cx); - language_model::LanguageModelRegistry::test(cx); - prompt_store::init(cx); - }); - } - - fn has_thread_entry(sidebar: &Sidebar, session_id: &acp::SessionId) -> bool { - sidebar.contents.entries.iter().any(|entry| { - matches!(entry, ListEntry::Thread(t) if &t.session_info.session_id == session_id) - }) - } - - async fn init_test_project( - worktree_path: &str, - cx: &mut TestAppContext, - ) -> Entity { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree(worktree_path, serde_json::json!({ "src": {} })) - .await; - cx.update(|cx| ::set_global(fs.clone(), cx)); - project::Project::test(fs, [worktree_path.as_ref()], cx).await - } +fn all_thread_infos_for_workspace( + workspace: &Entity, + cx: &App, +) -> impl Iterator { + let Some(agent_panel) = workspace.read(cx).panel::(cx) else { + return None.into_iter().flatten(); + }; + let agent_panel = agent_panel.read(cx); + + let threads = agent_panel + .parent_threads(cx) + .into_iter() + .map(|thread_view| { + let thread_view_ref = thread_view.read(cx); + let thread = thread_view_ref.thread.read(cx); + + let icon = thread_view_ref.agent_icon; + let icon_from_external_svg = thread_view_ref.agent_icon_from_external_svg.clone(); + let title = thread + .title() + .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into()); + let is_native = thread_view_ref.as_native_thread(cx).is_some(); + let is_title_generating = is_native && thread.has_provisional_title(); + let session_id = thread.session_id().clone(); + let is_background = agent_panel.is_background_thread(&session_id); + + let status = if thread.is_waiting_for_confirmation() { + AgentThreadStatus::WaitingForConfirmation + } else if thread.had_error() { + AgentThreadStatus::Error + } else { + match thread.status() { + ThreadStatus::Generating => AgentThreadStatus::Running, + ThreadStatus::Idle => AgentThreadStatus::Completed, + } + }; - fn setup_sidebar( - multi_workspace: &Entity, - cx: &mut gpui::VisualTestContext, - ) -> Entity { - let multi_workspace = multi_workspace.clone(); - let sidebar = - cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx))); - multi_workspace.update(cx, |mw, cx| { - mw.register_sidebar(sidebar.clone(), cx); + let diff_stats = thread.action_log().read(cx).diff_stats(cx); + + ActiveThreadInfo { + session_id, + title, + status, + icon, + icon_from_external_svg, + is_background, + is_title_generating, + diff_stats, + } }); - cx.run_until_parked(); - sidebar - } - async fn save_n_test_threads( - count: u32, - path_list: &PathList, - cx: &mut gpui::VisualTestContext, - ) { - for i in 0..count { - save_thread_metadata( - acp::SessionId::new(Arc::from(format!("thread-{}", i))), - format!("Thread {}", i + 1).into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(), - path_list.clone(), - cx, - ) - .await; - } - cx.run_until_parked(); - } + Some(threads).into_iter().flatten() +} - async fn save_test_thread_metadata( - session_id: &acp::SessionId, - path_list: PathList, - cx: &mut TestAppContext, - ) { - save_thread_metadata( - session_id.clone(), - "Test".into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), - path_list, - cx, +pub fn dump_workspace_info( + workspace: &mut Workspace, + _: &DumpWorkspaceInfo, + window: &mut gpui::Window, + cx: &mut gpui::Context, +) { + use std::fmt::Write; + + let mut output = String::new(); + let this_entity = cx.entity(); + + let multi_workspace = workspace.multi_workspace().and_then(|weak| weak.upgrade()); + let workspaces: Vec> = match &multi_workspace { + Some(mw) => mw.read(cx).workspaces().to_vec(), + None => vec![this_entity.clone()], + }; + let active_index = multi_workspace + .as_ref() + .map(|mw| mw.read(cx).active_workspace_index()); + + writeln!(output, "MultiWorkspace: {} workspace(s)", workspaces.len()).ok(); + if let Some(index) = active_index { + writeln!(output, "Active workspace index: {index}").ok(); + } + writeln!(output).ok(); + + for (index, ws) in workspaces.iter().enumerate() { + let is_active = active_index == Some(index); + writeln!( + output, + "--- Workspace {index}{} ---", + if is_active { " (active)" } else { "" } ) - .await; - } + .ok(); - async fn save_named_thread_metadata( - session_id: &str, - title: &str, - path_list: &PathList, - cx: &mut gpui::VisualTestContext, - ) { - save_thread_metadata( - acp::SessionId::new(Arc::from(session_id)), - SharedString::from(title.to_string()), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), - path_list.clone(), - cx, - ) - .await; - cx.run_until_parked(); + // The action handler is already inside an update on `this_entity`, + // so we must avoid a nested read/update on that same entity. + if *ws == this_entity { + dump_single_workspace(workspace, &mut output, cx); + } else { + ws.read_with(cx, |ws, cx| { + dump_single_workspace(ws, &mut output, cx); + }); + } } - async fn save_thread_metadata( - session_id: acp::SessionId, - title: SharedString, - updated_at: DateTime, - path_list: PathList, - cx: &mut TestAppContext, - ) { - let metadata = ThreadMetadata { - session_id, - agent_id: None, - title, - updated_at, - created_at: None, - folder_paths: path_list, - }; - let task = cx.update(|cx| { - SidebarThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx)) + let project = workspace.project().clone(); + cx.spawn_in(window, async move |_this, cx| { + let buffer = project + .update(cx, |project, cx| project.create_buffer(None, false, cx)) + .await?; + + buffer.update(cx, |buffer, cx| { + buffer.set_text(output, cx); }); - task.await.unwrap(); - } - fn open_and_focus_sidebar(sidebar: &Entity, cx: &mut gpui::VisualTestContext) { - let multi_workspace = sidebar.read_with(cx, |s, _| s.multi_workspace.upgrade()); - if let Some(multi_workspace) = multi_workspace { - multi_workspace.update_in(cx, |mw, window, cx| { - if !mw.sidebar_open() { - mw.toggle_sidebar(window, cx); - } - }); - } - cx.run_until_parked(); - sidebar.update_in(cx, |_, window, cx| { - cx.focus_self(window); + let buffer = cx.new(|cx| { + editor::MultiBuffer::singleton(buffer, cx).with_title("Workspace Info".into()) }); - cx.run_until_parked(); - } - fn visible_entries_as_strings( - sidebar: &Entity, - cx: &mut gpui::VisualTestContext, - ) -> Vec { - sidebar.read_with(cx, |sidebar, _cx| { - sidebar - .contents - .entries - .iter() - .enumerate() - .map(|(ix, entry)| { - let selected = if sidebar.selection == Some(ix) { - " <== selected" - } else { - "" - }; - match entry { - ListEntry::ProjectHeader { - label, - path_list, - highlight_positions: _, - .. - } => { - let icon = if sidebar.collapsed_groups.contains(path_list) { - ">" - } else { - "v" - }; - format!("{} [{}]{}", icon, label, selected) - } - ListEntry::Thread(thread) => { - let title = thread - .session_info - .title - .as_ref() - .map(|s| s.as_ref()) - .unwrap_or("Untitled"); - let active = if thread.is_live { " *" } else { "" }; - let status_str = match thread.status { - AgentThreadStatus::Running => " (running)", - AgentThreadStatus::Error => " (error)", - AgentThreadStatus::WaitingForConfirmation => " (waiting)", - _ => "", - }; - let notified = if sidebar - .contents - .is_thread_notified(&thread.session_info.session_id) - { - " (!)" - } else { - "" - }; - let worktree = thread - .worktree_name - .as_ref() - .map(|name| format!(" {{{}}}", name)) - .unwrap_or_default(); - format!( - " {}{}{}{}{}{}", - title, worktree, active, status_str, notified, selected - ) - } - ListEntry::ViewMore { - is_fully_expanded, .. - } => { - if *is_fully_expanded { - format!(" - Collapse{}", selected) - } else { - format!(" + View More{}", selected) - } - } - ListEntry::NewThread { .. } => { - format!(" [+ New Thread]{}", selected) - } - } - }) - .collect() + _this.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane( + Box::new(cx.new(|cx| { + let mut editor = + editor::Editor::for_multibuffer(buffer, Some(project.clone()), window, cx); + editor.set_read_only(true); + editor.set_should_serialize(false, cx); + editor.set_breadcrumb_header("Workspace Info".into()); + editor + })), + None, + true, + window, + cx, + ); }) - } + }) + .detach_and_log_err(cx); +} - #[test] - fn test_clean_mention_links() { - // Simple mention link - assert_eq!( - Sidebar::clean_mention_links("check [@Button.tsx](file:///path/to/Button.tsx)"), - "check @Button.tsx" - ); +fn dump_single_workspace(workspace: &Workspace, output: &mut String, cx: &gpui::App) { + use std::fmt::Write; - // Multiple mention links - assert_eq!( - Sidebar::clean_mention_links( - "look at [@foo.rs](file:///foo.rs) and [@bar.rs](file:///bar.rs)" - ), - "look at @foo.rs and @bar.rs" - ); - - // No mention links — passthrough - assert_eq!( - Sidebar::clean_mention_links("plain text with no mentions"), - "plain text with no mentions" - ); - - // Incomplete link syntax — preserved as-is - assert_eq!( - Sidebar::clean_mention_links("broken [@mention without closing"), - "broken [@mention without closing" - ); - - // Regular markdown link (no @) — not touched - assert_eq!( - Sidebar::clean_mention_links("see [docs](https://example.com)"), - "see [docs](https://example.com)" - ); - - // Empty input - assert_eq!(Sidebar::clean_mention_links(""), ""); - } - - #[gpui::test] - async fn test_entities_released_on_window_close(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let weak_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().downgrade()); - let weak_sidebar = sidebar.downgrade(); - let weak_multi_workspace = multi_workspace.downgrade(); - - drop(sidebar); - drop(multi_workspace); - cx.update(|window, _cx| window.remove_window()); - cx.run_until_parked(); - - weak_multi_workspace.assert_released(); - weak_sidebar.assert_released(); - weak_workspace.assert_released(); - } - - #[gpui::test] - async fn test_single_workspace_no_threads(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " [+ New Thread]"] - ); - } - - #[gpui::test] - async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - save_thread_metadata( - acp::SessionId::new(Arc::from("thread-1")), - "Fix crash in project panel".into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(), - path_list.clone(), - cx, - ) - .await; - - save_thread_metadata( - acp::SessionId::new(Arc::from("thread-2")), - "Add inline diff view".into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), - path_list.clone(), - cx, - ) - .await; - cx.run_until_parked(); - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " Fix crash in project panel", - " Add inline diff view", - ] - ); - } - - #[gpui::test] - async fn test_workspace_lifecycle(cx: &mut TestAppContext) { - let project = init_test_project("/project-a", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - // Single workspace with a thread - let path_list = PathList::new(&[std::path::PathBuf::from("/project-a")]); - - save_thread_metadata( - acp::SessionId::new(Arc::from("thread-a1")), - "Thread A1".into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), - path_list.clone(), - cx, - ) - .await; - cx.run_until_parked(); - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " Thread A1"] - ); - - // Add a second workspace - multi_workspace.update_in(cx, |mw, window, cx| { - mw.create_test_workspace(window, cx).detach(); - }); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " Thread A1",] - ); - - // Remove the second workspace - multi_workspace.update_in(cx, |mw, window, cx| { - mw.remove_workspace(1, window, cx); - }); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " Thread A1"] - ); - } - - #[gpui::test] - async fn test_view_more_pagination(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(12, &path_list, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " Thread 12", - " Thread 11", - " Thread 10", - " Thread 9", - " Thread 8", - " + View More", - ] - ); - } - - #[gpui::test] - async fn test_view_more_batched_expansion(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - // Create 17 threads: initially shows 5, then 10, then 15, then all 17 with Collapse - save_n_test_threads(17, &path_list, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // Initially shows 5 threads + View More - let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 7); // header + 5 threads + View More - assert!(entries.iter().any(|e| e.contains("View More"))); - - // Focus and navigate to View More, then confirm to expand by one batch - open_and_focus_sidebar(&sidebar, cx); - for _ in 0..7 { - cx.dispatch_action(SelectNext); - } - cx.dispatch_action(Confirm); - cx.run_until_parked(); - - // Now shows 10 threads + View More - let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 12); // header + 10 threads + View More - assert!(entries.iter().any(|e| e.contains("View More"))); - - // Expand again by one batch - sidebar.update_in(cx, |s, _window, cx| { - let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0); - s.expanded_groups.insert(path_list.clone(), current + 1); - s.update_entries(cx); - }); - cx.run_until_parked(); - - // Now shows 15 threads + View More - let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 17); // header + 15 threads + View More - assert!(entries.iter().any(|e| e.contains("View More"))); - - // Expand one more time - should show all 17 threads with Collapse button - sidebar.update_in(cx, |s, _window, cx| { - let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0); - s.expanded_groups.insert(path_list.clone(), current + 1); - s.update_entries(cx); - }); - cx.run_until_parked(); - - // All 17 threads shown with Collapse button - let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 19); // header + 17 threads + Collapse - assert!(!entries.iter().any(|e| e.contains("View More"))); - assert!(entries.iter().any(|e| e.contains("Collapse"))); - - // Click collapse - should go back to showing 5 threads - sidebar.update_in(cx, |s, _window, cx| { - s.expanded_groups.remove(&path_list); - s.update_entries(cx); - }); - cx.run_until_parked(); - - // Back to initial state: 5 threads + View More - let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 7); // header + 5 threads + View More - assert!(entries.iter().any(|e| e.contains("View More"))); - } - - #[gpui::test] - async fn test_collapse_and_expand_group(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(1, &path_list, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Thread 1"] - ); - - // Collapse - sidebar.update_in(cx, |s, window, cx| { - s.toggle_collapse(&path_list, window, cx); - }); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["> [my-project]"] - ); - - // Expand - sidebar.update_in(cx, |s, window, cx| { - s.toggle_collapse(&path_list, window, cx); - }); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Thread 1"] - ); - } - - #[gpui::test] - async fn test_visible_entries_as_strings(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - let expanded_path = PathList::new(&[std::path::PathBuf::from("/expanded")]); - let collapsed_path = PathList::new(&[std::path::PathBuf::from("/collapsed")]); - - sidebar.update_in(cx, |s, _window, _cx| { - s.collapsed_groups.insert(collapsed_path.clone()); - s.contents - .notified_threads - .insert(acp::SessionId::new(Arc::from("t-5"))); - s.contents.entries = vec![ - // Expanded project header - ListEntry::ProjectHeader { - path_list: expanded_path.clone(), - label: "expanded-project".into(), - workspace: workspace.clone(), - highlight_positions: Vec::new(), - has_running_threads: false, - waiting_thread_count: 0, - }, - ListEntry::Thread(ThreadEntry { - agent: Agent::NativeAgent, - session_info: acp_thread::AgentSessionInfo { - session_id: acp::SessionId::new(Arc::from("t-1")), - work_dirs: None, - title: Some("Completed thread".into()), - updated_at: Some(Utc::now()), - created_at: Some(Utc::now()), - meta: None, - }, - icon: IconName::ZedAgent, - icon_from_external_svg: None, - status: AgentThreadStatus::Completed, - workspace: ThreadEntryWorkspace::Open(workspace.clone()), - is_live: false, - is_background: false, - is_title_generating: false, - highlight_positions: Vec::new(), - worktree_name: None, - worktree_full_path: None, - worktree_highlight_positions: Vec::new(), - diff_stats: DiffStats::default(), - }), - // Active thread with Running status - ListEntry::Thread(ThreadEntry { - agent: Agent::NativeAgent, - session_info: acp_thread::AgentSessionInfo { - session_id: acp::SessionId::new(Arc::from("t-2")), - work_dirs: None, - title: Some("Running thread".into()), - updated_at: Some(Utc::now()), - created_at: Some(Utc::now()), - meta: None, - }, - icon: IconName::ZedAgent, - icon_from_external_svg: None, - status: AgentThreadStatus::Running, - workspace: ThreadEntryWorkspace::Open(workspace.clone()), - is_live: true, - is_background: false, - is_title_generating: false, - highlight_positions: Vec::new(), - worktree_name: None, - worktree_full_path: None, - worktree_highlight_positions: Vec::new(), - diff_stats: DiffStats::default(), - }), - // Active thread with Error status - ListEntry::Thread(ThreadEntry { - agent: Agent::NativeAgent, - session_info: acp_thread::AgentSessionInfo { - session_id: acp::SessionId::new(Arc::from("t-3")), - work_dirs: None, - title: Some("Error thread".into()), - updated_at: Some(Utc::now()), - created_at: Some(Utc::now()), - meta: None, - }, - icon: IconName::ZedAgent, - icon_from_external_svg: None, - status: AgentThreadStatus::Error, - workspace: ThreadEntryWorkspace::Open(workspace.clone()), - is_live: true, - is_background: false, - is_title_generating: false, - highlight_positions: Vec::new(), - worktree_name: None, - worktree_full_path: None, - worktree_highlight_positions: Vec::new(), - diff_stats: DiffStats::default(), - }), - // Thread with WaitingForConfirmation status, not active - ListEntry::Thread(ThreadEntry { - agent: Agent::NativeAgent, - session_info: acp_thread::AgentSessionInfo { - session_id: acp::SessionId::new(Arc::from("t-4")), - work_dirs: None, - title: Some("Waiting thread".into()), - updated_at: Some(Utc::now()), - created_at: Some(Utc::now()), - meta: None, - }, - icon: IconName::ZedAgent, - icon_from_external_svg: None, - status: AgentThreadStatus::WaitingForConfirmation, - workspace: ThreadEntryWorkspace::Open(workspace.clone()), - is_live: false, - is_background: false, - is_title_generating: false, - highlight_positions: Vec::new(), - worktree_name: None, - worktree_full_path: None, - worktree_highlight_positions: Vec::new(), - diff_stats: DiffStats::default(), - }), - // Background thread that completed (should show notification) - ListEntry::Thread(ThreadEntry { - agent: Agent::NativeAgent, - session_info: acp_thread::AgentSessionInfo { - session_id: acp::SessionId::new(Arc::from("t-5")), - work_dirs: None, - title: Some("Notified thread".into()), - updated_at: Some(Utc::now()), - created_at: Some(Utc::now()), - meta: None, - }, - icon: IconName::ZedAgent, - icon_from_external_svg: None, - status: AgentThreadStatus::Completed, - workspace: ThreadEntryWorkspace::Open(workspace.clone()), - is_live: true, - is_background: true, - is_title_generating: false, - highlight_positions: Vec::new(), - worktree_name: None, - worktree_full_path: None, - worktree_highlight_positions: Vec::new(), - diff_stats: DiffStats::default(), - }), - // View More entry - ListEntry::ViewMore { - path_list: expanded_path.clone(), - is_fully_expanded: false, - }, - // Collapsed project header - ListEntry::ProjectHeader { - path_list: collapsed_path.clone(), - label: "collapsed-project".into(), - workspace: workspace.clone(), - highlight_positions: Vec::new(), - has_running_threads: false, - waiting_thread_count: 0, - }, - ]; + let workspace_db_id = workspace.database_id(); + match workspace_db_id { + Some(id) => writeln!(output, "Workspace DB ID: {id:?}").ok(), + None => writeln!(output, "Workspace DB ID: (none)").ok(), + }; - // Select the Running thread (index 2) - s.selection = Some(2); - }); + let project = workspace.project().read(cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [expanded-project]", - " Completed thread", - " Running thread * (running) <== selected", - " Error thread * (error)", - " Waiting thread (waiting)", - " Notified thread * (!)", - " + View More", - "> [collapsed-project]", - ] - ); + let repos: Vec<_> = project + .repositories(cx) + .values() + .map(|repo| repo.read(cx).snapshot()) + .collect(); - // Move selection to the collapsed header - sidebar.update_in(cx, |s, _window, _cx| { - s.selection = Some(7); - }); + writeln!(output, "Worktrees:").ok(); + for worktree in project.worktrees(cx) { + let worktree = worktree.read(cx); + let abs_path = worktree.abs_path(); + let visible = worktree.is_visible(); - assert_eq!( - visible_entries_as_strings(&sidebar, cx).last().cloned(), - Some("> [collapsed-project] <== selected".to_string()), - ); + let repo_info = repos + .iter() + .find(|snapshot| abs_path.starts_with(&*snapshot.work_directory_abs_path)); - // Clear selection - sidebar.update_in(cx, |s, _window, _cx| { - s.selection = None; - }); + let is_linked = repo_info.map(|s| s.is_linked_worktree()).unwrap_or(false); + let original_repo_path = repo_info.map(|s| &s.original_repo_abs_path); + let branch = repo_info.and_then(|s| s.branch.as_ref().map(|b| b.ref_name.clone())); - // No entry should have the selected marker - let entries = visible_entries_as_strings(&sidebar, cx); - for entry in &entries { - assert!( - !entry.contains("<== selected"), - "unexpected selection marker in: {}", - entry - ); + write!(output, " - {}", abs_path.display()).ok(); + if !visible { + write!(output, " (hidden)").ok(); } - } - - #[gpui::test] - async fn test_keyboard_select_next_and_previous(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(3, &path_list, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // Entries: [header, thread3, thread2, thread1] - // Focusing the sidebar does not set a selection; select_next/select_previous - // handle None gracefully by starting from the first or last entry. - open_and_focus_sidebar(&sidebar, cx); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); - - // First SelectNext from None starts at index 0 - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); - - // Move down through remaining entries - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); - - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2)); - - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3)); - - // At the end, wraps back to first entry - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); - - // Navigate back to the end - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2)); - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3)); - - // Move back up - cx.dispatch_action(SelectPrevious); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2)); - - cx.dispatch_action(SelectPrevious); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); - - cx.dispatch_action(SelectPrevious); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); - - // At the top, selection clears (focus returns to editor) - cx.dispatch_action(SelectPrevious); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); - } - - #[gpui::test] - async fn test_keyboard_select_first_and_last(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(3, &path_list, cx).await; - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - open_and_focus_sidebar(&sidebar, cx); - - // SelectLast jumps to the end - cx.dispatch_action(SelectLast); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3)); - - // SelectFirst jumps to the beginning - cx.dispatch_action(SelectFirst); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); - } - - #[gpui::test] - async fn test_keyboard_focus_in_does_not_set_selection(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - // Initially no selection - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); - - // Open the sidebar so it's rendered, then focus it to trigger focus_in. - // focus_in no longer sets a default selection. - open_and_focus_sidebar(&sidebar, cx); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); - - // Manually set a selection, blur, then refocus — selection should be preserved - sidebar.update_in(cx, |sidebar, _window, _cx| { - sidebar.selection = Some(0); - }); - - cx.update(|window, _cx| { - window.blur(); - }); - cx.run_until_parked(); - - sidebar.update_in(cx, |_, window, cx| { - cx.focus_self(window); - }); - cx.run_until_parked(); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); - } - - #[gpui::test] - async fn test_keyboard_confirm_on_project_header_toggles_collapse(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(1, &path_list, cx).await; - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Thread 1"] - ); - - // Focus the sidebar and select the header (index 0) - open_and_focus_sidebar(&sidebar, cx); - sidebar.update_in(cx, |sidebar, _window, _cx| { - sidebar.selection = Some(0); - }); - - // Confirm on project header collapses the group - cx.dispatch_action(Confirm); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["> [my-project] <== selected"] - ); - - // Confirm again expands the group - cx.dispatch_action(Confirm); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project] <== selected", " Thread 1",] - ); - } - - #[gpui::test] - async fn test_keyboard_confirm_on_view_more_expands(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(8, &path_list, cx).await; - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // Should show header + 5 threads + "View More" - let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 7); - assert!(entries.iter().any(|e| e.contains("View More"))); - - // Focus sidebar (selection starts at None), then navigate down to the "View More" entry (index 6) - open_and_focus_sidebar(&sidebar, cx); - for _ in 0..7 { - cx.dispatch_action(SelectNext); + if let Some(branch) = &branch { + write!(output, " [branch: {branch}]").ok(); } - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(6)); - - // Confirm on "View More" to expand - cx.dispatch_action(Confirm); - cx.run_until_parked(); - - // All 8 threads should now be visible with a "Collapse" button - let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 10); // header + 8 threads + Collapse button - assert!(!entries.iter().any(|e| e.contains("View More"))); - assert!(entries.iter().any(|e| e.contains("Collapse"))); - } - - #[gpui::test] - async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(1, &path_list, cx).await; - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Thread 1"] - ); - - // Focus sidebar and manually select the header (index 0). Press left to collapse. - open_and_focus_sidebar(&sidebar, cx); - sidebar.update_in(cx, |sidebar, _window, _cx| { - sidebar.selection = Some(0); - }); - - cx.dispatch_action(SelectParent); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["> [my-project] <== selected"] - ); - - // Press right to expand - cx.dispatch_action(SelectChild); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project] <== selected", " Thread 1",] - ); - - // Press right again on already-expanded header moves selection down - cx.dispatch_action(SelectChild); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); - } - - #[gpui::test] - async fn test_keyboard_collapse_from_child_selects_parent(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(1, &path_list, cx).await; - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // Focus sidebar (selection starts at None), then navigate down to the thread (child) - open_and_focus_sidebar(&sidebar, cx); - cx.dispatch_action(SelectNext); - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Thread 1 <== selected",] - ); - - // Pressing left on a child collapses the parent group and selects it - cx.dispatch_action(SelectParent); - cx.run_until_parked(); - - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["> [my-project] <== selected"] - ); - } - - #[gpui::test] - async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) { - let project = init_test_project("/empty-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - // An empty project has the header and a new thread button. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [empty-project]", " [+ New Thread]"] - ); - - // Focus sidebar — focus_in does not set a selection - open_and_focus_sidebar(&sidebar, cx); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); - - // First SelectNext from None starts at index 0 (header) - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); - - // SelectNext moves to the new thread button - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); - - // At the end, wraps back to first entry - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); - - // SelectPrevious from first entry clears selection (returns to editor) - cx.dispatch_action(SelectPrevious); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); - } - - #[gpui::test] - async fn test_selection_clamps_after_entry_removal(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(1, &path_list, cx).await; - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // Focus sidebar (selection starts at None), navigate down to the thread (index 1) - open_and_focus_sidebar(&sidebar, cx); - cx.dispatch_action(SelectNext); - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); - - // Collapse the group, which removes the thread from the list - cx.dispatch_action(SelectParent); - cx.run_until_parked(); - - // Selection should be clamped to the last valid index (0 = header) - let selection = sidebar.read_with(cx, |s, _| s.selection); - let entry_count = sidebar.read_with(cx, |s, _| s.contents.entries.len()); - assert!( - selection.unwrap_or(0) < entry_count, - "selection {} should be within bounds (entries: {})", - selection.unwrap_or(0), - entry_count, - ); - } - - async fn init_test_project_with_agent_panel( - worktree_path: &str, - cx: &mut TestAppContext, - ) -> Entity { - agent_ui::test_support::init_test(cx); - cx.update(|cx| { - cx.update_flags(false, vec!["agent-v2".into()]); - ThreadStore::init_global(cx); - SidebarThreadMetadataStore::init_global(cx); - language_model::LanguageModelRegistry::test(cx); - prompt_store::init(cx); - }); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree(worktree_path, serde_json::json!({ "src": {} })) - .await; - cx.update(|cx| ::set_global(fs.clone(), cx)); - project::Project::test(fs, [worktree_path.as_ref()], cx).await - } - - fn add_agent_panel( - workspace: &Entity, - project: &Entity, - cx: &mut gpui::VisualTestContext, - ) -> Entity { - workspace.update_in(cx, |workspace, window, cx| { - let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); - let panel = cx.new(|cx| AgentPanel::test_new(workspace, text_thread_store, window, cx)); - workspace.add_panel(panel.clone(), window, cx); - panel - }) - } - - fn setup_sidebar_with_agent_panel( - multi_workspace: &Entity, - project: &Entity, - cx: &mut gpui::VisualTestContext, - ) -> (Entity, Entity) { - let sidebar = setup_sidebar(multi_workspace, cx); - let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone()); - let panel = add_agent_panel(&workspace, project, cx); - (sidebar, panel) - } - - #[gpui::test] - async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) { - let project = init_test_project_with_agent_panel("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - // Open thread A and keep it generating. - let connection = StubAgentConnection::new(); - open_thread_with_connection(&panel, connection.clone(), cx); - send_message(&panel, cx); - - let session_id_a = active_session_id(&panel, cx); - save_test_thread_metadata(&session_id_a, path_list.clone(), cx).await; - - cx.update(|_, cx| { - connection.send_update( - session_id_a.clone(), - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())), - cx, - ); - }); - cx.run_until_parked(); - - // Open thread B (idle, default response) — thread A goes to background. - connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk::new("Done".into()), - )]); - open_thread_with_connection(&panel, connection, cx); - send_message(&panel, cx); - - let session_id_b = active_session_id(&panel, cx); - save_test_thread_metadata(&session_id_b, path_list.clone(), cx).await; - - cx.run_until_parked(); - - let mut entries = visible_entries_as_strings(&sidebar, cx); - entries[1..].sort(); - assert_eq!( - entries, - vec!["v [my-project]", " Hello *", " Hello * (running)",] - ); - } - - #[gpui::test] - async fn test_background_thread_completion_triggers_notification(cx: &mut TestAppContext) { - let project_a = init_test_project_with_agent_panel("/project-a", cx).await; - let (multi_workspace, cx) = cx - .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); - let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, &project_a, cx); - - let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); - - // Open thread on workspace A and keep it generating. - let connection_a = StubAgentConnection::new(); - open_thread_with_connection(&panel_a, connection_a.clone(), cx); - send_message(&panel_a, cx); - - let session_id_a = active_session_id(&panel_a, cx); - save_test_thread_metadata(&session_id_a, path_list_a.clone(), cx).await; - - cx.update(|_, cx| { - connection_a.send_update( - session_id_a.clone(), - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())), - cx, - ); - }); - cx.run_until_parked(); - - // Add a second workspace and activate it (making workspace A the background). - let fs = cx.update(|_, cx| ::global(cx)); - let project_b = project::Project::test(fs, [], cx).await; - multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(project_b, window, cx); - }); - cx.run_until_parked(); - - // Thread A is still running; no notification yet. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " Hello * (running)",] - ); - - // Complete thread A's turn (transition Running → Completed). - connection_a.end_turn(session_id_a.clone(), acp::StopReason::EndTurn); - cx.run_until_parked(); - - // The completed background thread shows a notification indicator. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " Hello * (!)",] - ); - } - - fn type_in_search(sidebar: &Entity, query: &str, cx: &mut gpui::VisualTestContext) { - sidebar.update_in(cx, |sidebar, window, cx| { - window.focus(&sidebar.filter_editor.focus_handle(cx), cx); - sidebar.filter_editor.update(cx, |editor, cx| { - editor.set_text(query, window, cx); - }); - }); - cx.run_until_parked(); - } - - #[gpui::test] - async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - for (id, title, hour) in [ - ("t-1", "Fix crash in project panel", 3), - ("t-2", "Add inline diff view", 2), - ("t-3", "Refactor settings module", 1), - ] { - save_thread_metadata( - acp::SessionId::new(Arc::from(id)), - title.into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - path_list.clone(), - cx, - ) - .await; + if is_linked { + if let Some(original) = original_repo_path { + write!(output, " [linked worktree -> {}]", original.display()).ok(); + } else { + write!(output, " [linked worktree]").ok(); + } } - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " Fix crash in project panel", - " Add inline diff view", - " Refactor settings module", - ] - ); - - // User types "diff" in the search box — only the matching thread remains, - // with its workspace header preserved for context. - type_in_search(&sidebar, "diff", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Add inline diff view <== selected",] - ); - - // User changes query to something with no matches — list is empty. - type_in_search(&sidebar, "nonexistent", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - Vec::::new() - ); + writeln!(output).ok(); } - #[gpui::test] - async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) { - // Scenario: A user remembers a thread title but not the exact casing. - // Search should match case-insensitively so they can still find it. - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - save_thread_metadata( - acp::SessionId::new(Arc::from("thread-1")), - "Fix Crash In Project Panel".into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), - path_list.clone(), - cx, - ) - .await; - cx.run_until_parked(); - - // Lowercase query matches mixed-case title. - type_in_search(&sidebar, "fix crash", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " Fix Crash In Project Panel <== selected", - ] - ); - - // Uppercase query also matches the same title. - type_in_search(&sidebar, "FIX CRASH", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " Fix Crash In Project Panel <== selected", - ] - ); - } + if let Some(panel) = workspace.panel::(cx) { + let panel = panel.read(cx); - #[gpui::test] - async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContext) { - // Scenario: A user searches, finds what they need, then presses Escape - // to dismiss the filter and see the full list again. - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - for (id, title, hour) in [("t-1", "Alpha thread", 2), ("t-2", "Beta thread", 1)] { - save_thread_metadata( - acp::SessionId::new(Arc::from(id)), - title.into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - path_list.clone(), - cx, + let panel_workspace_id = panel.workspace_id(); + if panel_workspace_id != workspace_db_id { + writeln!( + output, + " \u{26a0} workspace ID mismatch! panel has {panel_workspace_id:?}, workspace has {workspace_db_id:?}" ) - .await; + .ok(); } - cx.run_until_parked(); - - // Confirm the full list is showing. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Alpha thread", " Beta thread",] - ); - - // User types a search query to filter down. - open_and_focus_sidebar(&sidebar, cx); - type_in_search(&sidebar, "alpha", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Alpha thread <== selected",] - ); - // User presses Escape — filter clears, full list is restored. - // The selection index (1) now points at the first thread entry. - cx.dispatch_action(Cancel); - cx.run_until_parked(); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " Alpha thread <== selected", - " Beta thread", - ] - ); - } - - #[gpui::test] - async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppContext) { - let project_a = init_test_project("/project-a", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); - - for (id, title, hour) in [ - ("a1", "Fix bug in sidebar", 2), - ("a2", "Add tests for editor", 1), - ] { - save_thread_metadata( - acp::SessionId::new(Arc::from(id)), - title.into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - path_list_a.clone(), - cx, - ) - .await; + if let Some(thread) = panel.active_agent_thread(cx) { + let thread = thread.read(cx); + let title = thread.title().unwrap_or_else(|| "(untitled)".into()); + let session_id = thread.session_id(); + let status = match thread.status() { + ThreadStatus::Idle => "idle", + ThreadStatus::Generating => "generating", + }; + let entry_count = thread.entries().len(); + write!(output, "Active thread: {title} (session: {session_id})").ok(); + write!(output, " [{status}, {entry_count} entries").ok(); + if thread.is_waiting_for_confirmation() { + write!(output, ", awaiting confirmation").ok(); + } + writeln!(output, "]").ok(); + } else { + writeln!(output, "Active thread: (none)").ok(); } - // Add a second workspace. - multi_workspace.update_in(cx, |mw, window, cx| { - mw.create_test_workspace(window, cx).detach(); - }); - cx.run_until_parked(); - - let path_list_b = PathList::new::(&[]); - - for (id, title, hour) in [ - ("b1", "Refactor sidebar layout", 3), - ("b2", "Fix typo in README", 1), - ] { - save_thread_metadata( - acp::SessionId::new(Arc::from(id)), - title.into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - path_list_b.clone(), - cx, + let background_threads = panel.background_threads(); + if !background_threads.is_empty() { + writeln!( + output, + "Background threads ({}): ", + background_threads.len() ) - .await; + .ok(); + for (session_id, conversation_view) in background_threads { + if let Some(thread_view) = conversation_view.read(cx).root_thread(cx) { + let thread = thread_view.read(cx).thread.read(cx); + let title = thread.title().unwrap_or_else(|| "(untitled)".into()); + let status = match thread.status() { + ThreadStatus::Idle => "idle", + ThreadStatus::Generating => "generating", + }; + let entry_count = thread.entries().len(); + write!(output, " - {title} (session: {session_id})").ok(); + write!(output, " [{status}, {entry_count} entries").ok(); + if thread.is_waiting_for_confirmation() { + write!(output, ", awaiting confirmation").ok(); + } + writeln!(output, "]").ok(); + } else { + writeln!(output, " - (not connected) (session: {session_id})").ok(); + } + } } - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [project-a]", - " Fix bug in sidebar", - " Add tests for editor", - ] - ); - - // "sidebar" matches a thread in each workspace — both headers stay visible. - type_in_search(&sidebar, "sidebar", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " Fix bug in sidebar <== selected",] - ); - - // "typo" only matches in the second workspace — the first header disappears. - type_in_search(&sidebar, "typo", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - Vec::::new() - ); - - // "project-a" matches the first workspace name — the header appears - // with all child threads included. - type_in_search(&sidebar, "project-a", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [project-a]", - " Fix bug in sidebar <== selected", - " Add tests for editor", - ] - ); + } else { + writeln!(output, "Agent panel: not loaded").ok(); } - #[gpui::test] - async fn test_search_matches_workspace_name(cx: &mut TestAppContext) { - let project_a = init_test_project("/alpha-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list_a = PathList::new(&[std::path::PathBuf::from("/alpha-project")]); - - for (id, title, hour) in [ - ("a1", "Fix bug in sidebar", 2), - ("a2", "Add tests for editor", 1), - ] { - save_thread_metadata( - acp::SessionId::new(Arc::from(id)), - title.into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - path_list_a.clone(), - cx, - ) - .await; - } - - // Add a second workspace. - multi_workspace.update_in(cx, |mw, window, cx| { - mw.create_test_workspace(window, cx).detach(); - }); - cx.run_until_parked(); - - let path_list_b = PathList::new::(&[]); - - for (id, title, hour) in [ - ("b1", "Refactor sidebar layout", 3), - ("b2", "Fix typo in README", 1), - ] { - save_thread_metadata( - acp::SessionId::new(Arc::from(id)), - title.into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - path_list_b.clone(), - cx, - ) - .await; - } - cx.run_until_parked(); - - // "alpha" matches the workspace name "alpha-project" but no thread titles. - // The workspace header should appear with all child threads included. - type_in_search(&sidebar, "alpha", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [alpha-project]", - " Fix bug in sidebar <== selected", - " Add tests for editor", - ] - ); - - // "sidebar" matches thread titles in both workspaces but not workspace names. - // Both headers appear with their matching threads. - type_in_search(&sidebar, "sidebar", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [alpha-project]", " Fix bug in sidebar <== selected",] - ); - - // "alpha sidebar" matches the workspace name "alpha-project" (fuzzy: a-l-p-h-a-s-i-d-e-b-a-r - // doesn't match) — but does not match either workspace name or any thread. - // Actually let's test something simpler: a query that matches both a workspace - // name AND some threads in that workspace. Matching threads should still appear. - type_in_search(&sidebar, "fix", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [alpha-project]", " Fix bug in sidebar <== selected",] - ); - - // A query that matches a workspace name AND a thread in that same workspace. - // Both the header (highlighted) and all child threads should appear. - type_in_search(&sidebar, "alpha", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [alpha-project]", - " Fix bug in sidebar <== selected", - " Add tests for editor", - ] - ); - - // Now search for something that matches only a workspace name when there - // are also threads with matching titles — the non-matching workspace's - // threads should still appear if their titles match. - type_in_search(&sidebar, "alp", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [alpha-project]", - " Fix bug in sidebar <== selected", - " Add tests for editor", - ] - ); - } - - #[gpui::test] - async fn test_search_finds_threads_hidden_behind_view_more(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - // Create 8 threads. The oldest one has a unique name and will be - // behind View More (only 5 shown by default). - for i in 0..8u32 { - let title = if i == 0 { - "Hidden gem thread".to_string() - } else { - format!("Thread {}", i + 1) - }; - save_thread_metadata( - acp::SessionId::new(Arc::from(format!("thread-{}", i))), - title.into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(), - path_list.clone(), - cx, - ) - .await; - } - cx.run_until_parked(); - - // Confirm the thread is not visible and View More is shown. - let entries = visible_entries_as_strings(&sidebar, cx); - assert!( - entries.iter().any(|e| e.contains("View More")), - "should have View More button" - ); - assert!( - !entries.iter().any(|e| e.contains("Hidden gem")), - "Hidden gem should be behind View More" - ); - - // User searches for the hidden thread — it appears, and View More is gone. - type_in_search(&sidebar, "hidden gem", cx); - let filtered = visible_entries_as_strings(&sidebar, cx); - assert_eq!( - filtered, - vec!["v [my-project]", " Hidden gem thread <== selected",] - ); - assert!( - !filtered.iter().any(|e| e.contains("View More")), - "View More should not appear when filtering" - ); - } - - #[gpui::test] - async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - save_thread_metadata( - acp::SessionId::new(Arc::from("thread-1")), - "Important thread".into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), - path_list.clone(), - cx, - ) - .await; - cx.run_until_parked(); - - // User focuses the sidebar and collapses the group using keyboard: - // manually select the header, then press SelectParent to collapse. - open_and_focus_sidebar(&sidebar, cx); - sidebar.update_in(cx, |sidebar, _window, _cx| { - sidebar.selection = Some(0); - }); - cx.dispatch_action(SelectParent); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["> [my-project] <== selected"] - ); - - // User types a search — the thread appears even though its group is collapsed. - type_in_search(&sidebar, "important", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["> [my-project]", " Important thread <== selected",] - ); - } - - #[gpui::test] - async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - for (id, title, hour) in [ - ("t-1", "Fix crash in panel", 3), - ("t-2", "Fix lint warnings", 2), - ("t-3", "Add new feature", 1), - ] { - save_thread_metadata( - acp::SessionId::new(Arc::from(id)), - title.into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - path_list.clone(), - cx, - ) - .await; - } - cx.run_until_parked(); - - open_and_focus_sidebar(&sidebar, cx); - - // User types "fix" — two threads match. - type_in_search(&sidebar, "fix", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " Fix crash in panel <== selected", - " Fix lint warnings", - ] - ); - - // Selection starts on the first matching thread. User presses - // SelectNext to move to the second match. - cx.dispatch_action(SelectNext); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " Fix crash in panel", - " Fix lint warnings <== selected", - ] - ); - - // User can also jump back with SelectPrevious. - cx.dispatch_action(SelectPrevious); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " Fix crash in panel <== selected", - " Fix lint warnings", - ] - ); - } - - #[gpui::test] - async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - multi_workspace.update_in(cx, |mw, window, cx| { - mw.create_test_workspace(window, cx).detach(); - }); - cx.run_until_parked(); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - save_thread_metadata( - acp::SessionId::new(Arc::from("hist-1")), - "Historical Thread".into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(), - path_list.clone(), - cx, - ) - .await; - cx.run_until_parked(); - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Historical Thread",] - ); - - // Switch to workspace 1 so we can verify the confirm switches back. - multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate_index(1, window, cx); - }); - cx.run_until_parked(); - assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), - 1 - ); - - // Confirm on the historical (non-live) thread at index 1. - // Before a previous fix, the workspace field was Option and - // historical threads had None, so activate_thread early-returned - // without switching the workspace. - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.selection = Some(1); - sidebar.confirm(&Confirm, window, cx); - }); - cx.run_until_parked(); - - assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), - 0 - ); - } - - #[gpui::test] - async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - save_thread_metadata( - acp::SessionId::new(Arc::from("t-1")), - "Thread A".into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), - path_list.clone(), - cx, - ) - .await; - - save_thread_metadata( - acp::SessionId::new(Arc::from("t-2")), - "Thread B".into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), - path_list.clone(), - cx, - ) - .await; - - cx.run_until_parked(); - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Thread A", " Thread B",] - ); - - // Keyboard confirm preserves selection. - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.selection = Some(1); - sidebar.confirm(&Confirm, window, cx); - }); - assert_eq!( - sidebar.read_with(cx, |sidebar, _| sidebar.selection), - Some(1) - ); - - // Click handlers clear selection to None so no highlight lingers - // after a click regardless of focus state. The hover style provides - // visual feedback during mouse interaction instead. - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.selection = None; - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - sidebar.toggle_collapse(&path_list, window, cx); - }); - assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None); - - // When the user tabs back into the sidebar, focus_in no longer - // restores selection — it stays None. - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.focus_in(window, cx); - }); - assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None); - } - - #[gpui::test] - async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) { - let project = init_test_project_with_agent_panel("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - let connection = StubAgentConnection::new(); - connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk::new("Hi there!".into()), - )]); - open_thread_with_connection(&panel, connection, cx); - send_message(&panel, cx); - - let session_id = active_session_id(&panel, cx); - save_test_thread_metadata(&session_id, path_list.clone(), cx).await; - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Hello *"] - ); - - // Simulate the agent generating a title. The notification chain is: - // AcpThread::set_title emits TitleUpdated → - // ConnectionView::handle_thread_event calls cx.notify() → - // AgentPanel observer fires and emits AgentPanelEvent → - // Sidebar subscription calls update_entries / rebuild_contents. - // - // Before the fix, handle_thread_event did NOT call cx.notify() for - // TitleUpdated, so the AgentPanel observer never fired and the - // sidebar kept showing the old title. - let thread = panel.read_with(cx, |panel, cx| panel.active_agent_thread(cx).unwrap()); - thread.update(cx, |thread, cx| { - thread - .set_title("Friendly Greeting with AI".into(), cx) - .detach(); - }); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Friendly Greeting with AI *"] - ); - } - - #[gpui::test] - async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) { - let project_a = init_test_project_with_agent_panel("/project-a", cx).await; - let (multi_workspace, cx) = cx - .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); - let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, &project_a, cx); - - let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); - - // Save a thread so it appears in the list. - let connection_a = StubAgentConnection::new(); - connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk::new("Done".into()), - )]); - open_thread_with_connection(&panel_a, connection_a, cx); - send_message(&panel_a, cx); - let session_id_a = active_session_id(&panel_a, cx); - save_test_thread_metadata(&session_id_a, path_list_a.clone(), cx).await; - - // Add a second workspace with its own agent panel. - let fs = cx.update(|_, cx| ::global(cx)); - fs.as_fake() - .insert_tree("/project-b", serde_json::json!({ "src": {} })) - .await; - let project_b = project::Project::test(fs, ["/project-b".as_ref()], cx).await; - let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(project_b.clone(), window, cx) - }); - let panel_b = add_agent_panel(&workspace_b, &project_b, cx); - cx.run_until_parked(); - - let workspace_a = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].clone()); - - // ── 1. Initial state: focused thread derived from active panel ───── - sidebar.read_with(cx, |sidebar, _cx| { - assert_eq!( - sidebar.focused_thread.as_ref(), - Some(&session_id_a), - "The active panel's thread should be focused on startup" - ); - }); - - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.activate_thread( - Agent::NativeAgent, - acp_thread::AgentSessionInfo { - session_id: session_id_a.clone(), - work_dirs: None, - title: Some("Test".into()), - updated_at: None, - created_at: None, - meta: None, - }, - &workspace_a, - window, - cx, - ); - }); - cx.run_until_parked(); - - sidebar.read_with(cx, |sidebar, _cx| { - assert_eq!( - sidebar.focused_thread.as_ref(), - Some(&session_id_a), - "After clicking a thread, it should be the focused thread" - ); - assert!( - has_thread_entry(sidebar, &session_id_a), - "The clicked thread should be present in the entries" - ); - }); - - workspace_a.read_with(cx, |workspace, cx| { - assert!( - workspace.panel::(cx).is_some(), - "Agent panel should exist" - ); - let dock = workspace.right_dock().read(cx); - assert!( - dock.is_open(), - "Clicking a thread should open the agent panel dock" - ); - }); - - let connection_b = StubAgentConnection::new(); - connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk::new("Thread B".into()), - )]); - open_thread_with_connection(&panel_b, connection_b, cx); - send_message(&panel_b, cx); - let session_id_b = active_session_id(&panel_b, cx); - let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]); - save_test_thread_metadata(&session_id_b, path_list_b.clone(), cx).await; - cx.run_until_parked(); - - // Workspace A is currently active. Click a thread in workspace B, - // which also triggers a workspace switch. - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.activate_thread( - Agent::NativeAgent, - acp_thread::AgentSessionInfo { - session_id: session_id_b.clone(), - work_dirs: None, - title: Some("Thread B".into()), - updated_at: None, - created_at: None, - meta: None, - }, - &workspace_b, - window, - cx, - ); - }); - cx.run_until_parked(); - - sidebar.read_with(cx, |sidebar, _cx| { - assert_eq!( - sidebar.focused_thread.as_ref(), - Some(&session_id_b), - "Clicking a thread in another workspace should focus that thread" - ); - assert!( - has_thread_entry(sidebar, &session_id_b), - "The cross-workspace thread should be present in the entries" - ); - }); - - multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate_index(0, window, cx); - }); - cx.run_until_parked(); - - sidebar.read_with(cx, |sidebar, _cx| { - assert_eq!( - sidebar.focused_thread.as_ref(), - Some(&session_id_a), - "Switching workspace should seed focused_thread from the new active panel" - ); - assert!( - has_thread_entry(sidebar, &session_id_a), - "The seeded thread should be present in the entries" - ); - }); - - let connection_b2 = StubAgentConnection::new(); - connection_b2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk::new(DEFAULT_THREAD_TITLE.into()), - )]); - open_thread_with_connection(&panel_b, connection_b2, cx); - send_message(&panel_b, cx); - let session_id_b2 = active_session_id(&panel_b, cx); - save_test_thread_metadata(&session_id_b2, path_list_b.clone(), cx).await; - cx.run_until_parked(); - - // Panel B is not the active workspace's panel (workspace A is - // active), so opening a thread there should not change focused_thread. - // This prevents running threads in background workspaces from causing - // the selection highlight to jump around. - sidebar.read_with(cx, |sidebar, _cx| { - assert_eq!( - sidebar.focused_thread.as_ref(), - Some(&session_id_a), - "Opening a thread in a non-active panel should not change focused_thread" - ); - }); - - workspace_b.update_in(cx, |workspace, window, cx| { - workspace.focus_handle(cx).focus(window, cx); - }); - cx.run_until_parked(); - - sidebar.read_with(cx, |sidebar, _cx| { - assert_eq!( - sidebar.focused_thread.as_ref(), - Some(&session_id_a), - "Defocusing the sidebar should not change focused_thread" - ); - }); - - // Switching workspaces via the multi_workspace (simulates clicking - // a workspace header) should clear focused_thread. - multi_workspace.update_in(cx, |mw, window, cx| { - if let Some(index) = mw.workspaces().iter().position(|w| w == &workspace_b) { - mw.activate_index(index, window, cx); - } - }); - cx.run_until_parked(); - - sidebar.read_with(cx, |sidebar, _cx| { - assert_eq!( - sidebar.focused_thread.as_ref(), - Some(&session_id_b2), - "Switching workspace should seed focused_thread from the new active panel" - ); - assert!( - has_thread_entry(sidebar, &session_id_b2), - "The seeded thread should be present in the entries" - ); - }); - - // ── 8. Focusing the agent panel thread keeps focused_thread ──── - // Workspace B still has session_id_b2 loaded in the agent panel. - // Clicking into the thread (simulated by focusing its view) should - // keep focused_thread since it was already seeded on workspace switch. - panel_b.update_in(cx, |panel, window, cx| { - if let Some(thread_view) = panel.active_conversation_view() { - thread_view.read(cx).focus_handle(cx).focus(window, cx); - } - }); - cx.run_until_parked(); - - sidebar.read_with(cx, |sidebar, _cx| { - assert_eq!( - sidebar.focused_thread.as_ref(), - Some(&session_id_b2), - "Focusing the agent panel thread should set focused_thread" - ); - assert!( - has_thread_entry(sidebar, &session_id_b2), - "The focused thread should be present in the entries" - ); - }); - } - - #[gpui::test] - async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContext) { - let project = init_test_project_with_agent_panel("/project-a", cx).await; - let fs = cx.update(|cx| ::global(cx)); - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx); - - let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); - - // Start a thread and send a message so it has history. - let connection = StubAgentConnection::new(); - connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk::new("Done".into()), - )]); - open_thread_with_connection(&panel, connection, cx); - send_message(&panel, cx); - let session_id = active_session_id(&panel, cx); - save_test_thread_metadata(&session_id, path_list_a.clone(), cx).await; - cx.run_until_parked(); - - // Verify the thread appears in the sidebar. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " Hello *",] - ); - - // The "New Thread" button should NOT be in "active/draft" state - // because the panel has a thread with messages. - sidebar.read_with(cx, |sidebar, _cx| { - assert!( - !sidebar.active_thread_is_draft, - "Panel has a thread with messages, so it should not be a draft" - ); - }); - - // Now add a second folder to the workspace, changing the path_list. - fs.as_fake() - .insert_tree("/project-b", serde_json::json!({ "src": {} })) - .await; - project - .update(cx, |project, cx| { - project.find_or_create_worktree("/project-b", true, cx) - }) - .await - .expect("should add worktree"); - cx.run_until_parked(); - - // The workspace path_list is now [project-a, project-b]. The old - // thread was stored under [project-a], so it no longer appears in - // the sidebar list for this workspace. - let entries = visible_entries_as_strings(&sidebar, cx); - assert!( - !entries.iter().any(|e| e.contains("Hello")), - "Thread stored under the old path_list should not appear: {:?}", - entries - ); - - // The "New Thread" button must still be clickable (not stuck in - // "active/draft" state). Verify that `active_thread_is_draft` is - // false — the panel still has the old thread with messages. - sidebar.read_with(cx, |sidebar, _cx| { - assert!( - !sidebar.active_thread_is_draft, - "After adding a folder the panel still has a thread with messages, \ - so active_thread_is_draft should be false" - ); - }); - - // Actually click "New Thread" by calling create_new_thread and - // verify a new draft is created. - let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone()); - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.create_new_thread(&workspace, window, cx); - }); - cx.run_until_parked(); - - // After creating a new thread, the panel should now be in draft - // state (no messages on the new thread). - sidebar.read_with(cx, |sidebar, _cx| { - assert!( - sidebar.active_thread_is_draft, - "After creating a new thread the panel should be in draft state" - ); - }); - } - - #[gpui::test] - async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) { - // When the user presses Cmd-N (NewThread action) while viewing a - // non-empty thread, the sidebar should show the "New Thread" entry. - // This exercises the same code path as the workspace action handler - // (which bypasses the sidebar's create_new_thread method). - let project = init_test_project_with_agent_panel("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - // Create a non-empty thread (has messages). - let connection = StubAgentConnection::new(); - connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk::new("Done".into()), - )]); - open_thread_with_connection(&panel, connection, cx); - send_message(&panel, cx); - - let session_id = active_session_id(&panel, cx); - save_test_thread_metadata(&session_id, path_list.clone(), cx).await; - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Hello *"] - ); - - // Simulate cmd-n - let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone()); - panel.update_in(cx, |panel, window, cx| { - panel.new_thread(&NewThread, window, cx); - }); - workspace.update_in(cx, |workspace, window, cx| { - workspace.focus_panel::(window, cx); - }); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " [+ New Thread]", " Hello *"], - "After Cmd-N the sidebar should show a highlighted New Thread entry" - ); - - sidebar.read_with(cx, |sidebar, _cx| { - assert!( - sidebar.focused_thread.is_none(), - "focused_thread should be cleared after Cmd-N" - ); - assert!( - sidebar.active_thread_is_draft, - "the new blank thread should be a draft" - ); - }); - } - - #[gpui::test] - async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestAppContext) { - // When the active workspace is an absorbed git worktree, cmd-n - // should still show the "New Thread" entry under the main repo's - // header and highlight it as active. - agent_ui::test_support::init_test(cx); - cx.update(|cx| { - cx.update_flags(false, vec!["agent-v2".into()]); - ThreadStore::init_global(cx); - SidebarThreadMetadataStore::init_global(cx); - language_model::LanguageModelRegistry::test(cx); - prompt_store::init(cx); - }); - - let fs = FakeFs::new(cx.executor()); - - // Main repo with a linked worktree. - fs.insert_tree( - "/project", - serde_json::json!({ - ".git": { - "worktrees": { - "feature-a": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-a", - }, - }, - }, - "src": {}, - }), - ) - .await; - - // Worktree checkout pointing back to the main repo. - fs.insert_tree( - "/wt-feature-a", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-a", - "src": {}, - }), - ) - .await; - - fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt-feature-a"), - ref_name: Some("refs/heads/feature-a".into()), - sha: "aaa".into(), - }); - }) - .unwrap(); - - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; - let worktree_project = - project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; - - main_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - worktree_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - - let (multi_workspace, cx) = cx.add_window_view(|window, cx| { - MultiWorkspace::test_new(main_project.clone(), window, cx) - }); - - let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(worktree_project.clone(), window, cx) - }); - - let worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx); - - // Switch to the worktree workspace. - multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate_index(1, window, cx); - }); - - let sidebar = setup_sidebar(&multi_workspace, cx); - - // Create a non-empty thread in the worktree workspace. - let connection = StubAgentConnection::new(); - connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk::new("Done".into()), - )]); - open_thread_with_connection(&worktree_panel, connection, cx); - send_message(&worktree_panel, cx); - - let session_id = active_session_id(&worktree_panel, cx); - let wt_path_list = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); - save_test_thread_metadata(&session_id, wt_path_list, cx).await; - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project]", " Hello {wt-feature-a} *"] - ); - - // Simulate Cmd-N in the worktree workspace. - worktree_panel.update_in(cx, |panel, window, cx| { - panel.new_thread(&NewThread, window, cx); - }); - worktree_workspace.update_in(cx, |workspace, window, cx| { - workspace.focus_panel::(window, cx); - }); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [project]", - " [+ New Thread]", - " Hello {wt-feature-a} *" - ], - "After Cmd-N in an absorbed worktree, the sidebar should show \ - a highlighted New Thread entry under the main repo header" - ); - - sidebar.read_with(cx, |sidebar, _cx| { - assert!( - sidebar.focused_thread.is_none(), - "focused_thread should be cleared after Cmd-N" - ); - assert!( - sidebar.active_thread_is_draft, - "the new blank thread should be a draft" - ); - }); - } - - async fn init_test_project_with_git( - worktree_path: &str, - cx: &mut TestAppContext, - ) -> (Entity, Arc) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - worktree_path, - serde_json::json!({ - ".git": {}, - "src": {}, - }), - ) - .await; - cx.update(|cx| ::set_global(fs.clone(), cx)); - let project = project::Project::test(fs.clone(), [worktree_path.as_ref()], cx).await; - (project, fs) - } - - #[gpui::test] - async fn test_search_matches_worktree_name(cx: &mut TestAppContext) { - let (project, fs) = init_test_project_with_git("/project", cx).await; - - fs.as_fake() - .with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt/rosewood"), - ref_name: Some("refs/heads/rosewood".into()), - sha: "abc".into(), - }); - }) - .unwrap(); - - project - .update(cx, |project, cx| project.git_scans_complete(cx)) - .await; - - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let main_paths = PathList::new(&[std::path::PathBuf::from("/project")]); - let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]); - save_named_thread_metadata("main-t", "Unrelated Thread", &main_paths, cx).await; - save_named_thread_metadata("wt-t", "Fix Bug", &wt_paths, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // Search for "rosewood" — should match the worktree name, not the title. - type_in_search(&sidebar, "rosewood", cx); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project]", " Fix Bug {rosewood} <== selected"], - ); - } - - #[gpui::test] - async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) { - let (project, fs) = init_test_project_with_git("/project", cx).await; - - project - .update(cx, |project, cx| project.git_scans_complete(cx)) - .await; - - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - // Save a thread against a worktree path that doesn't exist yet. - let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]); - save_named_thread_metadata("wt-thread", "Worktree Thread", &wt_paths, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // Thread is not visible yet — no worktree knows about this path. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project]", " [+ New Thread]"] - ); - - // Now add the worktree to the git state and trigger a rescan. - fs.as_fake() - .with_git_state(std::path::Path::new("/project/.git"), true, |state| { - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt/rosewood"), - ref_name: Some("refs/heads/rosewood".into()), - sha: "abc".into(), - }); - }) - .unwrap(); - - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project]", " Worktree Thread {rosewood}",] - ); - } - - #[gpui::test] - async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppContext) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - - // Create the main repo directory (not opened as a workspace yet). - fs.insert_tree( - "/project", - serde_json::json!({ - ".git": { - "worktrees": { - "feature-a": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-a", - }, - "feature-b": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-b", - }, - }, - }, - "src": {}, - }), - ) - .await; - - // Two worktree checkouts whose .git files point back to the main repo. - fs.insert_tree( - "/wt-feature-a", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-a", - "src": {}, - }), - ) - .await; - fs.insert_tree( - "/wt-feature-b", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-b", - "src": {}, - }), - ) - .await; - - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; - let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await; - - project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await; - project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await; - - // Open both worktrees as workspaces — no main repo yet. - let (multi_workspace, cx) = cx - .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); - multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(project_b.clone(), window, cx); - }); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let paths_a = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); - let paths_b = PathList::new(&[std::path::PathBuf::from("/wt-feature-b")]); - save_named_thread_metadata("thread-a", "Thread A", &paths_a, cx).await; - save_named_thread_metadata("thread-b", "Thread B", &paths_b, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // Without the main repo, each worktree has its own header. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [wt-feature-a]", - " Thread A", - "v [wt-feature-b]", - " Thread B", - ] - ); - - // Configure the main repo to list both worktrees before opening - // it so the initial git scan picks them up. - fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt-feature-a"), - ref_name: Some("refs/heads/feature-a".into()), - sha: "aaa".into(), - }); - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt-feature-b"), - ref_name: Some("refs/heads/feature-b".into()), - sha: "bbb".into(), - }); - }) - .unwrap(); - - let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; - main_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - - multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(main_project.clone(), window, cx); - }); - cx.run_until_parked(); - - // Both worktree workspaces should now be absorbed under the main - // repo header, with worktree chips. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [project]", - " Thread A {wt-feature-a}", - " Thread B {wt-feature-b}", - ] - ); - - // Remove feature-b from the main repo's linked worktrees. - // The feature-b workspace should be pruned automatically. - fs.with_git_state(std::path::Path::new("/project/.git"), true, |state| { - state - .worktrees - .retain(|wt| wt.path != std::path::Path::new("/wt-feature-b")); - }) - .unwrap(); - - cx.run_until_parked(); - - // feature-b's workspace is pruned; feature-a remains absorbed - // under the main repo. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project]", " Thread A {wt-feature-a}",] - ); - } - - #[gpui::test] - async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAppContext) { - // When a worktree workspace is absorbed under the main repo, a - // running thread in the worktree's agent panel should still show - // live status (spinner + "(running)") in the sidebar. - agent_ui::test_support::init_test(cx); - cx.update(|cx| { - cx.update_flags(false, vec!["agent-v2".into()]); - ThreadStore::init_global(cx); - SidebarThreadMetadataStore::init_global(cx); - language_model::LanguageModelRegistry::test(cx); - prompt_store::init(cx); - }); - - let fs = FakeFs::new(cx.executor()); - - // Main repo with a linked worktree. - fs.insert_tree( - "/project", - serde_json::json!({ - ".git": { - "worktrees": { - "feature-a": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-a", - }, - }, - }, - "src": {}, - }), - ) - .await; - - // Worktree checkout pointing back to the main repo. - fs.insert_tree( - "/wt-feature-a", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-a", - "src": {}, - }), - ) - .await; - - fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt-feature-a"), - ref_name: Some("refs/heads/feature-a".into()), - sha: "aaa".into(), - }); - }) - .unwrap(); - - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; - let worktree_project = - project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; - - main_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - worktree_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - - // Create the MultiWorkspace with both projects. - let (multi_workspace, cx) = cx.add_window_view(|window, cx| { - MultiWorkspace::test_new(main_project.clone(), window, cx) - }); - - let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(worktree_project.clone(), window, cx) - }); - - // Add an agent panel to the worktree workspace so we can run a - // thread inside it. - let worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx); - - // Switch back to the main workspace before setting up the sidebar. - multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate_index(0, window, cx); - }); - - let sidebar = setup_sidebar(&multi_workspace, cx); - - // Start a thread in the worktree workspace's panel and keep it - // generating (don't resolve it). - let connection = StubAgentConnection::new(); - open_thread_with_connection(&worktree_panel, connection.clone(), cx); - send_message(&worktree_panel, cx); - - let session_id = active_session_id(&worktree_panel, cx); - - // Save metadata so the sidebar knows about this thread. - let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); - save_test_thread_metadata(&session_id, wt_paths, cx).await; - - // Keep the thread generating by sending a chunk without ending - // the turn. - cx.update(|_, cx| { - connection.send_update( - session_id.clone(), - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())), - cx, - ); - }); - cx.run_until_parked(); - - // The worktree thread should be absorbed under the main project - // and show live running status. - let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!( - entries, - vec!["v [project]", " Hello {wt-feature-a} * (running)",] - ); - } - - #[gpui::test] - async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAppContext) { - agent_ui::test_support::init_test(cx); - cx.update(|cx| { - cx.update_flags(false, vec!["agent-v2".into()]); - ThreadStore::init_global(cx); - SidebarThreadMetadataStore::init_global(cx); - language_model::LanguageModelRegistry::test(cx); - prompt_store::init(cx); - }); - - let fs = FakeFs::new(cx.executor()); - - fs.insert_tree( - "/project", - serde_json::json!({ - ".git": { - "worktrees": { - "feature-a": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-a", - }, - }, - }, - "src": {}, - }), - ) - .await; - - fs.insert_tree( - "/wt-feature-a", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-a", - "src": {}, - }), - ) - .await; - - fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt-feature-a"), - ref_name: Some("refs/heads/feature-a".into()), - sha: "aaa".into(), - }); - }) - .unwrap(); - - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; - let worktree_project = - project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; - - main_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - worktree_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - - let (multi_workspace, cx) = cx.add_window_view(|window, cx| { - MultiWorkspace::test_new(main_project.clone(), window, cx) - }); - - let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(worktree_project.clone(), window, cx) - }); - - let worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx); - - multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate_index(0, window, cx); - }); - - let sidebar = setup_sidebar(&multi_workspace, cx); - - let connection = StubAgentConnection::new(); - open_thread_with_connection(&worktree_panel, connection.clone(), cx); - send_message(&worktree_panel, cx); - - let session_id = active_session_id(&worktree_panel, cx); - let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); - save_test_thread_metadata(&session_id, wt_paths, cx).await; - - cx.update(|_, cx| { - connection.send_update( - session_id.clone(), - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())), - cx, - ); - }); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project]", " Hello {wt-feature-a} * (running)",] - ); - - connection.end_turn(session_id, acp::StopReason::EndTurn); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project]", " Hello {wt-feature-a} * (!)",] - ); - } - - #[gpui::test] - async fn test_clicking_worktree_thread_opens_workspace_when_none_exists( - cx: &mut TestAppContext, - ) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - - fs.insert_tree( - "/project", - serde_json::json!({ - ".git": { - "worktrees": { - "feature-a": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-a", - }, - }, - }, - "src": {}, - }), - ) - .await; - - fs.insert_tree( - "/wt-feature-a", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-a", - "src": {}, - }), - ) - .await; - - fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt-feature-a"), - ref_name: Some("refs/heads/feature-a".into()), - sha: "aaa".into(), - }); - }) - .unwrap(); - - cx.update(|cx| ::set_global(fs.clone(), cx)); - - // Only open the main repo — no workspace for the worktree. - let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; - main_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - - let (multi_workspace, cx) = cx.add_window_view(|window, cx| { - MultiWorkspace::test_new(main_project.clone(), window, cx) - }); - let sidebar = setup_sidebar(&multi_workspace, cx); - - // Save a thread for the worktree path (no workspace for it). - let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); - save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // Thread should appear under the main repo with a worktree chip. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project]", " WT Thread {wt-feature-a}"], - ); - - // Only 1 workspace should exist. - assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()), - 1, - ); - - // Focus the sidebar and select the worktree thread. - open_and_focus_sidebar(&sidebar, cx); - sidebar.update_in(cx, |sidebar, _window, _cx| { - sidebar.selection = Some(1); // index 0 is header, 1 is the thread - }); - - // Confirm to open the worktree thread. - cx.dispatch_action(Confirm); - cx.run_until_parked(); - - // A new workspace should have been created for the worktree path. - let new_workspace = multi_workspace.read_with(cx, |mw, _| { - assert_eq!( - mw.workspaces().len(), - 2, - "confirming a worktree thread without a workspace should open one", - ); - mw.workspaces()[1].clone() - }); - - let new_path_list = - new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx)); - assert_eq!( - new_path_list, - PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]), - "the new workspace should have been opened for the worktree path", - ); - } - - #[gpui::test] - async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace( - cx: &mut TestAppContext, - ) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - - fs.insert_tree( - "/project", - serde_json::json!({ - ".git": { - "worktrees": { - "feature-a": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-a", - }, - }, - }, - "src": {}, - }), - ) - .await; - - fs.insert_tree( - "/wt-feature-a", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-a", - "src": {}, - }), - ) - .await; - - fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt-feature-a"), - ref_name: Some("refs/heads/feature-a".into()), - sha: "aaa".into(), - }); - }) - .unwrap(); - - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; - let worktree_project = - project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; - - main_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - worktree_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - - let (multi_workspace, cx) = cx.add_window_view(|window, cx| { - MultiWorkspace::test_new(main_project.clone(), window, cx) - }); - - let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(worktree_project.clone(), window, cx) - }); - - // Activate the main workspace before setting up the sidebar. - multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate_index(0, window, cx); - }); - - let sidebar = setup_sidebar(&multi_workspace, cx); - - let paths_main = PathList::new(&[std::path::PathBuf::from("/project")]); - let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); - save_named_thread_metadata("thread-main", "Main Thread", &paths_main, cx).await; - save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // The worktree workspace should be absorbed under the main repo. - let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 3); - assert_eq!(entries[0], "v [project]"); - assert!(entries.contains(&" Main Thread".to_string())); - assert!(entries.contains(&" WT Thread {wt-feature-a}".to_string())); - - let wt_thread_index = entries - .iter() - .position(|e| e.contains("WT Thread")) - .expect("should find the worktree thread entry"); - - assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), - 0, - "main workspace should be active initially" - ); - - // Focus the sidebar and select the absorbed worktree thread. - open_and_focus_sidebar(&sidebar, cx); - sidebar.update_in(cx, |sidebar, _window, _cx| { - sidebar.selection = Some(wt_thread_index); - }); - - // Confirm to activate the worktree thread. - cx.dispatch_action(Confirm); - cx.run_until_parked(); - - // The worktree workspace should now be active, not the main one. - let active_workspace = multi_workspace.read_with(cx, |mw, _| { - mw.workspaces()[mw.active_workspace_index()].clone() - }); - assert_eq!( - active_workspace, worktree_workspace, - "clicking an absorbed worktree thread should activate the worktree workspace" - ); - } - - #[gpui::test] - async fn test_activate_archived_thread_with_saved_paths_activates_matching_workspace( - cx: &mut TestAppContext, - ) { - // Thread has saved metadata in ThreadStore. A matching workspace is - // already open. Expected: activates the matching workspace. - init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) - .await; - fs.insert_tree("/project-b", serde_json::json!({ "src": {} })) - .await; - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; - let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await; - - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); - - multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(project_b, window, cx); - }); - - let sidebar = setup_sidebar(&multi_workspace, cx); - - // Save a thread with path_list pointing to project-b. - let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]); - let session_id = acp::SessionId::new(Arc::from("archived-1")); - save_test_thread_metadata(&session_id, path_list_b.clone(), cx).await; - - // Ensure workspace A is active. - multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate_index(0, window, cx); - }); - cx.run_until_parked(); - assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), - 0 - ); - - // Call activate_archived_thread – should resolve saved paths and - // switch to the workspace for project-b. - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.activate_archived_thread( - Agent::NativeAgent, - acp_thread::AgentSessionInfo { - session_id: session_id.clone(), - work_dirs: Some(PathList::new(&[PathBuf::from("/project-b")])), - title: Some("Archived Thread".into()), - updated_at: None, - created_at: None, - meta: None, - }, - window, - cx, - ); - }); - cx.run_until_parked(); - - assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), - 1, - "should have activated the workspace matching the saved path_list" - ); - } - - #[gpui::test] - async fn test_activate_archived_thread_cwd_fallback_with_matching_workspace( - cx: &mut TestAppContext, - ) { - // Thread has no saved metadata but session_info has cwd. A matching - // workspace is open. Expected: uses cwd to find and activate it. - init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) - .await; - fs.insert_tree("/project-b", serde_json::json!({ "src": {} })) - .await; - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; - let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await; - - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); - - multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(project_b, window, cx); - }); - - let sidebar = setup_sidebar(&multi_workspace, cx); - - // Start with workspace A active. - multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate_index(0, window, cx); - }); - cx.run_until_parked(); - assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), - 0 - ); - - // No thread saved to the store – cwd is the only path hint. - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.activate_archived_thread( - Agent::NativeAgent, - acp_thread::AgentSessionInfo { - session_id: acp::SessionId::new(Arc::from("unknown-session")), - work_dirs: Some(PathList::new(&[std::path::PathBuf::from("/project-b")])), - title: Some("CWD Thread".into()), - updated_at: None, - created_at: None, - meta: None, - }, - window, - cx, - ); - }); - cx.run_until_parked(); - - assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), - 1, - "should have activated the workspace matching the cwd" - ); - } - - #[gpui::test] - async fn test_activate_archived_thread_no_paths_no_cwd_uses_active_workspace( - cx: &mut TestAppContext, - ) { - // Thread has no saved metadata and no cwd. Expected: falls back to - // the currently active workspace. - init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) - .await; - fs.insert_tree("/project-b", serde_json::json!({ "src": {} })) - .await; - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; - let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await; - - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); - - multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(project_b, window, cx); - }); - - let sidebar = setup_sidebar(&multi_workspace, cx); - - // Activate workspace B (index 1) to make it the active one. - multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate_index(1, window, cx); - }); - cx.run_until_parked(); - assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), - 1 - ); - - // No saved thread, no cwd – should fall back to the active workspace. - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.activate_archived_thread( - Agent::NativeAgent, - acp_thread::AgentSessionInfo { - session_id: acp::SessionId::new(Arc::from("no-context-session")), - work_dirs: None, - title: Some("Contextless Thread".into()), - updated_at: None, - created_at: None, - meta: None, - }, - window, - cx, - ); - }); - cx.run_until_parked(); - - assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), - 1, - "should have stayed on the active workspace when no path info is available" - ); - } - - #[gpui::test] - async fn test_activate_archived_thread_saved_paths_opens_new_workspace( - cx: &mut TestAppContext, - ) { - // Thread has saved metadata pointing to a path with no open workspace. - // Expected: opens a new workspace for that path. - init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) - .await; - fs.insert_tree("/project-b", serde_json::json!({ "src": {} })) - .await; - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; - - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); - - let sidebar = setup_sidebar(&multi_workspace, cx); - - // Save a thread with path_list pointing to project-b – which has no - // open workspace. - let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]); - let session_id = acp::SessionId::new(Arc::from("archived-new-ws")); - - assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()), - 1, - "should start with one workspace" - ); - - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.activate_archived_thread( - Agent::NativeAgent, - acp_thread::AgentSessionInfo { - session_id: session_id.clone(), - work_dirs: Some(path_list_b), - title: Some("New WS Thread".into()), - updated_at: None, - created_at: None, - meta: None, - }, - window, - cx, - ); - }); - cx.run_until_parked(); - - assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()), - 2, - "should have opened a second workspace for the archived thread's saved paths" - ); - } - - #[gpui::test] - async fn test_activate_archived_thread_reuses_workspace_in_another_window( - cx: &mut TestAppContext, - ) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) - .await; - fs.insert_tree("/project-b", serde_json::json!({ "src": {} })) - .await; - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; - let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await; - - let multi_workspace_a = - cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); - let multi_workspace_b = - cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx)); - - let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap(); - - let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx); - let sidebar = setup_sidebar(&multi_workspace_a_entity, cx_a); - - let session_id = acp::SessionId::new(Arc::from("archived-cross-window")); - - sidebar.update_in(cx_a, |sidebar, window, cx| { - sidebar.activate_archived_thread( - Agent::NativeAgent, - acp_thread::AgentSessionInfo { - session_id: session_id.clone(), - work_dirs: Some(PathList::new(&[PathBuf::from("/project-b")])), - title: Some("Cross Window Thread".into()), - updated_at: None, - created_at: None, - meta: None, - }, - window, - cx, - ); - }); - cx_a.run_until_parked(); - - assert_eq!( - multi_workspace_a - .read_with(cx_a, |mw, _| mw.workspaces().len()) - .unwrap(), - 1, - "should not add the other window's workspace into the current window" - ); - assert_eq!( - multi_workspace_b - .read_with(cx_a, |mw, _| mw.workspaces().len()) - .unwrap(), - 1, - "should reuse the existing workspace in the other window" - ); - assert!( - cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b, - "should activate the window that already owns the matching workspace" - ); - sidebar.read_with(cx_a, |sidebar, _| { - assert_eq!( - sidebar.focused_thread, None, - "source window's sidebar should not eagerly claim focus for a thread opened in another window" - ); - }); - } - - #[gpui::test] - async fn test_activate_archived_thread_reuses_workspace_in_another_window_with_target_sidebar( - cx: &mut TestAppContext, - ) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) - .await; - fs.insert_tree("/project-b", serde_json::json!({ "src": {} })) - .await; - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; - let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await; - - let multi_workspace_a = - cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); - let multi_workspace_b = - cx.add_window(|window, cx| MultiWorkspace::test_new(project_b.clone(), window, cx)); - - let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap(); - let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap(); - - let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx); - let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a); - - let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx); - let sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b); - let workspace_b = multi_workspace_b_entity.read_with(cx_b, |mw, _| mw.workspace().clone()); - let _panel_b = add_agent_panel(&workspace_b, &project_b, cx_b); - - let session_id = acp::SessionId::new(Arc::from("archived-cross-window-with-sidebar")); - - sidebar_a.update_in(cx_a, |sidebar, window, cx| { - sidebar.activate_archived_thread( - Agent::NativeAgent, - acp_thread::AgentSessionInfo { - session_id: session_id.clone(), - work_dirs: Some(PathList::new(&[PathBuf::from("/project-b")])), - title: Some("Cross Window Thread".into()), - updated_at: None, - created_at: None, - meta: None, - }, - window, - cx, - ); - }); - cx_a.run_until_parked(); - - assert_eq!( - multi_workspace_a - .read_with(cx_a, |mw, _| mw.workspaces().len()) - .unwrap(), - 1, - "should not add the other window's workspace into the current window" - ); - assert_eq!( - multi_workspace_b - .read_with(cx_a, |mw, _| mw.workspaces().len()) - .unwrap(), - 1, - "should reuse the existing workspace in the other window" - ); - assert!( - cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b, - "should activate the window that already owns the matching workspace" - ); - sidebar_a.read_with(cx_a, |sidebar, _| { - assert_eq!( - sidebar.focused_thread, None, - "source window's sidebar should not eagerly claim focus for a thread opened in another window" - ); - }); - sidebar_b.read_with(cx_b, |sidebar, _| { - assert_eq!( - sidebar.focused_thread.as_ref(), - Some(&session_id), - "target window's sidebar should eagerly focus the activated archived thread" - ); - }); - } - - #[gpui::test] - async fn test_activate_archived_thread_prefers_current_window_for_matching_paths( - cx: &mut TestAppContext, - ) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) - .await; - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let project_b = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; - let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; - - let multi_workspace_b = - cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx)); - let multi_workspace_a = - cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); - - let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap(); - - let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx); - let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a); - - let session_id = acp::SessionId::new(Arc::from("archived-current-window")); - - sidebar_a.update_in(cx_a, |sidebar, window, cx| { - sidebar.activate_archived_thread( - Agent::NativeAgent, - acp_thread::AgentSessionInfo { - session_id: session_id.clone(), - work_dirs: Some(PathList::new(&[PathBuf::from("/project-a")])), - title: Some("Current Window Thread".into()), - updated_at: None, - created_at: None, - meta: None, - }, - window, - cx, - ); - }); - cx_a.run_until_parked(); - - assert!( - cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_a, - "should keep activation in the current window when it already has a matching workspace" - ); - sidebar_a.read_with(cx_a, |sidebar, _| { - assert_eq!( - sidebar.focused_thread.as_ref(), - Some(&session_id), - "current window's sidebar should eagerly focus the activated archived thread" - ); - }); - assert_eq!( - multi_workspace_a - .read_with(cx_a, |mw, _| mw.workspaces().len()) - .unwrap(), - 1, - "current window should continue reusing its existing workspace" - ); - assert_eq!( - multi_workspace_b - .read_with(cx_a, |mw, _| mw.workspaces().len()) - .unwrap(), - 1, - "other windows should not be activated just because they also match the saved paths" - ); - } - - #[gpui::test] - async fn test_removing_workspace_also_removes_absorbed_worktrees(cx: &mut TestAppContext) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - - // Main repo with two linked worktrees. - fs.insert_tree( - "/project", - serde_json::json!({ - ".git": { - "worktrees": { - "feature-a": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-a", - }, - "feature-b": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-b", - }, - }, - }, - "src": {}, - }), - ) - .await; - - // Two worktree checkouts whose .git files point back to the main repo. - fs.insert_tree( - "/wt-feature-a", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-a", - "src": {}, - }), - ) - .await; - fs.insert_tree( - "/wt-feature-b", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-b", - "src": {}, - }), - ) - .await; - - // Configure the main repo to list both worktrees. - fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt-feature-a"), - ref_name: Some("refs/heads/feature-a".into()), - sha: "aaa".into(), - }); - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt-feature-b"), - ref_name: Some("refs/heads/feature-b".into()), - sha: "bbb".into(), - }); - }) - .unwrap(); - - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; - let worktree_project_a = - project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; - let worktree_project_b = - project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await; - - main_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - worktree_project_a - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - worktree_project_b - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - - // Open the main project first, then add both worktree workspaces. - let (multi_workspace, cx) = cx.add_window_view(|window, cx| { - MultiWorkspace::test_new(main_project.clone(), window, cx) - }); - multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(worktree_project_a.clone(), window, cx); - }); - multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(worktree_project_b.clone(), window, cx); - }); - let sidebar = setup_sidebar(&multi_workspace, cx); - - // Save threads for both worktrees so they appear in the sidebar. - let paths_a = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); - let paths_b = PathList::new(&[std::path::PathBuf::from("/wt-feature-b")]); - save_named_thread_metadata("thread-a", "Thread A", &paths_a, cx).await; - save_named_thread_metadata("thread-b", "Thread B", &paths_b, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // Both worktree threads should be absorbed under the main project header. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [project]", - " Thread A {wt-feature-a}", - " Thread B {wt-feature-b}", - ] - ); - - // Now remove the main workspace (index 0). - multi_workspace.update_in(cx, |mw, window, cx| { - mw.remove_workspace(0, window, cx); - }); - cx.run_until_parked(); - - // The worktree workspaces should also have been removed. - // Before the fix, they remain in the sidebar as standalone entries. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - Vec::::new(), - "removing the main workspace should also remove the absorbed worktree workspaces" - ); - } + writeln!(output).ok(); } diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..b81596fc72e445f6702b93a6c4396146abb6f296 --- /dev/null +++ b/crates/sidebar/src/sidebar_tests.rs @@ -0,0 +1,5343 @@ +use super::*; +use acp_thread::StubAgentConnection; +use agent::ThreadStore; +use agent_ui::{ + test_support::{active_session_id, open_thread_with_connection, send_message}, + thread_metadata_store::ThreadMetadata, +}; +use assistant_text_thread::TextThreadStore; +use chrono::DateTime; +use feature_flags::FeatureFlagAppExt as _; +use fs::FakeFs; +use gpui::TestAppContext; +use pretty_assertions::assert_eq; +use project::AgentId; +use settings::SettingsStore; +use std::{path::PathBuf, sync::Arc}; +use util::path_list::PathList; + +fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + theme_settings::init(theme::LoadThemes::JustBase, cx); + editor::init(cx); + cx.update_flags(false, vec!["agent-v2".into()]); + ThreadStore::init_global(cx); + ThreadMetadataStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + prompt_store::init(cx); + }); +} + +fn has_thread_entry(sidebar: &Sidebar, session_id: &acp::SessionId) -> bool { + sidebar + .contents + .entries + .iter() + .any(|entry| matches!(entry, ListEntry::Thread(t) if &t.metadata.session_id == session_id)) +} + +async fn init_test_project( + worktree_path: &str, + cx: &mut TestAppContext, +) -> Entity { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(worktree_path, serde_json::json!({ "src": {} })) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + project::Project::test(fs, [worktree_path.as_ref()], cx).await +} + +fn setup_sidebar( + multi_workspace: &Entity, + cx: &mut gpui::VisualTestContext, +) -> Entity { + let multi_workspace = multi_workspace.clone(); + let sidebar = + cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx))); + multi_workspace.update(cx, |mw, cx| { + mw.register_sidebar(sidebar.clone(), cx); + }); + cx.run_until_parked(); + sidebar +} + +async fn save_n_test_threads(count: u32, path_list: &PathList, cx: &mut gpui::VisualTestContext) { + for i in 0..count { + save_thread_metadata( + acp::SessionId::new(Arc::from(format!("thread-{}", i))), + format!("Thread {}", i + 1).into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(), + path_list.clone(), + cx, + ) + .await; + } + cx.run_until_parked(); +} + +async fn save_test_thread_metadata( + session_id: &acp::SessionId, + path_list: PathList, + cx: &mut TestAppContext, +) { + save_thread_metadata( + session_id.clone(), + "Test".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + path_list, + cx, + ) + .await; +} + +async fn save_named_thread_metadata( + session_id: &str, + title: &str, + path_list: &PathList, + cx: &mut gpui::VisualTestContext, +) { + save_thread_metadata( + acp::SessionId::new(Arc::from(session_id)), + SharedString::from(title.to_string()), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + cx.run_until_parked(); +} + +async fn save_thread_metadata( + session_id: acp::SessionId, + title: SharedString, + updated_at: DateTime, + path_list: PathList, + cx: &mut TestAppContext, +) { + let metadata = ThreadMetadata { + session_id, + agent_id: agent::ZED_AGENT_ID.clone(), + title, + updated_at, + created_at: None, + folder_paths: path_list, + archived: false, + }; + cx.update(|cx| { + ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx)) + }); + cx.run_until_parked(); +} + +fn open_and_focus_sidebar(sidebar: &Entity, cx: &mut gpui::VisualTestContext) { + let multi_workspace = sidebar.read_with(cx, |s, _| s.multi_workspace.upgrade()); + if let Some(multi_workspace) = multi_workspace { + multi_workspace.update_in(cx, |mw, window, cx| { + if !mw.sidebar_open() { + mw.toggle_sidebar(window, cx); + } + }); + } + cx.run_until_parked(); + sidebar.update_in(cx, |_, window, cx| { + cx.focus_self(window); + }); + cx.run_until_parked(); +} + +fn visible_entries_as_strings( + sidebar: &Entity, + cx: &mut gpui::VisualTestContext, +) -> Vec { + sidebar.read_with(cx, |sidebar, _cx| { + sidebar + .contents + .entries + .iter() + .enumerate() + .map(|(ix, entry)| { + let selected = if sidebar.selection == Some(ix) { + " <== selected" + } else { + "" + }; + match entry { + ListEntry::ProjectHeader { + label, + path_list, + highlight_positions: _, + .. + } => { + let icon = if sidebar.collapsed_groups.contains(path_list) { + ">" + } else { + "v" + }; + format!("{} [{}]{}", icon, label, selected) + } + ListEntry::Thread(thread) => { + let title = thread.metadata.title.as_ref(); + let active = if thread.is_live { " *" } else { "" }; + let status_str = match thread.status { + AgentThreadStatus::Running => " (running)", + AgentThreadStatus::Error => " (error)", + AgentThreadStatus::WaitingForConfirmation => " (waiting)", + _ => "", + }; + let notified = if sidebar + .contents + .is_thread_notified(&thread.metadata.session_id) + { + " (!)" + } else { + "" + }; + let worktree = if thread.worktrees.is_empty() { + String::new() + } else { + let mut seen = Vec::new(); + let mut chips = Vec::new(); + for wt in &thread.worktrees { + if !seen.contains(&wt.name) { + seen.push(wt.name.clone()); + chips.push(format!("{{{}}}", wt.name)); + } + } + format!(" {}", chips.join(", ")) + }; + format!( + " {}{}{}{}{}{}", + title, worktree, active, status_str, notified, selected + ) + } + ListEntry::ViewMore { + is_fully_expanded, .. + } => { + if *is_fully_expanded { + format!(" - Collapse{}", selected) + } else { + format!(" + View More{}", selected) + } + } + ListEntry::NewThread { worktrees, .. } => { + let worktree = if worktrees.is_empty() { + String::new() + } else { + let mut seen = Vec::new(); + let mut chips = Vec::new(); + for wt in worktrees { + if !seen.contains(&wt.name) { + seen.push(wt.name.clone()); + chips.push(format!("{{{}}}", wt.name)); + } + } + format!(" {}", chips.join(", ")) + }; + format!(" [+ New Thread{}]{}", worktree, selected) + } + } + }) + .collect() + }) +} + +#[test] +fn test_clean_mention_links() { + // Simple mention link + assert_eq!( + Sidebar::clean_mention_links("check [@Button.tsx](file:///path/to/Button.tsx)"), + "check @Button.tsx" + ); + + // Multiple mention links + assert_eq!( + Sidebar::clean_mention_links( + "look at [@foo.rs](file:///foo.rs) and [@bar.rs](file:///bar.rs)" + ), + "look at @foo.rs and @bar.rs" + ); + + // No mention links — passthrough + assert_eq!( + Sidebar::clean_mention_links("plain text with no mentions"), + "plain text with no mentions" + ); + + // Incomplete link syntax — preserved as-is + assert_eq!( + Sidebar::clean_mention_links("broken [@mention without closing"), + "broken [@mention without closing" + ); + + // Regular markdown link (no @) — not touched + assert_eq!( + Sidebar::clean_mention_links("see [docs](https://example.com)"), + "see [docs](https://example.com)" + ); + + // Empty input + assert_eq!(Sidebar::clean_mention_links(""), ""); +} + +#[gpui::test] +async fn test_entities_released_on_window_close(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let weak_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().downgrade()); + let weak_sidebar = sidebar.downgrade(); + let weak_multi_workspace = multi_workspace.downgrade(); + + drop(sidebar); + drop(multi_workspace); + cx.update(|window, _cx| window.remove_window()); + cx.run_until_parked(); + + weak_multi_workspace.assert_released(); + weak_sidebar.assert_released(); + weak_workspace.assert_released(); +} + +#[gpui::test] +async fn test_single_workspace_no_threads(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " [+ New Thread]"] + ); +} + +#[gpui::test] +async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + save_thread_metadata( + acp::SessionId::new(Arc::from("thread-1")), + "Fix crash in project panel".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + + save_thread_metadata( + acp::SessionId::new(Arc::from("thread-2")), + "Add inline diff view".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + cx.run_until_parked(); + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Fix crash in project panel", + " Add inline diff view", + ] + ); +} + +#[gpui::test] +async fn test_workspace_lifecycle(cx: &mut TestAppContext) { + let project = init_test_project("/project-a", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Single workspace with a thread + let path_list = PathList::new(&[std::path::PathBuf::from("/project-a")]); + + save_thread_metadata( + acp::SessionId::new(Arc::from("thread-a1")), + "Thread A1".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + cx.run_until_parked(); + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project-a]", " Thread A1"] + ); + + // Add a second workspace + multi_workspace.update_in(cx, |mw, window, cx| { + mw.create_test_workspace(window, cx).detach(); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project-a]", " Thread A1",] + ); + + // Remove the second workspace + multi_workspace.update_in(cx, |mw, window, cx| { + let workspace = mw.workspaces()[1].clone(); + mw.remove(&workspace, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project-a]", " Thread A1"] + ); +} + +#[gpui::test] +async fn test_view_more_pagination(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(12, &path_list, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Thread 12", + " Thread 11", + " Thread 10", + " Thread 9", + " Thread 8", + " + View More", + ] + ); +} + +#[gpui::test] +async fn test_view_more_batched_expansion(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + // Create 17 threads: initially shows 5, then 10, then 15, then all 17 with Collapse + save_n_test_threads(17, &path_list, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Initially shows 5 threads + View More + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!(entries.len(), 7); // header + 5 threads + View More + assert!(entries.iter().any(|e| e.contains("View More"))); + + // Focus and navigate to View More, then confirm to expand by one batch + open_and_focus_sidebar(&sidebar, cx); + for _ in 0..7 { + cx.dispatch_action(SelectNext); + } + cx.dispatch_action(Confirm); + cx.run_until_parked(); + + // Now shows 10 threads + View More + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!(entries.len(), 12); // header + 10 threads + View More + assert!(entries.iter().any(|e| e.contains("View More"))); + + // Expand again by one batch + sidebar.update_in(cx, |s, _window, cx| { + let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0); + s.expanded_groups.insert(path_list.clone(), current + 1); + s.update_entries(cx); + }); + cx.run_until_parked(); + + // Now shows 15 threads + View More + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!(entries.len(), 17); // header + 15 threads + View More + assert!(entries.iter().any(|e| e.contains("View More"))); + + // Expand one more time - should show all 17 threads with Collapse button + sidebar.update_in(cx, |s, _window, cx| { + let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0); + s.expanded_groups.insert(path_list.clone(), current + 1); + s.update_entries(cx); + }); + cx.run_until_parked(); + + // All 17 threads shown with Collapse button + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!(entries.len(), 19); // header + 17 threads + Collapse + assert!(!entries.iter().any(|e| e.contains("View More"))); + assert!(entries.iter().any(|e| e.contains("Collapse"))); + + // Click collapse - should go back to showing 5 threads + sidebar.update_in(cx, |s, _window, cx| { + s.expanded_groups.remove(&path_list); + s.update_entries(cx); + }); + cx.run_until_parked(); + + // Back to initial state: 5 threads + View More + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!(entries.len(), 7); // header + 5 threads + View More + assert!(entries.iter().any(|e| e.contains("View More"))); +} + +#[gpui::test] +async fn test_collapse_and_expand_group(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(1, &path_list, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Thread 1"] + ); + + // Collapse + sidebar.update_in(cx, |s, window, cx| { + s.toggle_collapse(&path_list, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["> [my-project]"] + ); + + // Expand + sidebar.update_in(cx, |s, window, cx| { + s.toggle_collapse(&path_list, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Thread 1"] + ); +} + +#[gpui::test] +async fn test_visible_entries_as_strings(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + let expanded_path = PathList::new(&[std::path::PathBuf::from("/expanded")]); + let collapsed_path = PathList::new(&[std::path::PathBuf::from("/collapsed")]); + + sidebar.update_in(cx, |s, _window, _cx| { + s.collapsed_groups.insert(collapsed_path.clone()); + s.contents + .notified_threads + .insert(acp::SessionId::new(Arc::from("t-5"))); + s.contents.entries = vec![ + // Expanded project header + ListEntry::ProjectHeader { + path_list: expanded_path.clone(), + label: "expanded-project".into(), + workspace: workspace.clone(), + highlight_positions: Vec::new(), + has_running_threads: false, + waiting_thread_count: 0, + is_active: true, + }, + ListEntry::Thread(ThreadEntry { + metadata: ThreadMetadata { + session_id: acp::SessionId::new(Arc::from("t-1")), + agent_id: AgentId::new("zed-agent"), + folder_paths: PathList::default(), + title: "Completed thread".into(), + updated_at: Utc::now(), + created_at: Some(Utc::now()), + archived: false, + }, + icon: IconName::ZedAgent, + icon_from_external_svg: None, + status: AgentThreadStatus::Completed, + workspace: ThreadEntryWorkspace::Open(workspace.clone()), + is_live: false, + is_background: false, + is_title_generating: false, + highlight_positions: Vec::new(), + worktrees: Vec::new(), + diff_stats: DiffStats::default(), + }), + // Active thread with Running status + ListEntry::Thread(ThreadEntry { + metadata: ThreadMetadata { + session_id: acp::SessionId::new(Arc::from("t-2")), + agent_id: AgentId::new("zed-agent"), + folder_paths: PathList::default(), + title: "Running thread".into(), + updated_at: Utc::now(), + created_at: Some(Utc::now()), + archived: false, + }, + icon: IconName::ZedAgent, + icon_from_external_svg: None, + status: AgentThreadStatus::Running, + workspace: ThreadEntryWorkspace::Open(workspace.clone()), + is_live: true, + is_background: false, + is_title_generating: false, + highlight_positions: Vec::new(), + worktrees: Vec::new(), + diff_stats: DiffStats::default(), + }), + // Active thread with Error status + ListEntry::Thread(ThreadEntry { + metadata: ThreadMetadata { + session_id: acp::SessionId::new(Arc::from("t-3")), + agent_id: AgentId::new("zed-agent"), + folder_paths: PathList::default(), + title: "Error thread".into(), + updated_at: Utc::now(), + created_at: Some(Utc::now()), + archived: false, + }, + icon: IconName::ZedAgent, + icon_from_external_svg: None, + status: AgentThreadStatus::Error, + workspace: ThreadEntryWorkspace::Open(workspace.clone()), + is_live: true, + is_background: false, + is_title_generating: false, + highlight_positions: Vec::new(), + worktrees: Vec::new(), + diff_stats: DiffStats::default(), + }), + // Thread with WaitingForConfirmation status, not active + ListEntry::Thread(ThreadEntry { + metadata: ThreadMetadata { + session_id: acp::SessionId::new(Arc::from("t-4")), + agent_id: AgentId::new("zed-agent"), + folder_paths: PathList::default(), + title: "Waiting thread".into(), + updated_at: Utc::now(), + created_at: Some(Utc::now()), + archived: false, + }, + icon: IconName::ZedAgent, + icon_from_external_svg: None, + status: AgentThreadStatus::WaitingForConfirmation, + workspace: ThreadEntryWorkspace::Open(workspace.clone()), + is_live: false, + is_background: false, + is_title_generating: false, + highlight_positions: Vec::new(), + worktrees: Vec::new(), + diff_stats: DiffStats::default(), + }), + // Background thread that completed (should show notification) + ListEntry::Thread(ThreadEntry { + metadata: ThreadMetadata { + session_id: acp::SessionId::new(Arc::from("t-5")), + agent_id: AgentId::new("zed-agent"), + folder_paths: PathList::default(), + title: "Notified thread".into(), + updated_at: Utc::now(), + created_at: Some(Utc::now()), + archived: false, + }, + icon: IconName::ZedAgent, + icon_from_external_svg: None, + status: AgentThreadStatus::Completed, + workspace: ThreadEntryWorkspace::Open(workspace.clone()), + is_live: true, + is_background: true, + is_title_generating: false, + highlight_positions: Vec::new(), + worktrees: Vec::new(), + diff_stats: DiffStats::default(), + }), + // View More entry + ListEntry::ViewMore { + path_list: expanded_path.clone(), + is_fully_expanded: false, + }, + // Collapsed project header + ListEntry::ProjectHeader { + path_list: collapsed_path.clone(), + label: "collapsed-project".into(), + workspace: workspace.clone(), + highlight_positions: Vec::new(), + has_running_threads: false, + waiting_thread_count: 0, + is_active: false, + }, + ]; + + // Select the Running thread (index 2) + s.selection = Some(2); + }); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [expanded-project]", + " Completed thread", + " Running thread * (running) <== selected", + " Error thread * (error)", + " Waiting thread (waiting)", + " Notified thread * (!)", + " + View More", + "> [collapsed-project]", + ] + ); + + // Move selection to the collapsed header + sidebar.update_in(cx, |s, _window, _cx| { + s.selection = Some(7); + }); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx).last().cloned(), + Some("> [collapsed-project] <== selected".to_string()), + ); + + // Clear selection + sidebar.update_in(cx, |s, _window, _cx| { + s.selection = None; + }); + + // No entry should have the selected marker + let entries = visible_entries_as_strings(&sidebar, cx); + for entry in &entries { + assert!( + !entry.contains("<== selected"), + "unexpected selection marker in: {}", + entry + ); + } +} + +#[gpui::test] +async fn test_keyboard_select_next_and_previous(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(3, &path_list, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Entries: [header, thread3, thread2, thread1] + // Focusing the sidebar does not set a selection; select_next/select_previous + // handle None gracefully by starting from the first or last entry. + open_and_focus_sidebar(&sidebar, cx); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); + + // First SelectNext from None starts at index 0 + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + + // Move down through remaining entries + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); + + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2)); + + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3)); + + // At the end, wraps back to first entry + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + + // Navigate back to the end + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2)); + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3)); + + // Move back up + cx.dispatch_action(SelectPrevious); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2)); + + cx.dispatch_action(SelectPrevious); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); + + cx.dispatch_action(SelectPrevious); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + + // At the top, selection clears (focus returns to editor) + cx.dispatch_action(SelectPrevious); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); +} + +#[gpui::test] +async fn test_keyboard_select_first_and_last(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(3, &path_list, cx).await; + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + open_and_focus_sidebar(&sidebar, cx); + + // SelectLast jumps to the end + cx.dispatch_action(SelectLast); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3)); + + // SelectFirst jumps to the beginning + cx.dispatch_action(SelectFirst); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); +} + +#[gpui::test] +async fn test_keyboard_focus_in_does_not_set_selection(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Initially no selection + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); + + // Open the sidebar so it's rendered, then focus it to trigger focus_in. + // focus_in no longer sets a default selection. + open_and_focus_sidebar(&sidebar, cx); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); + + // Manually set a selection, blur, then refocus — selection should be preserved + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(0); + }); + + cx.update(|window, _cx| { + window.blur(); + }); + cx.run_until_parked(); + + sidebar.update_in(cx, |_, window, cx| { + cx.focus_self(window); + }); + cx.run_until_parked(); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); +} + +#[gpui::test] +async fn test_keyboard_confirm_on_project_header_toggles_collapse(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(1, &path_list, cx).await; + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Thread 1"] + ); + + // Focus the sidebar and select the header (index 0) + open_and_focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(0); + }); + + // Confirm on project header collapses the group + cx.dispatch_action(Confirm); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["> [my-project] <== selected"] + ); + + // Confirm again expands the group + cx.dispatch_action(Confirm); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project] <== selected", " Thread 1",] + ); +} + +#[gpui::test] +async fn test_keyboard_confirm_on_view_more_expands(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(8, &path_list, cx).await; + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Should show header + 5 threads + "View More" + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!(entries.len(), 7); + assert!(entries.iter().any(|e| e.contains("View More"))); + + // Focus sidebar (selection starts at None), then navigate down to the "View More" entry (index 6) + open_and_focus_sidebar(&sidebar, cx); + for _ in 0..7 { + cx.dispatch_action(SelectNext); + } + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(6)); + + // Confirm on "View More" to expand + cx.dispatch_action(Confirm); + cx.run_until_parked(); + + // All 8 threads should now be visible with a "Collapse" button + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!(entries.len(), 10); // header + 8 threads + Collapse button + assert!(!entries.iter().any(|e| e.contains("View More"))); + assert!(entries.iter().any(|e| e.contains("Collapse"))); +} + +#[gpui::test] +async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(1, &path_list, cx).await; + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Thread 1"] + ); + + // Focus sidebar and manually select the header (index 0). Press left to collapse. + open_and_focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(0); + }); + + cx.dispatch_action(SelectParent); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["> [my-project] <== selected"] + ); + + // Press right to expand + cx.dispatch_action(SelectChild); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project] <== selected", " Thread 1",] + ); + + // Press right again on already-expanded header moves selection down + cx.dispatch_action(SelectChild); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); +} + +#[gpui::test] +async fn test_keyboard_collapse_from_child_selects_parent(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(1, &path_list, cx).await; + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Focus sidebar (selection starts at None), then navigate down to the thread (child) + open_and_focus_sidebar(&sidebar, cx); + cx.dispatch_action(SelectNext); + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Thread 1 <== selected",] + ); + + // Pressing left on a child collapses the parent group and selects it + cx.dispatch_action(SelectParent); + cx.run_until_parked(); + + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["> [my-project] <== selected"] + ); +} + +#[gpui::test] +async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) { + let project = init_test_project("/empty-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + // An empty project has the header and a new thread button. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [empty-project]", " [+ New Thread]"] + ); + + // Focus sidebar — focus_in does not set a selection + open_and_focus_sidebar(&sidebar, cx); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); + + // First SelectNext from None starts at index 0 (header) + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + + // SelectNext moves to the new thread button + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); + + // At the end, wraps back to first entry + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + + // SelectPrevious from first entry clears selection (returns to editor) + cx.dispatch_action(SelectPrevious); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); +} + +#[gpui::test] +async fn test_selection_clamps_after_entry_removal(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(1, &path_list, cx).await; + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Focus sidebar (selection starts at None), navigate down to the thread (index 1) + open_and_focus_sidebar(&sidebar, cx); + cx.dispatch_action(SelectNext); + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); + + // Collapse the group, which removes the thread from the list + cx.dispatch_action(SelectParent); + cx.run_until_parked(); + + // Selection should be clamped to the last valid index (0 = header) + let selection = sidebar.read_with(cx, |s, _| s.selection); + let entry_count = sidebar.read_with(cx, |s, _| s.contents.entries.len()); + assert!( + selection.unwrap_or(0) < entry_count, + "selection {} should be within bounds (entries: {})", + selection.unwrap_or(0), + entry_count, + ); +} + +async fn init_test_project_with_agent_panel( + worktree_path: &str, + cx: &mut TestAppContext, +) -> Entity { + agent_ui::test_support::init_test(cx); + cx.update(|cx| { + cx.update_flags(false, vec!["agent-v2".into()]); + ThreadStore::init_global(cx); + ThreadMetadataStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + prompt_store::init(cx); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(worktree_path, serde_json::json!({ "src": {} })) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + project::Project::test(fs, [worktree_path.as_ref()], cx).await +} + +fn add_agent_panel( + workspace: &Entity, + project: &Entity, + cx: &mut gpui::VisualTestContext, +) -> Entity { + workspace.update_in(cx, |workspace, window, cx| { + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); + let panel = cx.new(|cx| AgentPanel::test_new(workspace, text_thread_store, window, cx)); + workspace.add_panel(panel.clone(), window, cx); + panel + }) +} + +fn setup_sidebar_with_agent_panel( + multi_workspace: &Entity, + project: &Entity, + cx: &mut gpui::VisualTestContext, +) -> (Entity, Entity) { + let sidebar = setup_sidebar(multi_workspace, cx); + let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone()); + let panel = add_agent_panel(&workspace, project, cx); + (sidebar, panel) +} + +#[gpui::test] +async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) { + let project = init_test_project_with_agent_panel("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + // Open thread A and keep it generating. + let connection = StubAgentConnection::new(); + open_thread_with_connection(&panel, connection.clone(), cx); + send_message(&panel, cx); + + let session_id_a = active_session_id(&panel, cx); + save_test_thread_metadata(&session_id_a, path_list.clone(), cx).await; + + cx.update(|_, cx| { + connection.send_update( + session_id_a.clone(), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())), + cx, + ); + }); + cx.run_until_parked(); + + // Open thread B (idle, default response) — thread A goes to background. + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Done".into()), + )]); + open_thread_with_connection(&panel, connection, cx); + send_message(&panel, cx); + + let session_id_b = active_session_id(&panel, cx); + save_test_thread_metadata(&session_id_b, path_list.clone(), cx).await; + + cx.run_until_parked(); + + let mut entries = visible_entries_as_strings(&sidebar, cx); + entries[1..].sort(); + assert_eq!( + entries, + vec!["v [my-project]", " Hello *", " Hello * (running)",] + ); +} + +#[gpui::test] +async fn test_background_thread_completion_triggers_notification(cx: &mut TestAppContext) { + let project_a = init_test_project_with_agent_panel("/project-a", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); + let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, &project_a, cx); + + let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); + + // Open thread on workspace A and keep it generating. + let connection_a = StubAgentConnection::new(); + open_thread_with_connection(&panel_a, connection_a.clone(), cx); + send_message(&panel_a, cx); + + let session_id_a = active_session_id(&panel_a, cx); + save_test_thread_metadata(&session_id_a, path_list_a.clone(), cx).await; + + cx.update(|_, cx| { + connection_a.send_update( + session_id_a.clone(), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())), + cx, + ); + }); + cx.run_until_parked(); + + // Add a second workspace and activate it (making workspace A the background). + let fs = cx.update(|_, cx| ::global(cx)); + let project_b = project::Project::test(fs, [], cx).await; + multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_b, window, cx); + }); + cx.run_until_parked(); + + // Thread A is still running; no notification yet. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project-a]", " Hello * (running)",] + ); + + // Complete thread A's turn (transition Running → Completed). + connection_a.end_turn(session_id_a.clone(), acp::StopReason::EndTurn); + cx.run_until_parked(); + + // The completed background thread shows a notification indicator. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project-a]", " Hello * (!)",] + ); +} + +fn type_in_search(sidebar: &Entity, query: &str, cx: &mut gpui::VisualTestContext) { + sidebar.update_in(cx, |sidebar, window, cx| { + window.focus(&sidebar.filter_editor.focus_handle(cx), cx); + sidebar.filter_editor.update(cx, |editor, cx| { + editor.set_text(query, window, cx); + }); + }); + cx.run_until_parked(); +} + +#[gpui::test] +async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + for (id, title, hour) in [ + ("t-1", "Fix crash in project panel", 3), + ("t-2", "Add inline diff view", 2), + ("t-3", "Refactor settings module", 1), + ] { + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + } + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Fix crash in project panel", + " Add inline diff view", + " Refactor settings module", + ] + ); + + // User types "diff" in the search box — only the matching thread remains, + // with its workspace header preserved for context. + type_in_search(&sidebar, "diff", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Add inline diff view <== selected",] + ); + + // User changes query to something with no matches — list is empty. + type_in_search(&sidebar, "nonexistent", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + Vec::::new() + ); +} + +#[gpui::test] +async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) { + // Scenario: A user remembers a thread title but not the exact casing. + // Search should match case-insensitively so they can still find it. + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + save_thread_metadata( + acp::SessionId::new(Arc::from("thread-1")), + "Fix Crash In Project Panel".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + cx.run_until_parked(); + + // Lowercase query matches mixed-case title. + type_in_search(&sidebar, "fix crash", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Fix Crash In Project Panel <== selected", + ] + ); + + // Uppercase query also matches the same title. + type_in_search(&sidebar, "FIX CRASH", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Fix Crash In Project Panel <== selected", + ] + ); +} + +#[gpui::test] +async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContext) { + // Scenario: A user searches, finds what they need, then presses Escape + // to dismiss the filter and see the full list again. + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + for (id, title, hour) in [("t-1", "Alpha thread", 2), ("t-2", "Beta thread", 1)] { + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + } + cx.run_until_parked(); + + // Confirm the full list is showing. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Alpha thread", " Beta thread",] + ); + + // User types a search query to filter down. + open_and_focus_sidebar(&sidebar, cx); + type_in_search(&sidebar, "alpha", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Alpha thread <== selected",] + ); + + // User presses Escape — filter clears, full list is restored. + // The selection index (1) now points at the first thread entry. + cx.dispatch_action(Cancel); + cx.run_until_parked(); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Alpha thread <== selected", + " Beta thread", + ] + ); +} + +#[gpui::test] +async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppContext) { + let project_a = init_test_project("/project-a", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); + + for (id, title, hour) in [ + ("a1", "Fix bug in sidebar", 2), + ("a2", "Add tests for editor", 1), + ] { + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list_a.clone(), + cx, + ) + .await; + } + + // Add a second workspace. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.create_test_workspace(window, cx).detach(); + }); + cx.run_until_parked(); + + let path_list_b = PathList::new::(&[]); + + for (id, title, hour) in [ + ("b1", "Refactor sidebar layout", 3), + ("b2", "Fix typo in README", 1), + ] { + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list_b.clone(), + cx, + ) + .await; + } + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project-a]", + " Fix bug in sidebar", + " Add tests for editor", + ] + ); + + // "sidebar" matches a thread in each workspace — both headers stay visible. + type_in_search(&sidebar, "sidebar", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project-a]", " Fix bug in sidebar <== selected",] + ); + + // "typo" only matches in the second workspace — the first header disappears. + type_in_search(&sidebar, "typo", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + Vec::::new() + ); + + // "project-a" matches the first workspace name — the header appears + // with all child threads included. + type_in_search(&sidebar, "project-a", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project-a]", + " Fix bug in sidebar <== selected", + " Add tests for editor", + ] + ); +} + +#[gpui::test] +async fn test_search_matches_workspace_name(cx: &mut TestAppContext) { + let project_a = init_test_project("/alpha-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list_a = PathList::new(&[std::path::PathBuf::from("/alpha-project")]); + + for (id, title, hour) in [ + ("a1", "Fix bug in sidebar", 2), + ("a2", "Add tests for editor", 1), + ] { + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list_a.clone(), + cx, + ) + .await; + } + + // Add a second workspace. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.create_test_workspace(window, cx).detach(); + }); + cx.run_until_parked(); + + let path_list_b = PathList::new::(&[]); + + for (id, title, hour) in [ + ("b1", "Refactor sidebar layout", 3), + ("b2", "Fix typo in README", 1), + ] { + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list_b.clone(), + cx, + ) + .await; + } + cx.run_until_parked(); + + // "alpha" matches the workspace name "alpha-project" but no thread titles. + // The workspace header should appear with all child threads included. + type_in_search(&sidebar, "alpha", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [alpha-project]", + " Fix bug in sidebar <== selected", + " Add tests for editor", + ] + ); + + // "sidebar" matches thread titles in both workspaces but not workspace names. + // Both headers appear with their matching threads. + type_in_search(&sidebar, "sidebar", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [alpha-project]", " Fix bug in sidebar <== selected",] + ); + + // "alpha sidebar" matches the workspace name "alpha-project" (fuzzy: a-l-p-h-a-s-i-d-e-b-a-r + // doesn't match) — but does not match either workspace name or any thread. + // Actually let's test something simpler: a query that matches both a workspace + // name AND some threads in that workspace. Matching threads should still appear. + type_in_search(&sidebar, "fix", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [alpha-project]", " Fix bug in sidebar <== selected",] + ); + + // A query that matches a workspace name AND a thread in that same workspace. + // Both the header (highlighted) and all child threads should appear. + type_in_search(&sidebar, "alpha", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [alpha-project]", + " Fix bug in sidebar <== selected", + " Add tests for editor", + ] + ); + + // Now search for something that matches only a workspace name when there + // are also threads with matching titles — the non-matching workspace's + // threads should still appear if their titles match. + type_in_search(&sidebar, "alp", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [alpha-project]", + " Fix bug in sidebar <== selected", + " Add tests for editor", + ] + ); +} + +#[gpui::test] +async fn test_search_finds_threads_hidden_behind_view_more(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + // Create 8 threads. The oldest one has a unique name and will be + // behind View More (only 5 shown by default). + for i in 0..8u32 { + let title = if i == 0 { + "Hidden gem thread".to_string() + } else { + format!("Thread {}", i + 1) + }; + save_thread_metadata( + acp::SessionId::new(Arc::from(format!("thread-{}", i))), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(), + path_list.clone(), + cx, + ) + .await; + } + cx.run_until_parked(); + + // Confirm the thread is not visible and View More is shown. + let entries = visible_entries_as_strings(&sidebar, cx); + assert!( + entries.iter().any(|e| e.contains("View More")), + "should have View More button" + ); + assert!( + !entries.iter().any(|e| e.contains("Hidden gem")), + "Hidden gem should be behind View More" + ); + + // User searches for the hidden thread — it appears, and View More is gone. + type_in_search(&sidebar, "hidden gem", cx); + let filtered = visible_entries_as_strings(&sidebar, cx); + assert_eq!( + filtered, + vec!["v [my-project]", " Hidden gem thread <== selected",] + ); + assert!( + !filtered.iter().any(|e| e.contains("View More")), + "View More should not appear when filtering" + ); +} + +#[gpui::test] +async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + save_thread_metadata( + acp::SessionId::new(Arc::from("thread-1")), + "Important thread".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + cx.run_until_parked(); + + // User focuses the sidebar and collapses the group using keyboard: + // manually select the header, then press SelectParent to collapse. + open_and_focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(0); + }); + cx.dispatch_action(SelectParent); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["> [my-project] <== selected"] + ); + + // User types a search — the thread appears even though its group is collapsed. + type_in_search(&sidebar, "important", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["> [my-project]", " Important thread <== selected",] + ); +} + +#[gpui::test] +async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + for (id, title, hour) in [ + ("t-1", "Fix crash in panel", 3), + ("t-2", "Fix lint warnings", 2), + ("t-3", "Add new feature", 1), + ] { + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + } + cx.run_until_parked(); + + open_and_focus_sidebar(&sidebar, cx); + + // User types "fix" — two threads match. + type_in_search(&sidebar, "fix", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Fix crash in panel <== selected", + " Fix lint warnings", + ] + ); + + // Selection starts on the first matching thread. User presses + // SelectNext to move to the second match. + cx.dispatch_action(SelectNext); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Fix crash in panel", + " Fix lint warnings <== selected", + ] + ); + + // User can also jump back with SelectPrevious. + cx.dispatch_action(SelectPrevious); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Fix crash in panel <== selected", + " Fix lint warnings", + ] + ); +} + +#[gpui::test] +async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + multi_workspace.update_in(cx, |mw, window, cx| { + mw.create_test_workspace(window, cx).detach(); + }); + cx.run_until_parked(); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + save_thread_metadata( + acp::SessionId::new(Arc::from("hist-1")), + "Historical Thread".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + cx.run_until_parked(); + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Historical Thread",] + ); + + // Switch to workspace 1 so we can verify the confirm switches back. + multi_workspace.update_in(cx, |mw, window, cx| { + let workspace = mw.workspaces()[1].clone(); + mw.activate(workspace, window, cx); + }); + cx.run_until_parked(); + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), + 1 + ); + + // Confirm on the historical (non-live) thread at index 1. + // Before a previous fix, the workspace field was Option and + // historical threads had None, so activate_thread early-returned + // without switching the workspace. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.selection = Some(1); + sidebar.confirm(&Confirm, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), + 0 + ); +} + +#[gpui::test] +async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + save_thread_metadata( + acp::SessionId::new(Arc::from("t-1")), + "Thread A".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + + save_thread_metadata( + acp::SessionId::new(Arc::from("t-2")), + "Thread B".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + + cx.run_until_parked(); + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Thread A", " Thread B",] + ); + + // Keyboard confirm preserves selection. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.selection = Some(1); + sidebar.confirm(&Confirm, window, cx); + }); + assert_eq!( + sidebar.read_with(cx, |sidebar, _| sidebar.selection), + Some(1) + ); + + // Click handlers clear selection to None so no highlight lingers + // after a click regardless of focus state. The hover style provides + // visual feedback during mouse interaction instead. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.selection = None; + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + sidebar.toggle_collapse(&path_list, window, cx); + }); + assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None); + + // When the user tabs back into the sidebar, focus_in no longer + // restores selection — it stays None. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.focus_in(window, cx); + }); + assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None); +} + +#[gpui::test] +async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) { + let project = init_test_project_with_agent_panel("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + let connection = StubAgentConnection::new(); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Hi there!".into()), + )]); + open_thread_with_connection(&panel, connection, cx); + send_message(&panel, cx); + + let session_id = active_session_id(&panel, cx); + save_test_thread_metadata(&session_id, path_list.clone(), cx).await; + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Hello *"] + ); + + // Simulate the agent generating a title. The notification chain is: + // AcpThread::set_title emits TitleUpdated → + // ConnectionView::handle_thread_event calls cx.notify() → + // AgentPanel observer fires and emits AgentPanelEvent → + // Sidebar subscription calls update_entries / rebuild_contents. + // + // Before the fix, handle_thread_event did NOT call cx.notify() for + // TitleUpdated, so the AgentPanel observer never fired and the + // sidebar kept showing the old title. + let thread = panel.read_with(cx, |panel, cx| panel.active_agent_thread(cx).unwrap()); + thread.update(cx, |thread, cx| { + thread + .set_title("Friendly Greeting with AI".into(), cx) + .detach(); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Friendly Greeting with AI *"] + ); +} + +#[gpui::test] +async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) { + let project_a = init_test_project_with_agent_panel("/project-a", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); + let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, &project_a, cx); + + let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); + + // Save a thread so it appears in the list. + let connection_a = StubAgentConnection::new(); + connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Done".into()), + )]); + open_thread_with_connection(&panel_a, connection_a, cx); + send_message(&panel_a, cx); + let session_id_a = active_session_id(&panel_a, cx); + save_test_thread_metadata(&session_id_a, path_list_a.clone(), cx).await; + + // Add a second workspace with its own agent panel. + let fs = cx.update(|_, cx| ::global(cx)); + fs.as_fake() + .insert_tree("/project-b", serde_json::json!({ "src": {} })) + .await; + let project_b = project::Project::test(fs, ["/project-b".as_ref()], cx).await; + let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_b.clone(), window, cx) + }); + let panel_b = add_agent_panel(&workspace_b, &project_b, cx); + cx.run_until_parked(); + + let workspace_a = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].clone()); + + // ── 1. Initial state: focused thread derived from active panel ───── + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id_a), + "The active panel's thread should be focused on startup" + ); + }); + + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.activate_thread( + ThreadMetadata { + session_id: session_id_a.clone(), + agent_id: agent::ZED_AGENT_ID.clone(), + title: "Test".into(), + updated_at: Utc::now(), + created_at: None, + folder_paths: PathList::default(), + archived: false, + }, + &workspace_a, + window, + cx, + ); + }); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id_a), + "After clicking a thread, it should be the focused thread" + ); + assert!( + has_thread_entry(sidebar, &session_id_a), + "The clicked thread should be present in the entries" + ); + }); + + workspace_a.read_with(cx, |workspace, cx| { + assert!( + workspace.panel::(cx).is_some(), + "Agent panel should exist" + ); + let dock = workspace.right_dock().read(cx); + assert!( + dock.is_open(), + "Clicking a thread should open the agent panel dock" + ); + }); + + let connection_b = StubAgentConnection::new(); + connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Thread B".into()), + )]); + open_thread_with_connection(&panel_b, connection_b, cx); + send_message(&panel_b, cx); + let session_id_b = active_session_id(&panel_b, cx); + let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]); + save_test_thread_metadata(&session_id_b, path_list_b.clone(), cx).await; + cx.run_until_parked(); + + // Workspace A is currently active. Click a thread in workspace B, + // which also triggers a workspace switch. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.activate_thread( + ThreadMetadata { + session_id: session_id_b.clone(), + agent_id: agent::ZED_AGENT_ID.clone(), + title: "Thread B".into(), + updated_at: Utc::now(), + created_at: None, + folder_paths: PathList::default(), + archived: false, + }, + &workspace_b, + window, + cx, + ); + }); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id_b), + "Clicking a thread in another workspace should focus that thread" + ); + assert!( + has_thread_entry(sidebar, &session_id_b), + "The cross-workspace thread should be present in the entries" + ); + }); + + multi_workspace.update_in(cx, |mw, window, cx| { + let workspace = mw.workspaces()[0].clone(); + mw.activate(workspace, window, cx); + }); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id_a), + "Switching workspace should seed focused_thread from the new active panel" + ); + assert!( + has_thread_entry(sidebar, &session_id_a), + "The seeded thread should be present in the entries" + ); + }); + + let connection_b2 = StubAgentConnection::new(); + connection_b2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new(DEFAULT_THREAD_TITLE.into()), + )]); + open_thread_with_connection(&panel_b, connection_b2, cx); + send_message(&panel_b, cx); + let session_id_b2 = active_session_id(&panel_b, cx); + save_test_thread_metadata(&session_id_b2, path_list_b.clone(), cx).await; + cx.run_until_parked(); + + // Panel B is not the active workspace's panel (workspace A is + // active), so opening a thread there should not change focused_thread. + // This prevents running threads in background workspaces from causing + // the selection highlight to jump around. + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id_a), + "Opening a thread in a non-active panel should not change focused_thread" + ); + }); + + workspace_b.update_in(cx, |workspace, window, cx| { + workspace.focus_handle(cx).focus(window, cx); + }); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id_a), + "Defocusing the sidebar should not change focused_thread" + ); + }); + + // Switching workspaces via the multi_workspace (simulates clicking + // a workspace header) should clear focused_thread. + multi_workspace.update_in(cx, |mw, window, cx| { + if let Some(index) = mw.workspaces().iter().position(|w| w == &workspace_b) { + let workspace = mw.workspaces()[index].clone(); + mw.activate(workspace, window, cx); + } + }); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id_b2), + "Switching workspace should seed focused_thread from the new active panel" + ); + assert!( + has_thread_entry(sidebar, &session_id_b2), + "The seeded thread should be present in the entries" + ); + }); + + // ── 8. Focusing the agent panel thread keeps focused_thread ──── + // Workspace B still has session_id_b2 loaded in the agent panel. + // Clicking into the thread (simulated by focusing its view) should + // keep focused_thread since it was already seeded on workspace switch. + panel_b.update_in(cx, |panel, window, cx| { + if let Some(thread_view) = panel.active_conversation_view() { + thread_view.read(cx).focus_handle(cx).focus(window, cx); + } + }); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id_b2), + "Focusing the agent panel thread should set focused_thread" + ); + assert!( + has_thread_entry(sidebar, &session_id_b2), + "The focused thread should be present in the entries" + ); + }); +} + +#[gpui::test] +async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContext) { + let project = init_test_project_with_agent_panel("/project-a", cx).await; + let fs = cx.update(|cx| ::global(cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx); + + let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); + + // Start a thread and send a message so it has history. + let connection = StubAgentConnection::new(); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Done".into()), + )]); + open_thread_with_connection(&panel, connection, cx); + send_message(&panel, cx); + let session_id = active_session_id(&panel, cx); + save_test_thread_metadata(&session_id, path_list_a.clone(), cx).await; + cx.run_until_parked(); + + // Verify the thread appears in the sidebar. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project-a]", " Hello *",] + ); + + // The "New Thread" button should NOT be in "active/draft" state + // because the panel has a thread with messages. + sidebar.read_with(cx, |sidebar, cx| { + assert!( + !sidebar.active_thread_is_draft(cx), + "Panel has a thread with messages, so it should not be a draft" + ); + }); + + // Now add a second folder to the workspace, changing the path_list. + fs.as_fake() + .insert_tree("/project-b", serde_json::json!({ "src": {} })) + .await; + project + .update(cx, |project, cx| { + project.find_or_create_worktree("/project-b", true, cx) + }) + .await + .expect("should add worktree"); + cx.run_until_parked(); + + // The workspace path_list is now [project-a, project-b]. The active + // thread's metadata was re-saved with the new paths by the agent panel's + // project subscription, so it stays visible under the updated group. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project-a, project-b]", " Hello *",] + ); + + // The "New Thread" button must still be clickable (not stuck in + // "active/draft" state). Verify that `active_thread_is_draft` is + // false — the panel still has the old thread with messages. + sidebar.read_with(cx, |sidebar, cx| { + assert!( + !sidebar.active_thread_is_draft(cx), + "After adding a folder the panel still has a thread with messages, \ + so active_thread_is_draft should be false" + ); + }); + + // Actually click "New Thread" by calling create_new_thread and + // verify a new draft is created. + let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone()); + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.create_new_thread(&workspace, window, cx); + }); + cx.run_until_parked(); + + // After creating a new thread, the panel should now be in draft + // state (no messages on the new thread). + sidebar.read_with(cx, |sidebar, cx| { + assert!( + sidebar.active_thread_is_draft(cx), + "After creating a new thread the panel should be in draft state" + ); + }); +} + +#[gpui::test] +async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) { + // When the user presses Cmd-N (NewThread action) while viewing a + // non-empty thread, the sidebar should show the "New Thread" entry. + // This exercises the same code path as the workspace action handler + // (which bypasses the sidebar's create_new_thread method). + let project = init_test_project_with_agent_panel("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + // Create a non-empty thread (has messages). + let connection = StubAgentConnection::new(); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Done".into()), + )]); + open_thread_with_connection(&panel, connection, cx); + send_message(&panel, cx); + + let session_id = active_session_id(&panel, cx); + save_test_thread_metadata(&session_id, path_list.clone(), cx).await; + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Hello *"] + ); + + // Simulate cmd-n + let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone()); + panel.update_in(cx, |panel, window, cx| { + panel.new_thread(&NewThread, window, cx); + }); + workspace.update_in(cx, |workspace, window, cx| { + workspace.focus_panel::(window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " [+ New Thread]", " Hello *"], + "After Cmd-N the sidebar should show a highlighted New Thread entry" + ); + + sidebar.read_with(cx, |sidebar, cx| { + assert!( + sidebar.focused_thread.is_none(), + "focused_thread should be cleared after Cmd-N" + ); + assert!( + sidebar.active_thread_is_draft(cx), + "the new blank thread should be a draft" + ); + }); +} + +#[gpui::test] +async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestAppContext) { + // When the active workspace is an absorbed git worktree, cmd-n + // should still show the "New Thread" entry under the main repo's + // header and highlight it as active. + agent_ui::test_support::init_test(cx); + cx.update(|cx| { + cx.update_flags(false, vec!["agent-v2".into()]); + ThreadStore::init_global(cx); + ThreadMetadataStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + prompt_store::init(cx); + }); + + let fs = FakeFs::new(cx.executor()); + + // Main repo with a linked worktree. + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + + // Worktree checkout pointing back to the main repo. + fs.insert_tree( + "/wt-feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-a"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + }); + }) + .unwrap(); + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; + + main_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + worktree_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + + let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(worktree_project.clone(), window, cx) + }); + + let worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx); + + // Switch to the worktree workspace. + multi_workspace.update_in(cx, |mw, window, cx| { + let workspace = mw.workspaces()[1].clone(); + mw.activate(workspace, window, cx); + }); + + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Create a non-empty thread in the worktree workspace. + let connection = StubAgentConnection::new(); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Done".into()), + )]); + open_thread_with_connection(&worktree_panel, connection, cx); + send_message(&worktree_panel, cx); + + let session_id = active_session_id(&worktree_panel, cx); + let wt_path_list = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); + save_test_thread_metadata(&session_id, wt_path_list, cx).await; + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project]", + " [+ New Thread]", + " Hello {wt-feature-a} *" + ] + ); + + // Simulate Cmd-N in the worktree workspace. + worktree_panel.update_in(cx, |panel, window, cx| { + panel.new_thread(&NewThread, window, cx); + }); + worktree_workspace.update_in(cx, |workspace, window, cx| { + workspace.focus_panel::(window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project]", + " [+ New Thread]", + " [+ New Thread {wt-feature-a}]", + " Hello {wt-feature-a} *" + ], + "After Cmd-N in an absorbed worktree, the sidebar should show \ + a highlighted New Thread entry under the main repo header" + ); + + sidebar.read_with(cx, |sidebar, cx| { + assert!( + sidebar.focused_thread.is_none(), + "focused_thread should be cleared after Cmd-N" + ); + assert!( + sidebar.active_thread_is_draft(cx), + "the new blank thread should be a draft" + ); + }); +} + +async fn init_test_project_with_git( + worktree_path: &str, + cx: &mut TestAppContext, +) -> (Entity, Arc) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + worktree_path, + serde_json::json!({ + ".git": {}, + "src": {}, + }), + ) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + let project = project::Project::test(fs.clone(), [worktree_path.as_ref()], cx).await; + (project, fs) +} + +#[gpui::test] +async fn test_search_matches_worktree_name(cx: &mut TestAppContext) { + let (project, fs) = init_test_project_with_git("/project", cx).await; + + fs.as_fake() + .with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt/rosewood"), + ref_name: Some("refs/heads/rosewood".into()), + sha: "abc".into(), + }); + }) + .unwrap(); + + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let main_paths = PathList::new(&[std::path::PathBuf::from("/project")]); + let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]); + save_named_thread_metadata("main-t", "Unrelated Thread", &main_paths, cx).await; + save_named_thread_metadata("wt-t", "Fix Bug", &wt_paths, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Search for "rosewood" — should match the worktree name, not the title. + type_in_search(&sidebar, "rosewood", cx); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project]", " Fix Bug {rosewood} <== selected"], + ); +} + +#[gpui::test] +async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) { + let (project, fs) = init_test_project_with_git("/project", cx).await; + + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Save a thread against a worktree path that doesn't exist yet. + let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]); + save_named_thread_metadata("wt-thread", "Worktree Thread", &wt_paths, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Thread is not visible yet — no worktree knows about this path. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project]", " [+ New Thread]"] + ); + + // Now add the worktree to the git state and trigger a rescan. + fs.as_fake() + .with_git_state(std::path::Path::new("/project/.git"), true, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt/rosewood"), + ref_name: Some("refs/heads/rosewood".into()), + sha: "abc".into(), + }); + }) + .unwrap(); + + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project]", + " [+ New Thread]", + " Worktree Thread {rosewood}", + ] + ); +} + +#[gpui::test] +async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + // Create the main repo directory (not opened as a workspace yet). + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + "feature-b": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-b", + }, + }, + }, + "src": {}, + }), + ) + .await; + + // Two worktree checkouts whose .git files point back to the main repo. + fs.insert_tree( + "/wt-feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/wt-feature-b", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-b", + "src": {}, + }), + ) + .await; + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; + let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await; + + project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await; + project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await; + + // Open both worktrees as workspaces — no main repo yet. + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); + multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_b.clone(), window, cx); + }); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let paths_a = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); + let paths_b = PathList::new(&[std::path::PathBuf::from("/wt-feature-b")]); + save_named_thread_metadata("thread-a", "Thread A", &paths_a, cx).await; + save_named_thread_metadata("thread-b", "Thread B", &paths_b, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Without the main repo, each worktree has its own header. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project]", + " Thread A {wt-feature-a}", + " Thread B {wt-feature-b}", + ] + ); + + // Configure the main repo to list both worktrees before opening + // it so the initial git scan picks them up. + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-a"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + }); + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-b"), + ref_name: Some("refs/heads/feature-b".into()), + sha: "bbb".into(), + }); + }) + .unwrap(); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + main_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(main_project.clone(), window, cx); + }); + cx.run_until_parked(); + + // Both worktree workspaces should now be absorbed under the main + // repo header, with worktree chips. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project]", + " [+ New Thread]", + " Thread A {wt-feature-a}", + " Thread B {wt-feature-b}", + ] + ); +} + +#[gpui::test] +async fn test_threadless_workspace_shows_new_thread_with_worktree_chip(cx: &mut TestAppContext) { + // When a group has two workspaces — one with threads and one + // without — the threadless workspace should appear as a + // "New Thread" button with its worktree chip. + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + // Main repo with two linked worktrees. + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + "feature-b": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-b", + }, + }, + }, + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/wt-feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/wt-feature-b", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-b", + "src": {}, + }), + ) + .await; + + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-a"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + }); + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-b"), + ref_name: Some("refs/heads/feature-b".into()), + sha: "bbb".into(), + }); + }) + .unwrap(); + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + // Workspace A: worktree feature-a (has threads). + let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; + project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await; + + // Workspace B: worktree feature-b (no threads). + let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await; + project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); + multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_b.clone(), window, cx); + }); + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Only save a thread for workspace A. + let paths_a = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); + save_named_thread_metadata("thread-a", "Thread A", &paths_a, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Workspace A's thread appears normally. Workspace B (threadless) + // appears as a "New Thread" button with its worktree chip. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project]", + " [+ New Thread {wt-feature-b}]", + " Thread A {wt-feature-a}", + ] + ); +} + +#[gpui::test] +async fn test_multi_worktree_thread_shows_multiple_chips(cx: &mut TestAppContext) { + // A thread created in a workspace with roots from different git + // worktrees should show a chip for each distinct worktree name. + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + // Two main repos. + fs.insert_tree( + "/project_a", + serde_json::json!({ + ".git": { + "worktrees": { + "olivetti": { + "commondir": "../../", + "HEAD": "ref: refs/heads/olivetti", + }, + "selectric": { + "commondir": "../../", + "HEAD": "ref: refs/heads/selectric", + }, + }, + }, + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/project_b", + serde_json::json!({ + ".git": { + "worktrees": { + "olivetti": { + "commondir": "../../", + "HEAD": "ref: refs/heads/olivetti", + }, + "selectric": { + "commondir": "../../", + "HEAD": "ref: refs/heads/selectric", + }, + }, + }, + "src": {}, + }), + ) + .await; + + // Worktree checkouts. + for (repo, branch) in &[ + ("project_a", "olivetti"), + ("project_a", "selectric"), + ("project_b", "olivetti"), + ("project_b", "selectric"), + ] { + let worktree_path = format!("/worktrees/{repo}/{branch}/{repo}"); + let gitdir = format!("gitdir: /{repo}/.git/worktrees/{branch}"); + fs.insert_tree( + &worktree_path, + serde_json::json!({ + ".git": gitdir, + "src": {}, + }), + ) + .await; + } + + // Register linked worktrees. + for repo in &["project_a", "project_b"] { + let git_path = format!("/{repo}/.git"); + fs.with_git_state(std::path::Path::new(&git_path), false, |state| { + for branch in &["olivetti", "selectric"] { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from(format!("/worktrees/{repo}/{branch}/{repo}")), + ref_name: Some(format!("refs/heads/{branch}").into()), + sha: "aaa".into(), + }); + } + }) + .unwrap(); + } + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + // Open a workspace with the worktree checkout paths as roots + // (this is the workspace the thread was created in). + let project = project::Project::test( + fs.clone(), + [ + "/worktrees/project_a/olivetti/project_a".as_ref(), + "/worktrees/project_b/selectric/project_b".as_ref(), + ], + cx, + ) + .await; + project.update(cx, |p, cx| p.git_scans_complete(cx)).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Save a thread under the same paths as the workspace roots. + let thread_paths = PathList::new(&[ + std::path::PathBuf::from("/worktrees/project_a/olivetti/project_a"), + std::path::PathBuf::from("/worktrees/project_b/selectric/project_b"), + ]); + save_named_thread_metadata("wt-thread", "Cross Worktree Thread", &thread_paths, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Should show two distinct worktree chips. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project_a, project_b]", + " Cross Worktree Thread {olivetti}, {selectric}", + ] + ); +} + +#[gpui::test] +async fn test_same_named_worktree_chips_are_deduplicated(cx: &mut TestAppContext) { + // When a thread's roots span multiple repos but share the same + // worktree name (e.g. both in "olivetti"), only one chip should + // appear. + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + fs.insert_tree( + "/project_a", + serde_json::json!({ + ".git": { + "worktrees": { + "olivetti": { + "commondir": "../../", + "HEAD": "ref: refs/heads/olivetti", + }, + }, + }, + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/project_b", + serde_json::json!({ + ".git": { + "worktrees": { + "olivetti": { + "commondir": "../../", + "HEAD": "ref: refs/heads/olivetti", + }, + }, + }, + "src": {}, + }), + ) + .await; + + for repo in &["project_a", "project_b"] { + let worktree_path = format!("/worktrees/{repo}/olivetti/{repo}"); + let gitdir = format!("gitdir: /{repo}/.git/worktrees/olivetti"); + fs.insert_tree( + &worktree_path, + serde_json::json!({ + ".git": gitdir, + "src": {}, + }), + ) + .await; + + let git_path = format!("/{repo}/.git"); + fs.with_git_state(std::path::Path::new(&git_path), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from(format!("/worktrees/{repo}/olivetti/{repo}")), + ref_name: Some("refs/heads/olivetti".into()), + sha: "aaa".into(), + }); + }) + .unwrap(); + } + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project = project::Project::test( + fs.clone(), + [ + "/worktrees/project_a/olivetti/project_a".as_ref(), + "/worktrees/project_b/olivetti/project_b".as_ref(), + ], + cx, + ) + .await; + project.update(cx, |p, cx| p.git_scans_complete(cx)).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Thread with roots in both repos' "olivetti" worktrees. + let thread_paths = PathList::new(&[ + std::path::PathBuf::from("/worktrees/project_a/olivetti/project_a"), + std::path::PathBuf::from("/worktrees/project_b/olivetti/project_b"), + ]); + save_named_thread_metadata("wt-thread", "Same Branch Thread", &thread_paths, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Both worktree paths have the name "olivetti", so only one chip. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project_a, project_b]", + " Same Branch Thread {olivetti}", + ] + ); +} + +#[gpui::test] +async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAppContext) { + // When a worktree workspace is absorbed under the main repo, a + // running thread in the worktree's agent panel should still show + // live status (spinner + "(running)") in the sidebar. + agent_ui::test_support::init_test(cx); + cx.update(|cx| { + cx.update_flags(false, vec!["agent-v2".into()]); + ThreadStore::init_global(cx); + ThreadMetadataStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + prompt_store::init(cx); + }); + + let fs = FakeFs::new(cx.executor()); + + // Main repo with a linked worktree. + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + + // Worktree checkout pointing back to the main repo. + fs.insert_tree( + "/wt-feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-a"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + }); + }) + .unwrap(); + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; + + main_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + worktree_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + // Create the MultiWorkspace with both projects. + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + + let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(worktree_project.clone(), window, cx) + }); + + // Add an agent panel to the worktree workspace so we can run a + // thread inside it. + let worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx); + + // Switch back to the main workspace before setting up the sidebar. + multi_workspace.update_in(cx, |mw, window, cx| { + let workspace = mw.workspaces()[0].clone(); + mw.activate(workspace, window, cx); + }); + + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Start a thread in the worktree workspace's panel and keep it + // generating (don't resolve it). + let connection = StubAgentConnection::new(); + open_thread_with_connection(&worktree_panel, connection.clone(), cx); + send_message(&worktree_panel, cx); + + let session_id = active_session_id(&worktree_panel, cx); + + // Save metadata so the sidebar knows about this thread. + let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); + save_test_thread_metadata(&session_id, wt_paths, cx).await; + + // Keep the thread generating by sending a chunk without ending + // the turn. + cx.update(|_, cx| { + connection.send_update( + session_id.clone(), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())), + cx, + ); + }); + cx.run_until_parked(); + + // The worktree thread should be absorbed under the main project + // and show live running status. + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!( + entries, + vec![ + "v [project]", + " [+ New Thread]", + " Hello {wt-feature-a} * (running)", + ] + ); +} + +#[gpui::test] +async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAppContext) { + agent_ui::test_support::init_test(cx); + cx.update(|cx| { + cx.update_flags(false, vec!["agent-v2".into()]); + ThreadStore::init_global(cx); + ThreadMetadataStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + prompt_store::init(cx); + }); + + let fs = FakeFs::new(cx.executor()); + + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + + fs.insert_tree( + "/wt-feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-a"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + }); + }) + .unwrap(); + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; + + main_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + worktree_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + + let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(worktree_project.clone(), window, cx) + }); + + let worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx); + + multi_workspace.update_in(cx, |mw, window, cx| { + let workspace = mw.workspaces()[0].clone(); + mw.activate(workspace, window, cx); + }); + + let sidebar = setup_sidebar(&multi_workspace, cx); + + let connection = StubAgentConnection::new(); + open_thread_with_connection(&worktree_panel, connection.clone(), cx); + send_message(&worktree_panel, cx); + + let session_id = active_session_id(&worktree_panel, cx); + let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); + save_test_thread_metadata(&session_id, wt_paths, cx).await; + + cx.update(|_, cx| { + connection.send_update( + session_id.clone(), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())), + cx, + ); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project]", + " [+ New Thread]", + " Hello {wt-feature-a} * (running)", + ] + ); + + connection.end_turn(session_id, acp::StopReason::EndTurn); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project]", + " [+ New Thread]", + " Hello {wt-feature-a} * (!)", + ] + ); +} + +#[gpui::test] +async fn test_clicking_worktree_thread_opens_workspace_when_none_exists(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + + fs.insert_tree( + "/wt-feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-a"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + }); + }) + .unwrap(); + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + // Only open the main repo — no workspace for the worktree. + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + main_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Save a thread for the worktree path (no workspace for it). + let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); + save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Thread should appear under the main repo with a worktree chip. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project]", + " [+ New Thread]", + " WT Thread {wt-feature-a}" + ], + ); + + // Only 1 workspace should exist. + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()), + 1, + ); + + // Focus the sidebar and select the worktree thread. + open_and_focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(2); // index 0 is header, 1 is new thread, 2 is the thread + }); + + // Confirm to open the worktree thread. + cx.dispatch_action(Confirm); + cx.run_until_parked(); + + // A new workspace should have been created for the worktree path. + let new_workspace = multi_workspace.read_with(cx, |mw, _| { + assert_eq!( + mw.workspaces().len(), + 2, + "confirming a worktree thread without a workspace should open one", + ); + mw.workspaces()[1].clone() + }); + + let new_path_list = + new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx)); + assert_eq!( + new_path_list, + PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]), + "the new workspace should have been opened for the worktree path", + ); +} + +#[gpui::test] +async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_project( + cx: &mut TestAppContext, +) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + + fs.insert_tree( + "/wt-feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-a"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + }); + }) + .unwrap(); + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + main_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); + save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project]", + " [+ New Thread]", + " WT Thread {wt-feature-a}" + ], + ); + + open_and_focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(2); + }); + + let assert_sidebar_state = |sidebar: &mut Sidebar, _cx: &mut Context| { + let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| { + if let ListEntry::ProjectHeader { label, .. } = entry { + Some(label.as_ref()) + } else { + None + } + }); + + let Some(project_header) = project_headers.next() else { + panic!("expected exactly one sidebar project header named `project`, found none"); + }; + assert_eq!( + project_header, "project", + "expected the only sidebar project header to be `project`" + ); + if let Some(unexpected_header) = project_headers.next() { + panic!( + "expected exactly one sidebar project header named `project`, found extra header `{unexpected_header}`" + ); + } + + let mut saw_expected_thread = false; + for entry in &sidebar.contents.entries { + match entry { + ListEntry::ProjectHeader { label, .. } => { + assert_eq!( + label.as_ref(), + "project", + "expected the only sidebar project header to be `project`" + ); + } + ListEntry::Thread(thread) + if thread.metadata.title.as_ref() == "WT Thread" + && thread.worktrees.first().map(|wt| wt.name.as_ref()) + == Some("wt-feature-a") => + { + saw_expected_thread = true; + } + ListEntry::Thread(thread) => { + let title = thread.metadata.title.as_ref(); + let worktree_name = thread + .worktrees + .first() + .map(|wt| wt.name.as_ref()) + .unwrap_or(""); + panic!( + "unexpected sidebar thread while opening linked worktree thread: title=`{title}`, worktree=`{worktree_name}`" + ); + } + ListEntry::ViewMore { .. } => { + panic!("unexpected `View More` entry while opening linked worktree thread"); + } + ListEntry::NewThread { .. } => {} + } + } + + assert!( + saw_expected_thread, + "expected the sidebar to keep showing `WT Thread {{wt-feature-a}}` under `project`" + ); + }; + + sidebar + .update(cx, |_, cx| cx.observe_self(assert_sidebar_state)) + .detach(); + + let window = cx.windows()[0]; + cx.update_window(window, |_, window, cx| { + window.dispatch_action(Confirm.boxed_clone(), cx); + }) + .unwrap(); + + cx.run_until_parked(); + + sidebar.update(cx, assert_sidebar_state); +} + +#[gpui::test] +async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace( + cx: &mut TestAppContext, +) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + + fs.insert_tree( + "/wt-feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-a"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + }); + }) + .unwrap(); + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; + + main_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + worktree_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + + let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(worktree_project.clone(), window, cx) + }); + + // Activate the main workspace before setting up the sidebar. + multi_workspace.update_in(cx, |mw, window, cx| { + let workspace = mw.workspaces()[0].clone(); + mw.activate(workspace, window, cx); + }); + + let sidebar = setup_sidebar(&multi_workspace, cx); + + let paths_main = PathList::new(&[std::path::PathBuf::from("/project")]); + let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); + save_named_thread_metadata("thread-main", "Main Thread", &paths_main, cx).await; + save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // The worktree workspace should be absorbed under the main repo. + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!(entries.len(), 3); + assert_eq!(entries[0], "v [project]"); + assert!(entries.contains(&" Main Thread".to_string())); + assert!(entries.contains(&" WT Thread {wt-feature-a}".to_string())); + + let wt_thread_index = entries + .iter() + .position(|e| e.contains("WT Thread")) + .expect("should find the worktree thread entry"); + + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), + 0, + "main workspace should be active initially" + ); + + // Focus the sidebar and select the absorbed worktree thread. + open_and_focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(wt_thread_index); + }); + + // Confirm to activate the worktree thread. + cx.dispatch_action(Confirm); + cx.run_until_parked(); + + // The worktree workspace should now be active, not the main one. + let active_workspace = multi_workspace.read_with(cx, |mw, _| { + mw.workspaces()[mw.active_workspace_index()].clone() + }); + assert_eq!( + active_workspace, worktree_workspace, + "clicking an absorbed worktree thread should activate the worktree workspace" + ); +} + +#[gpui::test] +async fn test_activate_archived_thread_with_saved_paths_activates_matching_workspace( + cx: &mut TestAppContext, +) { + // Thread has saved metadata in ThreadStore. A matching workspace is + // already open. Expected: activates the matching workspace. + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) + .await; + fs.insert_tree("/project-b", serde_json::json!({ "src": {} })) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; + let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + + multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_b, window, cx); + }); + + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Save a thread with path_list pointing to project-b. + let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]); + let session_id = acp::SessionId::new(Arc::from("archived-1")); + save_test_thread_metadata(&session_id, path_list_b.clone(), cx).await; + + // Ensure workspace A is active. + multi_workspace.update_in(cx, |mw, window, cx| { + let workspace = mw.workspaces()[0].clone(); + mw.activate(workspace, window, cx); + }); + cx.run_until_parked(); + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), + 0 + ); + + // Call activate_archived_thread – should resolve saved paths and + // switch to the workspace for project-b. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.activate_archived_thread( + ThreadMetadata { + session_id: session_id.clone(), + agent_id: agent::ZED_AGENT_ID.clone(), + title: "Archived Thread".into(), + updated_at: Utc::now(), + created_at: None, + folder_paths: PathList::new(&[PathBuf::from("/project-b")]), + archived: false, + }, + window, + cx, + ); + }); + cx.run_until_parked(); + + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), + 1, + "should have activated the workspace matching the saved path_list" + ); +} + +#[gpui::test] +async fn test_activate_archived_thread_cwd_fallback_with_matching_workspace( + cx: &mut TestAppContext, +) { + // Thread has no saved metadata but session_info has cwd. A matching + // workspace is open. Expected: uses cwd to find and activate it. + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) + .await; + fs.insert_tree("/project-b", serde_json::json!({ "src": {} })) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; + let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + + multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_b, window, cx); + }); + + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Start with workspace A active. + multi_workspace.update_in(cx, |mw, window, cx| { + let workspace = mw.workspaces()[0].clone(); + mw.activate(workspace, window, cx); + }); + cx.run_until_parked(); + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), + 0 + ); + + // No thread saved to the store – cwd is the only path hint. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.activate_archived_thread( + ThreadMetadata { + session_id: acp::SessionId::new(Arc::from("unknown-session")), + agent_id: agent::ZED_AGENT_ID.clone(), + title: "CWD Thread".into(), + updated_at: Utc::now(), + created_at: None, + folder_paths: PathList::new(&[std::path::PathBuf::from("/project-b")]), + archived: false, + }, + window, + cx, + ); + }); + cx.run_until_parked(); + + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), + 1, + "should have activated the workspace matching the cwd" + ); +} + +#[gpui::test] +async fn test_activate_archived_thread_no_paths_no_cwd_uses_active_workspace( + cx: &mut TestAppContext, +) { + // Thread has no saved metadata and no cwd. Expected: falls back to + // the currently active workspace. + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) + .await; + fs.insert_tree("/project-b", serde_json::json!({ "src": {} })) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; + let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + + multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_b, window, cx); + }); + + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Activate workspace B (index 1) to make it the active one. + multi_workspace.update_in(cx, |mw, window, cx| { + let workspace = mw.workspaces()[1].clone(); + mw.activate(workspace, window, cx); + }); + cx.run_until_parked(); + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), + 1 + ); + + // No saved thread, no cwd – should fall back to the active workspace. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.activate_archived_thread( + ThreadMetadata { + session_id: acp::SessionId::new(Arc::from("no-context-session")), + agent_id: agent::ZED_AGENT_ID.clone(), + title: "Contextless Thread".into(), + updated_at: Utc::now(), + created_at: None, + folder_paths: PathList::default(), + archived: false, + }, + window, + cx, + ); + }); + cx.run_until_parked(); + + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), + 1, + "should have stayed on the active workspace when no path info is available" + ); +} + +#[gpui::test] +async fn test_activate_archived_thread_saved_paths_opens_new_workspace(cx: &mut TestAppContext) { + // Thread has saved metadata pointing to a path with no open workspace. + // Expected: opens a new workspace for that path. + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) + .await; + fs.insert_tree("/project-b", serde_json::json!({ "src": {} })) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Save a thread with path_list pointing to project-b – which has no + // open workspace. + let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]); + let session_id = acp::SessionId::new(Arc::from("archived-new-ws")); + + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()), + 1, + "should start with one workspace" + ); + + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.activate_archived_thread( + ThreadMetadata { + session_id: session_id.clone(), + agent_id: agent::ZED_AGENT_ID.clone(), + title: "New WS Thread".into(), + updated_at: Utc::now(), + created_at: None, + folder_paths: path_list_b, + archived: false, + }, + window, + cx, + ); + }); + cx.run_until_parked(); + + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()), + 2, + "should have opened a second workspace for the archived thread's saved paths" + ); +} + +#[gpui::test] +async fn test_activate_archived_thread_reuses_workspace_in_another_window(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) + .await; + fs.insert_tree("/project-b", serde_json::json!({ "src": {} })) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; + let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await; + + let multi_workspace_a = + cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + let multi_workspace_b = + cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx)); + + let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap(); + + let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx); + let sidebar = setup_sidebar(&multi_workspace_a_entity, cx_a); + + let session_id = acp::SessionId::new(Arc::from("archived-cross-window")); + + sidebar.update_in(cx_a, |sidebar, window, cx| { + sidebar.activate_archived_thread( + ThreadMetadata { + session_id: session_id.clone(), + agent_id: agent::ZED_AGENT_ID.clone(), + title: "Cross Window Thread".into(), + updated_at: Utc::now(), + created_at: None, + folder_paths: PathList::new(&[PathBuf::from("/project-b")]), + archived: false, + }, + window, + cx, + ); + }); + cx_a.run_until_parked(); + + assert_eq!( + multi_workspace_a + .read_with(cx_a, |mw, _| mw.workspaces().len()) + .unwrap(), + 1, + "should not add the other window's workspace into the current window" + ); + assert_eq!( + multi_workspace_b + .read_with(cx_a, |mw, _| mw.workspaces().len()) + .unwrap(), + 1, + "should reuse the existing workspace in the other window" + ); + assert!( + cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b, + "should activate the window that already owns the matching workspace" + ); + sidebar.read_with(cx_a, |sidebar, _| { + assert_eq!( + sidebar.focused_thread, None, + "source window's sidebar should not eagerly claim focus for a thread opened in another window" + ); + }); +} + +#[gpui::test] +async fn test_activate_archived_thread_reuses_workspace_in_another_window_with_target_sidebar( + cx: &mut TestAppContext, +) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) + .await; + fs.insert_tree("/project-b", serde_json::json!({ "src": {} })) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; + let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await; + + let multi_workspace_a = + cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + let multi_workspace_b = + cx.add_window(|window, cx| MultiWorkspace::test_new(project_b.clone(), window, cx)); + + let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap(); + let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap(); + + let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx); + let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a); + + let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx); + let sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b); + let workspace_b = multi_workspace_b_entity.read_with(cx_b, |mw, _| mw.workspace().clone()); + let _panel_b = add_agent_panel(&workspace_b, &project_b, cx_b); + + let session_id = acp::SessionId::new(Arc::from("archived-cross-window-with-sidebar")); + + sidebar_a.update_in(cx_a, |sidebar, window, cx| { + sidebar.activate_archived_thread( + ThreadMetadata { + session_id: session_id.clone(), + agent_id: agent::ZED_AGENT_ID.clone(), + title: "Cross Window Thread".into(), + updated_at: Utc::now(), + created_at: None, + folder_paths: PathList::new(&[PathBuf::from("/project-b")]), + archived: false, + }, + window, + cx, + ); + }); + cx_a.run_until_parked(); + + assert_eq!( + multi_workspace_a + .read_with(cx_a, |mw, _| mw.workspaces().len()) + .unwrap(), + 1, + "should not add the other window's workspace into the current window" + ); + assert_eq!( + multi_workspace_b + .read_with(cx_a, |mw, _| mw.workspaces().len()) + .unwrap(), + 1, + "should reuse the existing workspace in the other window" + ); + assert!( + cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b, + "should activate the window that already owns the matching workspace" + ); + sidebar_a.read_with(cx_a, |sidebar, _| { + assert_eq!( + sidebar.focused_thread, None, + "source window's sidebar should not eagerly claim focus for a thread opened in another window" + ); + }); + sidebar_b.read_with(cx_b, |sidebar, _| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id), + "target window's sidebar should eagerly focus the activated archived thread" + ); + }); +} + +#[gpui::test] +async fn test_activate_archived_thread_prefers_current_window_for_matching_paths( + cx: &mut TestAppContext, +) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project_b = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; + let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; + + let multi_workspace_b = + cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx)); + let multi_workspace_a = + cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + + let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap(); + + let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx); + let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a); + + let session_id = acp::SessionId::new(Arc::from("archived-current-window")); + + sidebar_a.update_in(cx_a, |sidebar, window, cx| { + sidebar.activate_archived_thread( + ThreadMetadata { + session_id: session_id.clone(), + agent_id: agent::ZED_AGENT_ID.clone(), + title: "Current Window Thread".into(), + updated_at: Utc::now(), + created_at: None, + folder_paths: PathList::new(&[PathBuf::from("/project-a")]), + archived: false, + }, + window, + cx, + ); + }); + cx_a.run_until_parked(); + + assert!( + cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_a, + "should keep activation in the current window when it already has a matching workspace" + ); + sidebar_a.read_with(cx_a, |sidebar, _| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id), + "current window's sidebar should eagerly focus the activated archived thread" + ); + }); + assert_eq!( + multi_workspace_a + .read_with(cx_a, |mw, _| mw.workspaces().len()) + .unwrap(), + 1, + "current window should continue reusing its existing workspace" + ); + assert_eq!( + multi_workspace_b + .read_with(cx_a, |mw, _| mw.workspaces().len()) + .unwrap(), + 1, + "other windows should not be activated just because they also match the saved paths" + ); +} + +#[gpui::test] +async fn test_archive_thread_uses_next_threads_own_workspace(cx: &mut TestAppContext) { + // Regression test: archive_thread previously always loaded the next thread + // through group_workspace (the main workspace's ProjectHeader), even when + // the next thread belonged to an absorbed linked-worktree workspace. That + // caused the worktree thread to be loaded in the main panel, which bound it + // to the main project and corrupted its stored folder_paths. + // + // The fix: use next.workspace (ThreadEntryWorkspace::Open) when available, + // falling back to group_workspace only for Closed workspaces. + agent_ui::test_support::init_test(cx); + cx.update(|cx| { + cx.update_flags(false, vec!["agent-v2".into()]); + ThreadStore::init_global(cx); + ThreadMetadataStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + prompt_store::init(cx); + }); + + let fs = FakeFs::new(cx.executor()); + + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + + fs.insert_tree( + "/wt-feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-a"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + }); + }) + .unwrap(); + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; + + main_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + worktree_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + + let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(worktree_project.clone(), window, cx) + }); + + // Activate main workspace so the sidebar tracks the main panel. + multi_workspace.update_in(cx, |mw, window, cx| { + let workspace = mw.workspaces()[0].clone(); + mw.activate(workspace, window, cx); + }); + + let sidebar = setup_sidebar(&multi_workspace, cx); + + let main_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspaces()[0].clone()); + let main_panel = add_agent_panel(&main_workspace, &main_project, cx); + let _worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx); + + // Open Thread 2 in the main panel and keep it running. + let connection = StubAgentConnection::new(); + open_thread_with_connection(&main_panel, connection.clone(), cx); + send_message(&main_panel, cx); + + let thread2_session_id = active_session_id(&main_panel, cx); + + cx.update(|_, cx| { + connection.send_update( + thread2_session_id.clone(), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())), + cx, + ); + }); + + // Save thread 2's metadata with a newer timestamp so it sorts above thread 1. + save_thread_metadata( + thread2_session_id.clone(), + "Thread 2".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + PathList::new(&[std::path::PathBuf::from("/project")]), + cx, + ) + .await; + + // Save thread 1's metadata with the worktree path and an older timestamp so + // it sorts below thread 2. archive_thread will find it as the "next" candidate. + let thread1_session_id = acp::SessionId::new(Arc::from("thread1-worktree-session")); + save_thread_metadata( + thread1_session_id.clone(), + "Thread 1".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]), + cx, + ) + .await; + + cx.run_until_parked(); + + // Verify the sidebar absorbed thread 1 under [project] with the worktree chip. + let entries_before = visible_entries_as_strings(&sidebar, cx); + assert!( + entries_before.iter().any(|s| s.contains("{wt-feature-a}")), + "Thread 1 should appear with the linked-worktree chip before archiving: {:?}", + entries_before + ); + + // The sidebar should track T2 as the focused thread (derived from the + // main panel's active view). + let focused = sidebar.read_with(cx, |s, _| s.focused_thread.clone()); + assert_eq!( + focused, + Some(thread2_session_id.clone()), + "focused thread should be Thread 2 before archiving: {:?}", + focused + ); + + // Archive thread 2. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.archive_thread(&thread2_session_id, window, cx); + }); + + cx.run_until_parked(); + + // The main panel's active thread must still be thread 2. + let main_active = main_panel.read_with(cx, |panel, cx| { + panel + .active_agent_thread(cx) + .map(|t| t.read(cx).session_id().clone()) + }); + assert_eq!( + main_active, + Some(thread2_session_id.clone()), + "main panel should not have been taken over by loading the linked-worktree thread T1; \ + before the fix, archive_thread used group_workspace instead of next.workspace, \ + causing T1 to be loaded in the wrong panel" + ); + + // Thread 1 should still appear in the sidebar with its worktree chip + // (Thread 2 was archived so it is gone from the list). + let entries_after = visible_entries_as_strings(&sidebar, cx); + assert!( + entries_after.iter().any(|s| s.contains("{wt-feature-a}")), + "T1 should still carry its linked-worktree chip after archiving T2: {:?}", + entries_after + ); +} + +#[gpui::test] +async fn test_linked_worktree_threads_not_duplicated_across_groups(cx: &mut TestAppContext) { + // When a multi-root workspace (e.g. [/other, /project]) shares a + // repo with a single-root workspace (e.g. [/project]), linked + // worktree threads from the shared repo should only appear under + // the dedicated group [project], not under [other, project]. + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + // Two independent repos, each with their own git history. + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/wt-feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/other", + serde_json::json!({ + ".git": {}, + "src": {}, + }), + ) + .await; + + // Register the linked worktree in the main repo. + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-a"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + }); + }) + .unwrap(); + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + // Workspace 1: just /project. + let project_only = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + project_only + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + // Workspace 2: /other and /project together (multi-root). + let multi_root = + project::Project::test(fs.clone(), ["/other".as_ref(), "/project".as_ref()], cx).await; + multi_root + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_only.clone(), window, cx)); + multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(multi_root.clone(), window, cx); + }); + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Save a thread under the linked worktree path. + let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); + save_named_thread_metadata("wt-thread", "Worktree Thread", &wt_paths, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // The thread should appear only under [project] (the dedicated + // group for the /project repo), not under [other, project]. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project]", + " [+ New Thread]", + " Worktree Thread {wt-feature-a}", + "v [other, project]", + " [+ New Thread]", + ] + ); +} + +#[gpui::test] +async fn test_thread_switcher_ordering(cx: &mut TestAppContext) { + let project = init_test_project_with_agent_panel("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + let switcher_ids = + |sidebar: &Entity, cx: &mut gpui::VisualTestContext| -> Vec { + sidebar.read_with(cx, |sidebar, cx| { + let switcher = sidebar + .thread_switcher + .as_ref() + .expect("switcher should be open"); + switcher + .read(cx) + .entries() + .iter() + .map(|e| e.session_id.clone()) + .collect() + }) + }; + + let switcher_selected_id = + |sidebar: &Entity, cx: &mut gpui::VisualTestContext| -> acp::SessionId { + sidebar.read_with(cx, |sidebar, cx| { + let switcher = sidebar + .thread_switcher + .as_ref() + .expect("switcher should be open"); + let s = switcher.read(cx); + s.selected_entry() + .expect("should have selection") + .session_id + .clone() + }) + }; + + // ── Setup: create three threads with distinct created_at times ────── + // Thread C (oldest), Thread B, Thread A (newest) — by created_at. + // We send messages in each so they also get last_message_sent_or_queued timestamps. + let connection_c = StubAgentConnection::new(); + connection_c.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Done C".into()), + )]); + open_thread_with_connection(&panel, connection_c, cx); + send_message(&panel, cx); + let session_id_c = active_session_id(&panel, cx); + cx.update(|_, cx| { + ThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.save( + ThreadMetadata { + session_id: session_id_c.clone(), + agent_id: agent::ZED_AGENT_ID.clone(), + title: "Thread C".into(), + updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0) + .unwrap(), + created_at: Some( + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + ), + folder_paths: path_list.clone(), + archived: false, + }, + cx, + ) + }) + }); + cx.run_until_parked(); + + let connection_b = StubAgentConnection::new(); + connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Done B".into()), + )]); + open_thread_with_connection(&panel, connection_b, cx); + send_message(&panel, cx); + let session_id_b = active_session_id(&panel, cx); + cx.update(|_, cx| { + ThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.save( + ThreadMetadata { + session_id: session_id_b.clone(), + agent_id: agent::ZED_AGENT_ID.clone(), + title: "Thread B".into(), + updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0) + .unwrap(), + created_at: Some( + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + ), + folder_paths: path_list.clone(), + archived: false, + }, + cx, + ) + }) + }); + cx.run_until_parked(); + + let connection_a = StubAgentConnection::new(); + connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Done A".into()), + )]); + open_thread_with_connection(&panel, connection_a, cx); + send_message(&panel, cx); + let session_id_a = active_session_id(&panel, cx); + cx.update(|_, cx| { + ThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.save( + ThreadMetadata { + session_id: session_id_a.clone(), + agent_id: agent::ZED_AGENT_ID.clone(), + title: "Thread A".into(), + updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0) + .unwrap(), + created_at: Some( + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(), + ), + folder_paths: path_list.clone(), + archived: false, + }, + cx, + ) + }) + }); + cx.run_until_parked(); + + // All three threads are now live. Thread A was opened last, so it's + // the one being viewed. Opening each thread called record_thread_access, + // so all three have last_accessed_at set. + // Access order is: A (most recent), B, C (oldest). + + // ── 1. Open switcher: threads sorted by last_accessed_at ─────────── + open_and_focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx); + }); + cx.run_until_parked(); + + // All three have last_accessed_at, so they sort by access time. + // A was accessed most recently (it's the currently viewed thread), + // then B, then C. + assert_eq!( + switcher_ids(&sidebar, cx), + vec![ + session_id_a.clone(), + session_id_b.clone(), + session_id_c.clone() + ], + ); + // First ctrl-tab selects the second entry (B). + assert_eq!(switcher_selected_id(&sidebar, cx), session_id_b); + + // Dismiss the switcher without confirming. + sidebar.update_in(cx, |sidebar, _window, cx| { + sidebar.dismiss_thread_switcher(cx); + }); + cx.run_until_parked(); + + // ── 2. Confirm on Thread C: it becomes most-recently-accessed ────── + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx); + }); + cx.run_until_parked(); + + // Cycle twice to land on Thread C (index 2). + sidebar.read_with(cx, |sidebar, cx| { + let switcher = sidebar.thread_switcher.as_ref().unwrap(); + assert_eq!(switcher.read(cx).selected_index(), 1); + }); + sidebar.update_in(cx, |sidebar, _window, cx| { + sidebar + .thread_switcher + .as_ref() + .unwrap() + .update(cx, |s, cx| s.cycle_selection(cx)); + }); + cx.run_until_parked(); + assert_eq!(switcher_selected_id(&sidebar, cx), session_id_c); + + // Confirm on Thread C. + sidebar.update_in(cx, |sidebar, window, cx| { + let switcher = sidebar.thread_switcher.as_ref().unwrap(); + let focus = switcher.focus_handle(cx); + focus.dispatch_action(&menu::Confirm, window, cx); + }); + cx.run_until_parked(); + + // Switcher should be dismissed after confirm. + sidebar.read_with(cx, |sidebar, _cx| { + assert!( + sidebar.thread_switcher.is_none(), + "switcher should be dismissed" + ); + }); + + // Re-open switcher: Thread C is now most-recently-accessed. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + switcher_ids(&sidebar, cx), + vec![ + session_id_c.clone(), + session_id_a.clone(), + session_id_b.clone() + ], + ); + + sidebar.update_in(cx, |sidebar, _window, cx| { + sidebar.dismiss_thread_switcher(cx); + }); + cx.run_until_parked(); + + // ── 3. Add a historical thread (no last_accessed_at, no message sent) ── + // This thread was never opened in a panel — it only exists in metadata. + cx.update(|_, cx| { + ThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.save( + ThreadMetadata { + session_id: acp::SessionId::new(Arc::from("thread-historical")), + agent_id: agent::ZED_AGENT_ID.clone(), + title: "Historical Thread".into(), + updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0) + .unwrap(), + created_at: Some( + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(), + ), + folder_paths: path_list.clone(), + archived: false, + }, + cx, + ) + }) + }); + cx.run_until_parked(); + + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx); + }); + cx.run_until_parked(); + + // Historical Thread has no last_accessed_at and no last_message_sent_or_queued, + // so it falls to tier 3 (sorted by created_at). It should appear after all + // accessed threads, even though its created_at (June 2024) is much later + // than the others. + // + // But the live threads (A, B, C) each had send_message called which sets + // last_message_sent_or_queued. So for the accessed threads (tier 1) the + // sort key is last_accessed_at; for Historical Thread (tier 3) it's created_at. + let session_id_hist = acp::SessionId::new(Arc::from("thread-historical")); + let ids = switcher_ids(&sidebar, cx); + assert_eq!( + ids, + vec![ + session_id_c.clone(), + session_id_a.clone(), + session_id_b.clone(), + session_id_hist.clone() + ], + ); + + sidebar.update_in(cx, |sidebar, _window, cx| { + sidebar.dismiss_thread_switcher(cx); + }); + cx.run_until_parked(); + + // ── 4. Add another historical thread with older created_at ───────── + cx.update(|_, cx| { + ThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.save( + ThreadMetadata { + session_id: acp::SessionId::new(Arc::from("thread-old-historical")), + agent_id: agent::ZED_AGENT_ID.clone(), + title: "Old Historical Thread".into(), + updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0) + .unwrap(), + created_at: Some( + chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0).unwrap(), + ), + folder_paths: path_list.clone(), + archived: false, + }, + cx, + ) + }) + }); + cx.run_until_parked(); + + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx); + }); + cx.run_until_parked(); + + // Both historical threads have no access or message times. They should + // appear after accessed threads, sorted by created_at (newest first). + let session_id_old_hist = acp::SessionId::new(Arc::from("thread-old-historical")); + let ids = switcher_ids(&sidebar, cx); + assert_eq!( + ids, + vec![ + session_id_c.clone(), + session_id_a.clone(), + session_id_b.clone(), + session_id_hist, + session_id_old_hist, + ], + ); + + sidebar.update_in(cx, |sidebar, _window, cx| { + sidebar.dismiss_thread_switcher(cx); + }); + cx.run_until_parked(); +} + +#[gpui::test] +async fn test_archive_thread_keeps_metadata_but_hides_from_sidebar(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + save_thread_metadata( + acp::SessionId::new(Arc::from("thread-to-archive")), + "Thread To Archive".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + cx.run_until_parked(); + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + let entries = visible_entries_as_strings(&sidebar, cx); + assert!( + entries.iter().any(|e| e.contains("Thread To Archive")), + "expected thread to be visible before archiving, got: {entries:?}" + ); + + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.archive_thread( + &acp::SessionId::new(Arc::from("thread-to-archive")), + window, + cx, + ); + }); + cx.run_until_parked(); + + let entries = visible_entries_as_strings(&sidebar, cx); + assert!( + !entries.iter().any(|e| e.contains("Thread To Archive")), + "expected thread to be hidden after archiving, got: {entries:?}" + ); + + cx.update(|_, cx| { + let store = ThreadMetadataStore::global(cx); + let archived: Vec<_> = store.read(cx).archived_entries().collect(); + assert_eq!(archived.len(), 1); + assert_eq!(archived[0].session_id.0.as_ref(), "thread-to-archive"); + assert!(archived[0].archived); + }); +} + +#[gpui::test] +async fn test_archived_threads_excluded_from_sidebar_entries(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + save_thread_metadata( + acp::SessionId::new(Arc::from("visible-thread")), + "Visible Thread".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + + cx.update(|_, cx| { + let metadata = ThreadMetadata { + session_id: acp::SessionId::new(Arc::from("archived-thread")), + agent_id: agent::ZED_AGENT_ID.clone(), + title: "Archived Thread".into(), + updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + created_at: None, + folder_paths: path_list.clone(), + archived: true, + }; + ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx)); + }); + cx.run_until_parked(); + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + let entries = visible_entries_as_strings(&sidebar, cx); + assert!( + entries.iter().any(|e| e.contains("Visible Thread")), + "expected visible thread in sidebar, got: {entries:?}" + ); + assert!( + !entries.iter().any(|e| e.contains("Archived Thread")), + "expected archived thread to be hidden from sidebar, got: {entries:?}" + ); + + cx.update(|_, cx| { + let store = ThreadMetadataStore::global(cx); + let all: Vec<_> = store.read(cx).entries().collect(); + assert_eq!( + all.len(), + 2, + "expected 2 total entries in the store, got: {}", + all.len() + ); + + let archived: Vec<_> = store.read(cx).archived_entries().collect(); + assert_eq!(archived.len(), 1); + assert_eq!(archived[0].session_id.0.as_ref(), "archived-thread"); + }); +} + +mod property_test { + use super::*; + use gpui::EntityId; + + struct UnopenedWorktree { + path: String, + } + + struct TestState { + fs: Arc, + thread_counter: u32, + workspace_counter: u32, + worktree_counter: u32, + saved_thread_ids: Vec, + workspace_paths: Vec, + main_repo_indices: Vec, + unopened_worktrees: Vec, + } + + impl TestState { + fn new(fs: Arc, initial_workspace_path: String) -> Self { + Self { + fs, + thread_counter: 0, + workspace_counter: 1, + worktree_counter: 0, + saved_thread_ids: Vec::new(), + workspace_paths: vec![initial_workspace_path], + main_repo_indices: vec![0], + unopened_worktrees: Vec::new(), + } + } + + fn next_thread_id(&mut self) -> acp::SessionId { + let id = self.thread_counter; + self.thread_counter += 1; + let session_id = acp::SessionId::new(Arc::from(format!("prop-thread-{id}"))); + self.saved_thread_ids.push(session_id.clone()); + session_id + } + + fn remove_thread(&mut self, index: usize) -> acp::SessionId { + self.saved_thread_ids.remove(index) + } + + fn next_workspace_path(&mut self) -> String { + let id = self.workspace_counter; + self.workspace_counter += 1; + format!("/prop-project-{id}") + } + + fn next_worktree_name(&mut self) -> String { + let id = self.worktree_counter; + self.worktree_counter += 1; + format!("wt-{id}") + } + } + + #[derive(Debug)] + enum Operation { + SaveThread { workspace_index: usize }, + SaveWorktreeThread { worktree_index: usize }, + DeleteThread { index: usize }, + ToggleAgentPanel, + AddWorkspace, + OpenWorktreeAsWorkspace { worktree_index: usize }, + RemoveWorkspace { index: usize }, + SwitchWorkspace { index: usize }, + AddLinkedWorktree { workspace_index: usize }, + } + + // Distribution (out of 20 slots): + // SaveThread: 5 slots (25%) + // SaveWorktreeThread: 2 slots (10%) + // DeleteThread: 2 slots (10%) + // ToggleAgentPanel: 2 slots (10%) + // AddWorkspace: 1 slot (5%) + // OpenWorktreeAsWorkspace: 1 slot (5%) + // RemoveWorkspace: 1 slot (5%) + // SwitchWorkspace: 2 slots (10%) + // AddLinkedWorktree: 4 slots (20%) + const DISTRIBUTION_SLOTS: u32 = 20; + + impl TestState { + fn generate_operation(&self, raw: u32) -> Operation { + let extra = (raw / DISTRIBUTION_SLOTS) as usize; + let workspace_count = self.workspace_paths.len(); + + match raw % DISTRIBUTION_SLOTS { + 0..=4 => Operation::SaveThread { + workspace_index: extra % workspace_count, + }, + 5..=6 if !self.unopened_worktrees.is_empty() => Operation::SaveWorktreeThread { + worktree_index: extra % self.unopened_worktrees.len(), + }, + 5..=6 => Operation::SaveThread { + workspace_index: extra % workspace_count, + }, + 7..=8 if !self.saved_thread_ids.is_empty() => Operation::DeleteThread { + index: extra % self.saved_thread_ids.len(), + }, + 7..=8 => Operation::SaveThread { + workspace_index: extra % workspace_count, + }, + 9..=10 => Operation::ToggleAgentPanel, + 11 if !self.unopened_worktrees.is_empty() => Operation::OpenWorktreeAsWorkspace { + worktree_index: extra % self.unopened_worktrees.len(), + }, + 11 => Operation::AddWorkspace, + 12 if workspace_count > 1 => Operation::RemoveWorkspace { + index: extra % workspace_count, + }, + 12 => Operation::AddWorkspace, + 13..=14 => Operation::SwitchWorkspace { + index: extra % workspace_count, + }, + 15..=19 if !self.main_repo_indices.is_empty() => { + let main_index = self.main_repo_indices[extra % self.main_repo_indices.len()]; + Operation::AddLinkedWorktree { + workspace_index: main_index, + } + } + 15..=19 => Operation::SaveThread { + workspace_index: extra % workspace_count, + }, + _ => unreachable!(), + } + } + } + + fn save_thread_to_path( + state: &mut TestState, + path_list: PathList, + cx: &mut gpui::VisualTestContext, + ) { + let session_id = state.next_thread_id(); + let title: SharedString = format!("Thread {}", session_id).into(); + let updated_at = chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0) + .unwrap() + + chrono::Duration::seconds(state.thread_counter as i64); + let metadata = ThreadMetadata { + session_id, + agent_id: agent::ZED_AGENT_ID.clone(), + title, + updated_at, + created_at: None, + folder_paths: path_list, + archived: false, + }; + cx.update(|_, cx| { + ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx)); + }); + } + + async fn perform_operation( + operation: Operation, + state: &mut TestState, + multi_workspace: &Entity, + sidebar: &Entity, + cx: &mut gpui::VisualTestContext, + ) { + match operation { + Operation::SaveThread { workspace_index } => { + let workspace = + multi_workspace.read_with(cx, |mw, _| mw.workspaces()[workspace_index].clone()); + let path_list = workspace + .read_with(cx, |workspace, cx| PathList::new(&workspace.root_paths(cx))); + save_thread_to_path(state, path_list, cx); + } + Operation::SaveWorktreeThread { worktree_index } => { + let worktree = &state.unopened_worktrees[worktree_index]; + let path_list = PathList::new(&[std::path::PathBuf::from(&worktree.path)]); + save_thread_to_path(state, path_list, cx); + } + Operation::DeleteThread { index } => { + let session_id = state.remove_thread(index); + cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .update(cx, |store, cx| store.delete(session_id, cx)); + }); + } + Operation::ToggleAgentPanel => { + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + let panel_open = + sidebar.read_with(cx, |sidebar, cx| sidebar.agent_panel_visible(cx)); + workspace.update_in(cx, |workspace, window, cx| { + if panel_open { + workspace.close_panel::(window, cx); + } else { + workspace.open_panel::(window, cx); + } + }); + } + Operation::AddWorkspace => { + let path = state.next_workspace_path(); + state + .fs + .insert_tree( + &path, + serde_json::json!({ + ".git": {}, + "src": {}, + }), + ) + .await; + let project = project::Project::test( + state.fs.clone() as Arc, + [path.as_ref()], + cx, + ) + .await; + project.update(cx, |p, cx| p.git_scans_complete(cx)).await; + let workspace = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project.clone(), window, cx) + }); + add_agent_panel(&workspace, &project, cx); + let new_index = state.workspace_paths.len(); + state.workspace_paths.push(path); + state.main_repo_indices.push(new_index); + } + Operation::OpenWorktreeAsWorkspace { worktree_index } => { + let worktree = state.unopened_worktrees.remove(worktree_index); + let project = project::Project::test( + state.fs.clone() as Arc, + [worktree.path.as_ref()], + cx, + ) + .await; + project.update(cx, |p, cx| p.git_scans_complete(cx)).await; + let workspace = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project.clone(), window, cx) + }); + add_agent_panel(&workspace, &project, cx); + state.workspace_paths.push(worktree.path); + } + Operation::RemoveWorkspace { index } => { + let removed = multi_workspace.update_in(cx, |mw, window, cx| { + let workspace = mw.workspaces()[index].clone(); + mw.remove(&workspace, window, cx) + }); + if removed { + state.workspace_paths.remove(index); + state.main_repo_indices.retain(|i| *i != index); + for i in &mut state.main_repo_indices { + if *i > index { + *i -= 1; + } + } + } + } + Operation::SwitchWorkspace { index } => { + let workspace = + multi_workspace.read_with(cx, |mw, _| mw.workspaces()[index].clone()); + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate(workspace, window, cx); + }); + } + Operation::AddLinkedWorktree { workspace_index } => { + let main_path = state.workspace_paths[workspace_index].clone(); + let dot_git = format!("{}/.git", main_path); + let worktree_name = state.next_worktree_name(); + let worktree_path = format!("/worktrees/{}", worktree_name); + + state.fs + .insert_tree( + &worktree_path, + serde_json::json!({ + ".git": format!("gitdir: {}/.git/worktrees/{}", main_path, worktree_name), + "src": {}, + }), + ) + .await; + + // Also create the worktree metadata dir inside the main repo's .git + state + .fs + .insert_tree( + &format!("{}/.git/worktrees/{}", main_path, worktree_name), + serde_json::json!({ + "commondir": "../../", + "HEAD": format!("ref: refs/heads/{}", worktree_name), + }), + ) + .await; + + let dot_git_path = std::path::Path::new(&dot_git); + let worktree_pathbuf = std::path::PathBuf::from(&worktree_path); + state + .fs + .with_git_state(dot_git_path, false, |git_state| { + git_state.worktrees.push(git::repository::Worktree { + path: worktree_pathbuf, + ref_name: Some(format!("refs/heads/{}", worktree_name).into()), + sha: "aaa".into(), + }); + }) + .unwrap(); + + // Re-scan the main workspace's project so it discovers the new worktree. + let main_workspace = + multi_workspace.read_with(cx, |mw, _| mw.workspaces()[workspace_index].clone()); + let main_project = main_workspace.read_with(cx, |ws, _| ws.project().clone()); + main_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + state.unopened_worktrees.push(UnopenedWorktree { + path: worktree_path, + }); + } + } + } + + fn update_sidebar(sidebar: &Entity, cx: &mut gpui::VisualTestContext) { + sidebar.update_in(cx, |sidebar, _window, cx| { + sidebar.collapsed_groups.clear(); + let path_lists: Vec = sidebar + .contents + .entries + .iter() + .filter_map(|entry| match entry { + ListEntry::ProjectHeader { path_list, .. } => Some(path_list.clone()), + _ => None, + }) + .collect(); + for path_list in path_lists { + sidebar.expanded_groups.insert(path_list, 10_000); + } + sidebar.update_entries(cx); + }); + } + + fn validate_sidebar_properties(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> { + verify_every_workspace_in_multiworkspace_is_shown(sidebar, cx)?; + verify_all_threads_are_shown(sidebar, cx)?; + verify_active_state_matches_current_workspace(sidebar, cx)?; + Ok(()) + } + + fn verify_every_workspace_in_multiworkspace_is_shown( + sidebar: &Sidebar, + cx: &App, + ) -> anyhow::Result<()> { + let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else { + anyhow::bail!("sidebar should still have an associated multi-workspace"); + }; + + let workspaces = multi_workspace.read(cx).workspaces().to_vec(); + + // Workspaces with no root paths are not shown because the + // sidebar skips empty path lists. All other workspaces should + // appear — either via a Thread entry or a NewThread entry for + // threadless workspaces. + let expected_workspaces: HashSet = workspaces + .iter() + .filter(|ws| !workspace_path_list(ws, cx).paths().is_empty()) + .map(|ws| ws.entity_id()) + .collect(); + + let sidebar_workspaces: HashSet = sidebar + .contents + .entries + .iter() + .filter_map(|entry| entry.workspace().map(|ws| ws.entity_id())) + .collect(); + + let missing = &expected_workspaces - &sidebar_workspaces; + let stray = &sidebar_workspaces - &expected_workspaces; + + anyhow::ensure!( + missing.is_empty() && stray.is_empty(), + "sidebar workspaces don't match multi-workspace.\n\ + Only in multi-workspace (missing): {:?}\n\ + Only in sidebar (stray): {:?}", + missing, + stray, + ); + + Ok(()) + } + + fn verify_all_threads_are_shown(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> { + let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else { + anyhow::bail!("sidebar should still have an associated multi-workspace"); + }; + let workspaces = multi_workspace.read(cx).workspaces().to_vec(); + let thread_store = ThreadMetadataStore::global(cx); + + let sidebar_thread_ids: HashSet = sidebar + .contents + .entries + .iter() + .filter_map(|entry| entry.session_id().cloned()) + .collect(); + + let mut metadata_thread_ids: HashSet = HashSet::default(); + for workspace in &workspaces { + let path_list = workspace_path_list(workspace, cx); + if path_list.paths().is_empty() { + continue; + } + for metadata in thread_store.read(cx).entries_for_path(&path_list) { + metadata_thread_ids.insert(metadata.session_id.clone()); + } + for snapshot in root_repository_snapshots(workspace, cx) { + for linked_worktree in snapshot.linked_worktrees() { + let worktree_path_list = + PathList::new(std::slice::from_ref(&linked_worktree.path)); + for metadata in thread_store.read(cx).entries_for_path(&worktree_path_list) { + metadata_thread_ids.insert(metadata.session_id.clone()); + } + } + } + } + + anyhow::ensure!( + sidebar_thread_ids == metadata_thread_ids, + "sidebar threads don't match metadata store: sidebar has {:?}, store has {:?}", + sidebar_thread_ids, + metadata_thread_ids, + ); + Ok(()) + } + + fn verify_active_state_matches_current_workspace( + sidebar: &Sidebar, + cx: &App, + ) -> anyhow::Result<()> { + let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else { + anyhow::bail!("sidebar should still have an associated multi-workspace"); + }; + + let workspace = multi_workspace.read(cx).workspace(); + + // TODO: The focused_thread should _always_ be Some(item-in-the-list) after + // update_entries. If the activated workspace's agent panel has an active thread, + // this item should match the one in the list. There may be a slight delay where + // a thread is loading so the agent panel returns None initially, and the + // focused_thread is often optimistically set to the thread the agent panel is + // going to be. + if sidebar.agent_panel_visible(cx) && !sidebar.active_thread_is_draft(cx) { + let panel_active_session_id = + workspace + .read(cx) + .panel::(cx) + .and_then(|panel| { + panel + .read(cx) + .active_conversation_view() + .and_then(|cv| cv.read(cx).parent_id(cx)) + }); + if let Some(panel_session_id) = panel_active_session_id { + anyhow::ensure!( + sidebar.focused_thread.as_ref() == Some(&panel_session_id), + "agent panel is visible with active session {:?} but sidebar focused_thread is {:?}", + panel_session_id, + sidebar.focused_thread, + ); + } + } + Ok(()) + } + + #[gpui::property_test] + async fn test_sidebar_invariants( + #[strategy = gpui::proptest::collection::vec(0u32..DISTRIBUTION_SLOTS * 10, 1..5)] + raw_operations: Vec, + cx: &mut TestAppContext, + ) { + agent_ui::test_support::init_test(cx); + cx.update(|cx| { + cx.update_flags(false, vec!["agent-v2".into()]); + ThreadStore::init_global(cx); + ThreadMetadataStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + prompt_store::init(cx); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/my-project", + serde_json::json!({ + ".git": {}, + "src": {}, + }), + ) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + let project = + project::Project::test(fs.clone() as Arc, ["/my-project".as_ref()], cx) + .await; + project.update(cx, |p, cx| p.git_scans_complete(cx)).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx); + + let mut state = TestState::new(fs, "/my-project".to_string()); + let mut executed: Vec = Vec::new(); + + for &raw_op in &raw_operations { + let operation = state.generate_operation(raw_op); + executed.push(format!("{:?}", operation)); + perform_operation(operation, &mut state, &multi_workspace, &sidebar, cx).await; + cx.run_until_parked(); + + update_sidebar(&sidebar, cx); + cx.run_until_parked(); + + let result = + sidebar.read_with(cx, |sidebar, cx| validate_sidebar_properties(sidebar, cx)); + if let Err(err) = result { + let log = executed.join("\n "); + panic!( + "Property violation after step {}:\n{err}\n\nOperations:\n {log}", + executed.len(), + ); + } + } + } +} + +#[gpui::test] +async fn test_removing_workspace_also_removes_absorbed_worktrees(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + // Main repo with two linked worktrees. + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + "feature-b": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-b", + }, + }, + }, + "src": {}, + }), + ) + .await; + + // Two worktree checkouts whose .git files point back to the main repo. + fs.insert_tree( + "/wt-feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/wt-feature-b", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-b", + "src": {}, + }), + ) + .await; + + // Configure the main repo to list both worktrees. + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-a"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + }); + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-b"), + ref_name: Some("refs/heads/feature-b".into()), + sha: "bbb".into(), + }); + }) + .unwrap(); + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + let worktree_project_a = + project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; + let worktree_project_b = + project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await; + + main_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + worktree_project_a + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + worktree_project_b + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + // Open the main project first, then add both worktree workspaces. + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(worktree_project_a.clone(), window, cx); + }); + multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(worktree_project_b.clone(), window, cx); + }); + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Save threads for both worktrees so they appear in the sidebar. + let paths_a = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); + let paths_b = PathList::new(&[std::path::PathBuf::from("/wt-feature-b")]); + save_named_thread_metadata("thread-a", "Thread A", &paths_a, cx).await; + save_named_thread_metadata("thread-b", "Thread B", &paths_b, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Both worktree threads should be absorbed under the main project header. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project]", + " [+ New Thread]", + " Thread A {wt-feature-a}", + " Thread B {wt-feature-b}", + ] + ); + + // Now remove the main workspace. + multi_workspace.update_in(cx, |mw, window, cx| { + let workspace = mw.workspaces()[0].clone(); + mw.remove(&workspace, window, cx); + }); + cx.run_until_parked(); + + // The worktree workspaces should also have been removed. + // Before the fix, they remain in the sidebar as standalone entries. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + Vec::::new(), + "removing the main workspace should also remove the absorbed worktree workspaces" + ); +} diff --git a/crates/sidebar/src/thread_switcher.rs b/crates/sidebar/src/thread_switcher.rs new file mode 100644 index 0000000000000000000000000000000000000000..a7f6584896c197374d02c7180f5d7bf19844daa7 --- /dev/null +++ b/crates/sidebar/src/thread_switcher.rs @@ -0,0 +1,371 @@ +use action_log::DiffStats; +use agent_client_protocol as acp; +use agent_ui::thread_metadata_store::ThreadMetadata; +use gpui::{ + Action as _, Animation, AnimationExt, AnyElement, DismissEvent, Entity, EventEmitter, + FocusHandle, Focusable, Hsla, Modifiers, ModifiersChangedEvent, Render, SharedString, + prelude::*, pulsating_between, +}; +use std::time::Duration; +use ui::{ + AgentThreadStatus, Color, CommonAnimationExt, DecoratedIcon, DiffStat, Icon, IconDecoration, + IconDecorationKind, IconName, IconSize, Label, LabelSize, prelude::*, +}; +use workspace::{ModalView, Workspace}; +use zed_actions::agents_sidebar::ToggleThreadSwitcher; + +const PANEL_WIDTH_REMS: f32 = 28.; + +pub(crate) struct ThreadSwitcherEntry { + pub session_id: acp::SessionId, + pub title: SharedString, + pub icon: IconName, + pub icon_from_external_svg: Option, + pub status: AgentThreadStatus, + pub metadata: ThreadMetadata, + pub workspace: Entity, + pub worktree_name: Option, + pub diff_stats: DiffStats, + pub is_title_generating: bool, + pub notified: bool, + pub timestamp: SharedString, +} + +pub(crate) enum ThreadSwitcherEvent { + Preview { + metadata: ThreadMetadata, + workspace: Entity, + }, + Confirmed { + metadata: ThreadMetadata, + workspace: Entity, + }, + Dismissed, +} + +pub(crate) struct ThreadSwitcher { + focus_handle: FocusHandle, + entries: Vec, + selected_index: usize, + init_modifiers: Option, +} + +impl ThreadSwitcher { + pub fn new( + entries: Vec, + select_last: bool, + window: &mut gpui::Window, + cx: &mut Context, + ) -> Self { + let init_modifiers = window.modifiers().modified().then_some(window.modifiers()); + let selected_index = if entries.is_empty() { + 0 + } else if select_last { + entries.len() - 1 + } else { + 1.min(entries.len().saturating_sub(1)) + }; + + if let Some(entry) = entries.get(selected_index) { + cx.emit(ThreadSwitcherEvent::Preview { + metadata: entry.metadata.clone(), + workspace: entry.workspace.clone(), + }); + } + + let focus_handle = cx.focus_handle(); + cx.on_focus_out(&focus_handle, window, |_this, _event, _window, cx| { + cx.emit(ThreadSwitcherEvent::Dismissed); + cx.emit(DismissEvent); + }) + .detach(); + + Self { + focus_handle, + entries, + selected_index, + init_modifiers, + } + } + + pub fn selected_entry(&self) -> Option<&ThreadSwitcherEntry> { + self.entries.get(self.selected_index) + } + + #[cfg(test)] + pub fn entries(&self) -> &[ThreadSwitcherEntry] { + &self.entries + } + + #[cfg(test)] + pub fn selected_index(&self) -> usize { + self.selected_index + } + + pub fn cycle_selection(&mut self, cx: &mut Context) { + if self.entries.is_empty() { + return; + } + self.selected_index = (self.selected_index + 1) % self.entries.len(); + self.emit_preview(cx); + } + + pub fn select_last(&mut self, cx: &mut Context) { + if self.entries.is_empty() { + return; + } + if self.selected_index == 0 { + self.selected_index = self.entries.len() - 1; + } else { + self.selected_index -= 1; + } + self.emit_preview(cx); + } + + fn emit_preview(&mut self, cx: &mut Context) { + if let Some(entry) = self.entries.get(self.selected_index) { + cx.emit(ThreadSwitcherEvent::Preview { + metadata: entry.metadata.clone(), + workspace: entry.workspace.clone(), + }); + } + } + + fn confirm(&mut self, _: &menu::Confirm, _window: &mut gpui::Window, cx: &mut Context) { + if let Some(entry) = self.entries.get(self.selected_index) { + cx.emit(ThreadSwitcherEvent::Confirmed { + metadata: entry.metadata.clone(), + workspace: entry.workspace.clone(), + }); + } + cx.emit(DismissEvent); + } + + fn cancel(&mut self, _: &menu::Cancel, _window: &mut gpui::Window, cx: &mut Context) { + cx.emit(ThreadSwitcherEvent::Dismissed); + cx.emit(DismissEvent); + } + + fn toggle( + &mut self, + action: &ToggleThreadSwitcher, + _window: &mut gpui::Window, + cx: &mut Context, + ) { + if action.select_last { + self.select_last(cx); + } else { + self.cycle_selection(cx); + } + } + + fn handle_modifiers_changed( + &mut self, + event: &ModifiersChangedEvent, + window: &mut gpui::Window, + cx: &mut Context, + ) { + let Some(init_modifiers) = self.init_modifiers else { + return; + }; + if !event.modified() || !init_modifiers.is_subset_of(event) { + self.init_modifiers = None; + if self.entries.is_empty() { + cx.emit(DismissEvent); + } else { + window.dispatch_action(menu::Confirm.boxed_clone(), cx); + } + } + } +} + +impl ModalView for ThreadSwitcher {} + +impl EventEmitter for ThreadSwitcher {} +impl EventEmitter for ThreadSwitcher {} + +impl Focusable for ThreadSwitcher { + fn focus_handle(&self, _cx: &gpui::App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for ThreadSwitcher { + fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context) -> impl IntoElement { + let selected_index = self.selected_index; + let color = cx.theme().colors(); + let panel_bg = color + .title_bar_background + .blend(color.panel_background.opacity(0.2)); + + v_flex() + .key_context("ThreadSwitcher") + .track_focus(&self.focus_handle) + .w(gpui::rems(PANEL_WIDTH_REMS)) + .elevation_3(cx) + .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed)) + .on_action(cx.listener(Self::confirm)) + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::toggle)) + .children(self.entries.iter().enumerate().map(|(ix, entry)| { + let is_first = ix == 0; + let is_last = ix == self.entries.len() - 1; + let selected = ix == selected_index; + let base_bg = if selected { + color.element_active + } else { + panel_bg + }; + + let dot_separator = || { + Label::new("\u{2022}") + .size(LabelSize::Small) + .color(Color::Muted) + .alpha(0.5) + }; + + let icon_container = || h_flex().size_4().flex_none().justify_center(); + + let agent_icon = || { + if let Some(ref svg) = entry.icon_from_external_svg { + Icon::from_external_svg(svg.clone()) + .color(Color::Muted) + .size(IconSize::Small) + } else { + Icon::new(entry.icon) + .color(Color::Muted) + .size(IconSize::Small) + } + }; + + let decoration = |kind: IconDecorationKind, deco_color: Hsla| { + IconDecoration::new(kind, base_bg, cx) + .color(deco_color) + .position(gpui::Point { + x: px(-2.), + y: px(-2.), + }) + }; + + let icon_element: AnyElement = if entry.status == AgentThreadStatus::Running { + icon_container() + .child( + Icon::new(IconName::LoadCircle) + .size(IconSize::Small) + .color(Color::Muted) + .with_rotate_animation(2), + ) + .into_any_element() + } else if entry.status == AgentThreadStatus::Error { + icon_container() + .child(DecoratedIcon::new( + agent_icon(), + Some(decoration(IconDecorationKind::X, cx.theme().status().error)), + )) + .into_any_element() + } else if entry.status == AgentThreadStatus::WaitingForConfirmation { + icon_container() + .child(DecoratedIcon::new( + agent_icon(), + Some(decoration( + IconDecorationKind::Triangle, + cx.theme().status().warning, + )), + )) + .into_any_element() + } else if entry.notified { + icon_container() + .child(DecoratedIcon::new( + agent_icon(), + Some(decoration(IconDecorationKind::Dot, color.text_accent)), + )) + .into_any_element() + } else { + icon_container().child(agent_icon()).into_any_element() + }; + + let title_label: AnyElement = if entry.is_title_generating { + Label::new(entry.title.clone()) + .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() + } else { + Label::new(entry.title.clone()).into_any_element() + }; + + let has_diff_stats = + entry.diff_stats.lines_added > 0 || entry.diff_stats.lines_removed > 0; + let has_worktree = entry.worktree_name.is_some(); + let has_timestamp = !entry.timestamp.is_empty(); + + v_flex() + .id(ix) + .w_full() + .py_1() + .px_1p5() + .border_1() + .border_color(gpui::transparent_black()) + .when(selected, |s| s.bg(color.element_active)) + .when(is_first, |s| s.rounded_t_lg()) + .when(is_last, |s| s.rounded_b_lg()) + .child( + h_flex() + .min_w_0() + .w_full() + .gap_1p5() + .child(icon_element) + .child(title_label), + ) + .when(has_worktree || has_diff_stats || has_timestamp, |this| { + this.child( + h_flex() + .min_w_0() + .gap_1p5() + .child(icon_container()) + .when_some(entry.worktree_name.clone(), |this, worktree| { + this.child( + h_flex() + .gap_1() + .child( + Icon::new(IconName::GitWorktree) + .size(IconSize::XSmall) + .color(Color::Muted), + ) + .child( + Label::new(worktree) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + }) + .when(has_worktree && (has_diff_stats || has_timestamp), |this| { + this.child(dot_separator()) + }) + .when(has_diff_stats, |this| { + this.child(DiffStat::new( + ix, + entry.diff_stats.lines_added as usize, + entry.diff_stats.lines_removed as usize, + )) + }) + .when(has_diff_stats && has_timestamp, |this| { + this.child(dot_separator()) + }) + .when(has_timestamp, |this| { + this.child( + Label::new(entry.timestamp.clone()) + .size(LabelSize::Small) + .color(Color::Muted), + ) + }), + ) + }) + })) + } +} diff --git a/crates/storybook/Cargo.toml b/crates/storybook/Cargo.toml index b1d512559526a00021f5339707c1e24a3110ff15..b641e5cbd8b5ce5e66f9fb082e74ea42124f8993 100644 --- a/crates/storybook/Cargo.toml +++ b/crates/storybook/Cargo.toml @@ -29,6 +29,7 @@ picker.workspace = true reqwest_client.workspace = true rust-embed.workspace = true settings.workspace = true +theme_settings.workspace = true simplelog.workspace = true story.workspace = true strum = { workspace = true, features = ["derive"] } diff --git a/crates/storybook/src/storybook.rs b/crates/storybook/src/storybook.rs index b8f659146c29162c25b94ca65d05770b4c08921b..d3df9bbc3a078793ab8e00c71cd4cb5cb9810fa6 100644 --- a/crates/storybook/src/storybook.rs +++ b/crates/storybook/src/storybook.rs @@ -15,10 +15,10 @@ use gpui::{ }; use log::LevelFilter; use reqwest_client::ReqwestClient; -use settings::{KeymapFile, Settings}; +use settings::{KeymapFile, Settings as _}; use simplelog::SimpleLogger; use strum::IntoEnumIterator; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::prelude::*; use crate::app_menus::app_menus; @@ -76,13 +76,13 @@ fn main() { cx.set_http_client(Arc::new(http_client)); settings::init(cx); - theme::init(theme::LoadThemes::All(Box::new(Assets)), cx); + theme_settings::init(theme::LoadThemes::All(Box::new(Assets)), cx); let selector = story_selector; let mut theme_settings = ThemeSettings::get_global(cx).clone(); theme_settings.theme = - theme::ThemeSelection::Static(settings::ThemeName(theme_name.into())); + theme_settings::ThemeSelection::Static(settings::ThemeName(theme_name.into())); ThemeSettings::override_global(theme_settings, cx); editor::init(cx); @@ -98,7 +98,7 @@ fn main() { ..Default::default() }, move |window, cx| { - theme::setup_ui_font(window, cx); + theme_settings::setup_ui_font(window, cx); cx.new(|cx| StoryWrapper::new(selector.story(window, cx))) }, diff --git a/crates/svg_preview/src/svg_preview_view.rs b/crates/svg_preview/src/svg_preview_view.rs index 1a001c6e18854428636626cc499e49433710a84d..259243b8ac7cd7d4122fc2f535d490b359442440 100644 --- a/crates/svg_preview/src/svg_preview_view.rs +++ b/crates/svg_preview/src/svg_preview_view.rs @@ -110,7 +110,7 @@ impl SvgPreviewView { let renderer = cx.svg_renderer(); let content = buffer.read(cx).snapshot(); let background_task = cx.background_spawn(async move { - renderer.render_single_frame(content.text().as_bytes(), SCALE_FACTOR, true) + renderer.render_single_frame(content.text().as_bytes(), SCALE_FACTOR) }); self._refresh = cx.spawn_in(window, async move |this, cx| { diff --git a/crates/tab_switcher/Cargo.toml b/crates/tab_switcher/Cargo.toml index e2855aa1696c3af0c3efeb2b927f968783978332..8855c8869ab52260be668c45c20e5af7a869433f 100644 --- a/crates/tab_switcher/Cargo.toml +++ b/crates/tab_switcher/Cargo.toml @@ -33,5 +33,6 @@ ctor.workspace = true gpui = { workspace = true, features = ["test-support"] } serde_json.workspace = true theme = { workspace = true, features = ["test-support"] } +theme_settings.workspace = true workspace = { workspace = true, features = ["test-support"] } zlog.workspace = true diff --git a/crates/tab_switcher/src/tab_switcher_tests.rs b/crates/tab_switcher/src/tab_switcher_tests.rs index e1e3f138252e4dc41aa67d9d5b848eac773d5f4f..5b8b9224192324cf0145417b252dcee3a07ddcc3 100644 --- a/crates/tab_switcher/src/tab_switcher_tests.rs +++ b/crates/tab_switcher/src/tab_switcher_tests.rs @@ -258,7 +258,7 @@ async fn test_close_selected_item(cx: &mut gpui::TestAppContext) { fn init_test(cx: &mut TestAppContext) -> Arc { cx.update(|cx| { let state = AppState::test(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); super::init(cx); editor::init(cx); state diff --git a/crates/tasks_ui/src/modal.rs b/crates/tasks_ui/src/modal.rs index 6b4fc21ef3ede0482c9eb3ac6b8dd9c000b7f7d4..34f0cd809692d649bcfbabb7952f3075618ead04 100644 --- a/crates/tasks_ui/src/modal.rs +++ b/crates/tasks_ui/src/modal.rs @@ -184,23 +184,11 @@ impl TasksModal { }; let mut new_candidates = used_tasks; new_candidates.extend(lsp_tasks); - let hide_vscode = current_resolved_tasks.iter().any(|(kind, _)| match kind { - TaskSourceKind::Worktree { - id: _, - directory_in_worktree: dir, - id_base: _, - } => dir.file_name().is_some_and(|name| name == ".zed"), - _ => false, - }); // todo(debugger): We're always adding lsp tasks here even if prefer_lsp is false // We should move the filter to new_candidates instead of on current // and add a test for this new_candidates.extend(current_resolved_tasks.into_iter().filter(|(task_kind, _)| { match task_kind { - TaskSourceKind::Worktree { - directory_in_worktree: dir, - .. - } => !(hide_vscode && dir.file_name().is_some_and(|name| name == ".vscode")), TaskSourceKind::Language { .. } => add_current_language_tasks, _ => true, } diff --git a/crates/tasks_ui/src/tasks_ui.rs b/crates/tasks_ui/src/tasks_ui.rs index 3da0c78f8070e874187c39fcbd073994028d146a..da351ad410d078e79aa4c3038fcf88184bc648fa 100644 --- a/crates/tasks_ui/src/tasks_ui.rs +++ b/crates/tasks_ui/src/tasks_ui.rs @@ -204,19 +204,19 @@ where else { return Task::ready(Vec::new()); }; - let (file, language) = task_contexts + let (language, buffer) = task_contexts .location() .map(|location| { - let buffer = location.buffer.read(cx); + let buffer = location.buffer.clone(); ( - buffer.file().cloned(), - buffer.language_at(location.range.start), + buffer.read(cx).language_at(location.range.start), + Some(buffer), ) }) .unwrap_or_default(); task_inventory .read(cx) - .list_tasks(file, language, task_contexts.worktree(), cx) + .list_tasks(buffer, language, task_contexts.worktree(), cx) })? .await; diff --git a/crates/terminal/Cargo.toml b/crates/terminal/Cargo.toml index fcb637f14b3785cf2d11b68b8cbf60934f055df4..8a598c1d7730ef59c19085f73cc65bd955ad4e35 100644 --- a/crates/terminal/Cargo.toml +++ b/crates/terminal/Cargo.toml @@ -37,6 +37,7 @@ sysinfo.workspace = true smol.workspace = true task.workspace = true theme.workspace = true +theme_settings.workspace = true thiserror.workspace = true url.workspace = true util.workspace = true diff --git a/crates/terminal/src/pty_info.rs b/crates/terminal/src/pty_info.rs index 2663095c52f386cfd9528f1c96fa32a39abd9a59..7b6676760ca61c1cfde22601d0c0eb0b9641b42a 100644 --- a/crates/terminal/src/pty_info.rs +++ b/crates/terminal/src/pty_info.rs @@ -36,11 +36,19 @@ impl ProcessIdGetter { } fn pid(&self) -> Option { + // Negative pid means error. + // Zero pid means no foreground process group is set on the PTY yet. + // Avoid killing the current process by returning a zero pid. let pid = unsafe { libc::tcgetpgrp(self.handle) }; - if pid < 0 { + if pid > 0 { + return Some(Pid::from_u32(pid as u32)); + } + + if self.fallback_pid > 0 { return Some(Pid::from_u32(self.fallback_pid)); } - Some(Pid::from_u32(pid as u32)) + + None } } diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index c85d849cb034f99367f5cd8e6f1a978e4efd7ae3..b620f5f03c2debf19cdc4856da8c039fe690651f 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -417,6 +417,7 @@ impl TerminalBuilder { window_id, }, child_exited: None, + keyboard_input_sent: false, event_loop_task: Task::ready(Ok(())), background_executor: background_executor.clone(), path_style, @@ -650,6 +651,7 @@ impl TerminalBuilder { window_id, }, child_exited: None, + keyboard_input_sent: false, event_loop_task: Task::ready(Ok(())), background_executor, path_style, @@ -876,6 +878,7 @@ pub struct Terminal { template: CopyTemplate, activation_script: Vec, child_exited: Option, + keyboard_input_sent: bool, event_loop_task: Task>, background_executor: BackgroundExecutor, path_style: PathStyle, @@ -1462,6 +1465,7 @@ impl Terminal { .push_back(InternalEvent::Scroll(AlacScroll::Bottom)); self.events.push_back(InternalEvent::SetSelection(None)); + self.keyboard_input_sent = true; let input = input.into(); #[cfg(any(test, feature = "test-support"))] self.input_log.push(input.to_vec()); @@ -1945,7 +1949,7 @@ impl Terminal { MouseButton::Middle => { if let Some(item) = _cx.read_from_primary() { let text = item.text().unwrap_or_default(); - self.input(text.into_bytes()); + self.paste(&text); } } _ => {} @@ -2245,7 +2249,17 @@ impl Terminal { let task = match &mut self.task { Some(task) => task, None => { - if self.child_exited.is_none_or(|e| e.code() == Some(0)) { + // For interactive shells (no task), we need to differentiate: + // 1. User-initiated exits (typed "exit", Ctrl+D, etc.) - always close, + // even if the shell exits with a non-zero code (e.g. after `false`). + // 2. Shell spawn failures (bad $SHELL) - don't close, so the user sees + // the error. Spawn failures never receive keyboard input. + let should_close = if self.keyboard_input_sent { + true + } else { + self.child_exited.is_none_or(|e| e.code() == Some(0)) + }; + if should_close { cx.emit(Event::CloseTerminal); } return; @@ -2556,16 +2570,16 @@ mod tests { Point, TestAppContext, bounds, point, size, }; use parking_lot::Mutex; - use rand::{Rng, distr, rngs::ThreadRng}; + use rand::{Rng, distr, rngs::StdRng}; use smol::channel::Receiver; use task::{Shell, ShellBuilder}; - #[cfg(target_os = "macos")] + #[cfg(not(target_os = "windows"))] fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { let settings_store = settings::SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); }); } @@ -2795,6 +2809,68 @@ mod tests { ); } + #[cfg(not(target_os = "windows"))] + #[gpui::test(iterations = 10)] + async fn test_terminal_closes_after_nonzero_exit(cx: &mut TestAppContext) { + init_test(cx); + + cx.executor().allow_parking(); + + let builder = cx + .update(|cx| { + TerminalBuilder::new( + None, + None, + task::Shell::System, + HashMap::default(), + CursorShape::default(), + AlternateScroll::On, + None, + vec![], + 0, + false, + 0, + None, + cx, + Vec::new(), + PathStyle::local(), + ) + }) + .await + .unwrap(); + let terminal = cx.new(|cx| builder.subscribe(cx)); + + let (event_tx, event_rx) = smol::channel::unbounded::(); + cx.update(|cx| { + cx.subscribe(&terminal, move |_, e, _| { + event_tx.send_blocking(e.clone()).unwrap(); + }) + }) + .detach(); + + let first_event = event_rx.recv().await.expect("No wakeup event received"); + + terminal.update(cx, |terminal, _| { + terminal.input(b"false\r".to_vec()); + }); + cx.executor().timer(Duration::from_millis(500)).await; + terminal.update(cx, |terminal, _| { + terminal.input(b"exit\r".to_vec()); + }); + + let mut all_events = vec![first_event]; + while let Ok(new_event) = event_rx.recv().await { + all_events.push(new_event.clone()); + if new_event == Event::CloseTerminal { + break; + } + } + assert!( + all_events.contains(&Event::CloseTerminal), + "Shell exiting after `false && exit` should close terminal, but got events: {all_events:?}", + ); + } + #[gpui::test(iterations = 10)] async fn test_terminal_no_exit_on_spawn_failure(cx: &mut TestAppContext) { cx.executor().allow_parking(); @@ -2877,9 +2953,8 @@ mod tests { } } - #[test] - fn test_mouse_to_cell_test() { - let mut rng = rand::rng(); + #[gpui::test] + fn test_mouse_to_cell_test(mut rng: StdRng) { const ITERATIONS: usize = 10; const PRECISION: usize = 1000; @@ -2927,10 +3002,8 @@ mod tests { } } - #[test] - fn test_mouse_to_cell_clamp() { - let mut rng = rand::rng(); - + #[gpui::test] + fn test_mouse_to_cell_clamp(mut rng: StdRng) { let size = crate::TerminalBounds { cell_width: Pixels::from(10.), line_height: Pixels::from(10.), @@ -2961,12 +3034,12 @@ mod tests { ); } - fn get_cells(size: TerminalBounds, rng: &mut ThreadRng) -> Vec> { + fn get_cells(size: TerminalBounds, rng: &mut StdRng) -> Vec> { let mut cells = Vec::new(); - for _ in 0..((size.height() / size.line_height()) as usize) { + for _ in 0..size.num_lines() { let mut row_vec = Vec::new(); - for _ in 0..((size.width() / size.cell_width()) as usize) { + for _ in 0..size.num_columns() { let cell_char = rng.sample(distr::Alphanumeric) as char; row_vec.push(cell_char) } diff --git a/crates/terminal/src/terminal_settings.rs b/crates/terminal/src/terminal_settings.rs index f24bd5ead6cfd8cb0d4ded66a770a6040d957b72..ec784d466b1f97ba2e44231aaef7475d62981479 100644 --- a/crates/terminal/src/terminal_settings.rs +++ b/crates/terminal/src/terminal_settings.rs @@ -14,7 +14,7 @@ use settings::{ merge_from::MergeFrom, }; use task::Shell; -use theme::FontFamilyName; +use theme_settings::FontFamilyName; #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] pub struct Toolbar { @@ -40,6 +40,7 @@ pub struct TerminalSettings { pub keep_selection_on_copy: bool, pub button: bool, pub dock: TerminalDockPosition, + pub flexible: bool, pub default_width: Pixels, pub default_height: Pixels, pub detect_venv: VenvSettings, @@ -110,6 +111,7 @@ impl settings::Settings for TerminalSettings { dock: user_content.dock.unwrap(), default_width: px(user_content.default_width.unwrap()), default_height: px(user_content.default_height.unwrap()), + flexible: user_content.flexible.unwrap(), detect_venv: project_content.detect_venv.unwrap(), scroll_multiplier: user_content.scroll_multiplier.unwrap(), max_scroll_history_lines: user_content.max_scroll_history_lines, diff --git a/crates/terminal_view/Cargo.toml b/crates/terminal_view/Cargo.toml index 6fc1d4ae710a342b2d275b6dd5713d37a14b1da6..ae4c19ff59b5b944588e06c3373de754d63feaf2 100644 --- a/crates/terminal_view/Cargo.toml +++ b/crates/terminal_view/Cargo.toml @@ -42,6 +42,7 @@ settings.workspace = true shellexpand.workspace = true terminal.workspace = true theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true diff --git a/crates/terminal_view/src/persistence.rs b/crates/terminal_view/src/persistence.rs index 8a022e4f74d52e993f2256dadc546a126fe23c9b..50b1e350fa91a4936691b5a35efe0a1666aba9cc 100644 --- a/crates/terminal_view/src/persistence.rs +++ b/crates/terminal_view/src/persistence.rs @@ -6,7 +6,7 @@ use gpui::{AppContext as _, AsyncWindowContext, Axis, Entity, Task, WeakEntity}; use project::Project; use serde::{Deserialize, Serialize}; use std::path::PathBuf; -use ui::{App, Context, Pixels, Window}; +use ui::{App, Context, Window}; use util::ResultExt as _; use db::{ @@ -97,12 +97,7 @@ pub(crate) fn deserialize_terminal_panel( ) -> Task>> { window.spawn(cx, async move |cx| { let terminal_panel = workspace.update_in(cx, |workspace, window, cx| { - cx.new(|cx| { - let mut panel = TerminalPanel::new(workspace, window, cx); - panel.height = serialized_panel.height.map(|h| h.round()); - panel.width = serialized_panel.width.map(|w| w.round()); - panel - }) + cx.new(|cx| TerminalPanel::new(workspace, window, cx)) })?; match &serialized_panel.items { SerializedItems::NoSplits(item_ids) => { @@ -317,8 +312,6 @@ pub(crate) struct SerializedTerminalPanel { pub items: SerializedItems, // A deprecated field, kept for backwards compatibility for the code before terminal splits were introduced. pub active_item_id: Option, - pub width: Option, - pub height: Option, } #[derive(Debug, Serialize, Deserialize)] diff --git a/crates/terminal_view/src/terminal_element.rs b/crates/terminal_view/src/terminal_element.rs index dc01a05dbe0c9c04398afc47a5cae1c2bd7b4e5d..0bb0837c6edb926cdcda70a54889de313cbe94f1 100644 --- a/crates/terminal_view/src/terminal_element.rs +++ b/crates/terminal_view/src/terminal_element.rs @@ -25,7 +25,8 @@ use terminal::{ }, terminal_settings::TerminalSettings, }; -use theme::{ActiveTheme, Theme, ThemeSettings}; +use theme::{ActiveTheme, Theme}; +use theme_settings::ThemeSettings; use ui::utils::ensure_minimum_contrast; use ui::{ParentElement, Tooltip}; use util::ResultExt; @@ -913,7 +914,9 @@ impl Element for TerminalElement { } TerminalMode::Standalone => terminal_settings .font_size - .map_or(buffer_font_size, |size| theme::adjusted_font_size(size, cx)), + .map_or(buffer_font_size, |size| { + theme_settings::adjusted_font_size(size, cx) + }), }; let theme = cx.theme().clone(); diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 81dbbcb741fe3f3091b4488636c3a7b3cada487b..a813a1adc55fe5de75f5d9547839b15eb391192e 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -79,8 +79,6 @@ pub struct TerminalPanel { pub(crate) center: PaneGroup, fs: Arc, workspace: WeakEntity, - pub(crate) width: Option, - pub(crate) height: Option, pending_serialization: Task>, pending_terminals_to_add: usize, deferred_tasks: HashMap>, @@ -100,8 +98,6 @@ impl TerminalPanel { fs: workspace.app_state().fs.clone(), workspace: workspace.weak_handle(), pending_serialization: Task::ready(None), - width: None, - height: None, pending_terminals_to_add: 0, deferred_tasks: HashMap::default(), assistant_enabled: false, @@ -657,6 +653,27 @@ impl TerminalPanel { window: &mut Window, cx: &mut Context, ) { + let center_pane = workspace.active_pane(); + let center_pane_has_focus = center_pane.focus_handle(cx).contains_focused(window, cx); + let active_center_item_is_terminal = center_pane + .read(cx) + .active_item() + .is_some_and(|item| item.downcast::().is_some()); + + if center_pane_has_focus && active_center_item_is_terminal { + let working_directory = default_working_directory(workspace, cx); + let local = action.local; + Self::add_center_terminal(workspace, window, cx, move |project, cx| { + if local { + project.create_local_terminal(cx) + } else { + project.create_terminal_shell(working_directory, cx) + } + }) + .detach_and_log_err(cx); + return; + } + let Some(terminal_panel) = workspace.panel::(cx) else { return; }; @@ -928,8 +945,6 @@ impl TerminalPanel { } fn serialize(&mut self, cx: &mut Context) { - let height = self.height; - let width = self.width; let Some(serialization_key) = self .workspace .read_with(cx, |workspace, _| { @@ -960,8 +975,6 @@ impl TerminalPanel { serde_json::to_string(&SerializedTerminalPanel { items, active_item_id: None, - height, - width, })?, ) .await?; @@ -1553,25 +1566,26 @@ impl Panel for TerminalPanel { }); } - fn size(&self, window: &Window, cx: &App) -> Pixels { + fn default_size(&self, window: &Window, cx: &App) -> Pixels { let settings = TerminalSettings::get_global(cx); match self.position(window, cx) { - DockPosition::Left | DockPosition::Right => { - self.width.unwrap_or(settings.default_width) - } - DockPosition::Bottom => self.height.unwrap_or(settings.default_height), + DockPosition::Left | DockPosition::Right => settings.default_width, + DockPosition::Bottom => 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.height = size, - } - cx.notify(); - cx.defer_in(window, |this, _, cx| { - this.serialize(cx); - }) + fn supports_flexible_size(&self) -> bool { + true + } + + fn has_flexible_size(&self, _window: &Window, cx: &App) -> bool { + TerminalSettings::get_global(cx).flexible + } + + fn set_flexible_size(&mut self, flexible: bool, _window: &mut Window, cx: &mut Context) { + settings::update_settings_file(self.fs.clone(), cx, move |settings, _| { + settings.terminal.get_or_insert_default().flexible = Some(flexible); + }); } fn is_zoomed(&self, _window: &Window, cx: &App) -> bool { @@ -1646,7 +1660,7 @@ impl Panel for TerminalPanel { } fn toggle_action(&self) -> Box { - Box::new(ToggleFocus) + Box::new(Toggle) } fn pane(&self) -> Option> { @@ -1654,7 +1668,7 @@ impl Panel for TerminalPanel { } fn activation_priority(&self) -> u32 { - 1 + 2 } } @@ -1904,6 +1918,436 @@ mod tests { ); } + async fn init_workspace_with_panel( + cx: &mut TestAppContext, + ) -> (gpui::WindowHandle, Entity) { + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx)); + + let terminal_panel = window_handle + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + let panel = cx.new(|cx| TerminalPanel::new(workspace, window, cx)); + workspace.add_panel(panel.clone(), window, cx); + panel + }) + }) + .expect("Failed to initialize workspace with terminal panel"); + + (window_handle, terminal_panel) + } + + #[gpui::test] + async fn test_new_terminal_opens_in_panel_by_default(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + init_test(cx); + + let (window_handle, terminal_panel) = init_workspace_with_panel(cx).await; + + let panel_items_before = + terminal_panel.read_with(cx, |panel, cx| panel.active_pane.read(cx).items_len()); + let center_items_before = window_handle + .read_with(cx, |multi_workspace, cx| { + multi_workspace + .workspace() + .read(cx) + .active_pane() + .read(cx) + .items_len() + }) + .expect("Failed to read center pane items"); + + window_handle + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + TerminalPanel::new_terminal( + workspace, + &workspace::NewTerminal::default(), + window, + cx, + ); + }) + }) + .expect("Failed to dispatch new_terminal"); + + cx.run_until_parked(); + + let panel_items_after = + terminal_panel.read_with(cx, |panel, cx| panel.active_pane.read(cx).items_len()); + let center_items_after = window_handle + .read_with(cx, |multi_workspace, cx| { + multi_workspace + .workspace() + .read(cx) + .active_pane() + .read(cx) + .items_len() + }) + .expect("Failed to read center pane items"); + + assert_eq!( + panel_items_after, + panel_items_before + 1, + "Terminal should be added to the panel when no center terminal is focused" + ); + assert_eq!( + center_items_after, center_items_before, + "Center pane should not gain a new terminal" + ); + } + + #[gpui::test] + async fn test_new_terminal_opens_in_center_when_center_terminal_focused( + cx: &mut TestAppContext, + ) { + cx.executor().allow_parking(); + init_test(cx); + + let (window_handle, terminal_panel) = init_workspace_with_panel(cx).await; + + window_handle + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + TerminalPanel::add_center_terminal(workspace, window, cx, |project, cx| { + project.create_terminal_shell(None, cx) + }) + }) + }) + .expect("Failed to update workspace") + .await + .expect("Failed to create center terminal"); + cx.run_until_parked(); + + let center_items_before = window_handle + .read_with(cx, |multi_workspace, cx| { + multi_workspace + .workspace() + .read(cx) + .active_pane() + .read(cx) + .items_len() + }) + .expect("Failed to read center pane items"); + assert_eq!(center_items_before, 1, "Center pane should have 1 terminal"); + + window_handle + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + let active_item = workspace + .active_pane() + .read(cx) + .active_item() + .expect("Center pane should have an active item"); + let terminal_view = active_item + .downcast::() + .expect("Active center item should be a TerminalView"); + window.focus(&terminal_view.focus_handle(cx), cx); + }) + }) + .expect("Failed to focus terminal view"); + cx.run_until_parked(); + + let panel_items_before = + terminal_panel.read_with(cx, |panel, cx| panel.active_pane.read(cx).items_len()); + + window_handle + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + TerminalPanel::new_terminal( + workspace, + &workspace::NewTerminal::default(), + window, + cx, + ); + }) + }) + .expect("Failed to dispatch new_terminal"); + cx.run_until_parked(); + + let center_items_after = window_handle + .read_with(cx, |multi_workspace, cx| { + multi_workspace + .workspace() + .read(cx) + .active_pane() + .read(cx) + .items_len() + }) + .expect("Failed to read center pane items"); + let panel_items_after = + terminal_panel.read_with(cx, |panel, cx| panel.active_pane.read(cx).items_len()); + + assert_eq!( + center_items_after, + center_items_before + 1, + "New terminal should be added to the center pane" + ); + assert_eq!( + panel_items_after, panel_items_before, + "Terminal panel should not gain a new terminal" + ); + } + + #[gpui::test] + async fn test_new_terminal_opens_in_panel_when_panel_focused(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + init_test(cx); + + let (window_handle, terminal_panel) = init_workspace_with_panel(cx).await; + + window_handle + .update(cx, |_, window, cx| { + terminal_panel.update(cx, |panel, cx| { + panel.add_terminal_shell(None, RevealStrategy::Always, window, cx) + }) + }) + .expect("Failed to update workspace") + .await + .expect("Failed to create panel terminal"); + cx.run_until_parked(); + + window_handle + .update(cx, |_, window, cx| { + window.focus(&terminal_panel.read(cx).focus_handle(cx), cx); + }) + .expect("Failed to focus terminal panel"); + cx.run_until_parked(); + + let panel_items_before = + terminal_panel.read_with(cx, |panel, cx| panel.active_pane.read(cx).items_len()); + + let center_items_before = window_handle + .read_with(cx, |multi_workspace, cx| { + multi_workspace + .workspace() + .read(cx) + .active_pane() + .read(cx) + .items_len() + }) + .expect("Failed to read center pane items"); + + window_handle + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + TerminalPanel::new_terminal( + workspace, + &workspace::NewTerminal::default(), + window, + cx, + ); + }) + }) + .expect("Failed to dispatch new_terminal"); + cx.run_until_parked(); + + let panel_items_after = + terminal_panel.read_with(cx, |panel, cx| panel.active_pane.read(cx).items_len()); + let center_items_after = window_handle + .read_with(cx, |multi_workspace, cx| { + multi_workspace + .workspace() + .read(cx) + .active_pane() + .read(cx) + .items_len() + }) + .expect("Failed to read center pane items"); + + assert_eq!( + panel_items_after, + panel_items_before + 1, + "New terminal should be added to the panel when panel is focused" + ); + assert_eq!( + center_items_after, center_items_before, + "Center pane should not gain a new terminal" + ); + } + + #[gpui::test] + async fn test_new_local_terminal_opens_in_center_when_center_terminal_focused( + cx: &mut TestAppContext, + ) { + cx.executor().allow_parking(); + init_test(cx); + + let (window_handle, terminal_panel) = init_workspace_with_panel(cx).await; + + window_handle + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + TerminalPanel::add_center_terminal(workspace, window, cx, |project, cx| { + project.create_terminal_shell(None, cx) + }) + }) + }) + .expect("Failed to update workspace") + .await + .expect("Failed to create center terminal"); + cx.run_until_parked(); + + window_handle + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + let active_item = workspace + .active_pane() + .read(cx) + .active_item() + .expect("Center pane should have an active item"); + let terminal_view = active_item + .downcast::() + .expect("Active center item should be a TerminalView"); + window.focus(&terminal_view.focus_handle(cx), cx); + }) + }) + .expect("Failed to focus terminal view"); + cx.run_until_parked(); + + let center_items_before = window_handle + .read_with(cx, |multi_workspace, cx| { + multi_workspace + .workspace() + .read(cx) + .active_pane() + .read(cx) + .items_len() + }) + .expect("Failed to read center pane items"); + let panel_items_before = + terminal_panel.read_with(cx, |panel, cx| panel.active_pane.read(cx).items_len()); + + window_handle + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + TerminalPanel::new_terminal( + workspace, + &workspace::NewTerminal { local: true }, + window, + cx, + ); + }) + }) + .expect("Failed to dispatch new_terminal with local=true"); + cx.run_until_parked(); + + let center_items_after = window_handle + .read_with(cx, |multi_workspace, cx| { + multi_workspace + .workspace() + .read(cx) + .active_pane() + .read(cx) + .items_len() + }) + .expect("Failed to read center pane items"); + let panel_items_after = + terminal_panel.read_with(cx, |panel, cx| panel.active_pane.read(cx).items_len()); + + assert_eq!( + center_items_after, + center_items_before + 1, + "New local terminal should be added to the center pane" + ); + assert_eq!( + panel_items_after, panel_items_before, + "Terminal panel should not gain a new terminal" + ); + } + + #[gpui::test] + async fn test_new_terminal_opens_in_panel_when_panel_focused_and_center_has_terminal( + cx: &mut TestAppContext, + ) { + cx.executor().allow_parking(); + init_test(cx); + + let (window_handle, terminal_panel) = init_workspace_with_panel(cx).await; + + window_handle + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + TerminalPanel::add_center_terminal(workspace, window, cx, |project, cx| { + project.create_terminal_shell(None, cx) + }) + }) + }) + .expect("Failed to update workspace") + .await + .expect("Failed to create center terminal"); + cx.run_until_parked(); + + window_handle + .update(cx, |_, window, cx| { + terminal_panel.update(cx, |panel, cx| { + panel.add_terminal_shell(None, RevealStrategy::Always, window, cx) + }) + }) + .expect("Failed to update workspace") + .await + .expect("Failed to create panel terminal"); + cx.run_until_parked(); + + window_handle + .update(cx, |_, window, cx| { + window.focus(&terminal_panel.read(cx).focus_handle(cx), cx); + }) + .expect("Failed to focus terminal panel"); + cx.run_until_parked(); + + let panel_items_before = + terminal_panel.read_with(cx, |panel, cx| panel.active_pane.read(cx).items_len()); + let center_items_before = window_handle + .read_with(cx, |multi_workspace, cx| { + multi_workspace + .workspace() + .read(cx) + .active_pane() + .read(cx) + .items_len() + }) + .expect("Failed to read center pane items"); + + window_handle + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + TerminalPanel::new_terminal( + workspace, + &workspace::NewTerminal::default(), + window, + cx, + ); + }) + }) + .expect("Failed to dispatch new_terminal"); + cx.run_until_parked(); + + let panel_items_after = + terminal_panel.read_with(cx, |panel, cx| panel.active_pane.read(cx).items_len()); + let center_items_after = window_handle + .read_with(cx, |multi_workspace, cx| { + multi_workspace + .workspace() + .read(cx) + .active_pane() + .read(cx) + .items_len() + }) + .expect("Failed to read center pane items"); + + assert_eq!( + panel_items_after, + panel_items_before + 1, + "New terminal should go to panel when panel is focused, even if center has a terminal" + ); + assert_eq!( + center_items_after, center_items_before, + "Center pane should not gain a new terminal when panel is focused" + ); + } + fn set_max_tabs(cx: &mut TestAppContext, value: Option) { cx.update_global(|store: &mut SettingsStore, cx| { store.update_user_settings(cx, |settings| { @@ -1916,7 +2360,7 @@ mod tests { cx.update(|cx| { let store = SettingsStore::test(cx); cx.set_global(store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); crate::init(cx); }); diff --git a/crates/terminal_view/src/terminal_path_like_target.rs b/crates/terminal_view/src/terminal_path_like_target.rs index 18eab6fc5b4ccca1bcc6db33a35dc490582037ac..f0f13d8fc2cd737722f30d7e56248e4284ed4495 100644 --- a/crates/terminal_view/src/terminal_path_like_target.rs +++ b/crates/terminal_view/src/terminal_path_like_target.rs @@ -554,7 +554,7 @@ mod tests { let fs = app_cx.update(AppState::test).fs.as_fake().clone(); app_cx.update(|cx| { - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); }); diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 0b2bfa44870282de79d63a74e507115fb198ed66..b97a0845dbac5447158db01179dbf0849c72ea87 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -6,7 +6,10 @@ pub mod terminal_scrollbar; mod terminal_slash_command; use assistant_slash_command::SlashCommandRegistry; -use editor::{Editor, EditorSettings, actions::SelectAll, blink_manager::BlinkManager}; +use editor::{ + Editor, EditorSettings, actions::SelectAll, blink_manager::BlinkManager, + ui_scrollbar_settings_from_raw, +}; use gpui::{ Action, AnyElement, App, ClipboardEntry, DismissEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, Font, KeyContext, KeyDownEvent, Keystroke, MouseButton, MouseDownEvent, @@ -48,7 +51,7 @@ use terminal_slash_command::TerminalSlashCommand; use ui::{ ContextMenu, Divider, ScrollAxes, Scrollbars, Tooltip, WithScrollbar, prelude::*, - scrollbars::{self, GlobalSetting, ScrollbarVisibility}, + scrollbars::{self, ScrollbarVisibility}, }; use util::ResultExt; use workspace::{ @@ -754,7 +757,14 @@ impl TerminalView { } pub fn should_show_cursor(&self, focused: bool, cx: &mut Context) -> bool { - // Always show cursor when not focused or in special modes + // Hide cursor when in embedded mode and not focused (read-only output like Agent panel) + if let TerminalMode::Embedded { .. } = &self.mode { + if !focused { + return false; + } + } + + // For Standalone mode: always show cursor when not focused or in special modes if !focused || self .terminal @@ -1114,20 +1124,15 @@ fn regex_search_for_query(query: &SearchQuery) -> Option { } } +#[derive(Default)] struct TerminalScrollbarSettingsWrapper; -impl GlobalSetting for TerminalScrollbarSettingsWrapper { - fn get_value(_cx: &App) -> &Self { - &Self - } -} - impl ScrollbarVisibility for TerminalScrollbarSettingsWrapper { fn visibility(&self, cx: &App) -> scrollbars::ShowScrollbar { TerminalSettings::get_global(cx) .scrollbar .show - .map(Into::into) + .map(ui_scrollbar_settings_from_raw) .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show) } } @@ -1350,9 +1355,16 @@ impl Item for TerminalView { None => (IconName::Terminal, Color::Muted, None), }; + let self_handle = self.self_handle.clone(); h_flex() .gap_1() .group("term-tab-icon") + .track_focus(&self.focus_handle) + .on_action(move |action: &RenameTerminal, window, cx| { + self_handle + .update(cx, |this, cx| this.rename_terminal(action, window, cx)) + .ok(); + }) .child( h_flex() .group("term-tab-icon") @@ -2208,7 +2220,7 @@ mod tests { ) { let params = cx.update(AppState::test); cx.update(|cx| { - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); }); let project = Project::test(params.fs.clone(), [], cx).await; diff --git a/crates/text/src/text.rs b/crates/text/src/text.rs index ee095a7f19fd1acf8b1b4a1526fb16b00e3fd43f..b8f2ce6ce9b66040b4e633d28bfb42e1791a38ca 100644 --- a/crates/text/src/text.rs +++ b/crates/text/src/text.rs @@ -223,10 +223,11 @@ impl History { redo_stack: Vec::new(), transaction_depth: 0, // Don't group transactions in tests unless we opt in, because it's a footgun. - #[cfg(any(test, feature = "test-support"))] - group_interval: Duration::ZERO, - #[cfg(not(any(test, feature = "test-support")))] - group_interval: Duration::from_millis(300), + group_interval: if cfg!(any(test, feature = "test-support")) { + Duration::ZERO + } else { + Duration::from_millis(300) + }, } } @@ -1825,6 +1826,10 @@ impl Buffer { tx.try_send(()).ok(); } } + + pub fn set_group_interval(&mut self, group_interval: Duration) { + self.history.group_interval = group_interval; + } } #[cfg(any(test, feature = "test-support"))] @@ -1929,10 +1934,6 @@ impl Buffer { assert!(!self.text().contains("\r\n")); } - pub fn set_group_interval(&mut self, group_interval: Duration) { - self.history.group_interval = group_interval; - } - pub fn random_byte_range(&self, start_offset: usize, rng: &mut impl rand::Rng) -> Range { let end = self.clip_offset(rng.random_range(start_offset..=self.len()), Bias::Right); let start = self.clip_offset(rng.random_range(start_offset..=end), Bias::Right); diff --git a/crates/theme/Cargo.toml b/crates/theme/Cargo.toml index ef193c500d461201e8746ad3ec0f33b01e423b18..dcfa711554ec7457c63d5ce9c9488e337de78836 100644 --- a/crates/theme/Cargo.toml +++ b/crates/theme/Cargo.toml @@ -10,7 +10,7 @@ workspace = true [features] default = [] -test-support = ["gpui/test-support", "fs/test-support", "settings/test-support"] +test-support = ["gpui/test-support"] [lib] path = "src/theme.rs" @@ -20,10 +20,7 @@ doctest = false anyhow.workspace = true collections.workspace = true derive_more.workspace = true -fs.workspace = true -futures.workspace = true gpui.workspace = true -log.workspace = true palette = { workspace = true, default-features = false, features = ["std"] } parking_lot.workspace = true refineable.workspace = true @@ -31,13 +28,9 @@ schemars = { workspace = true, features = ["indexmap2"] } serde.workspace = true serde_json.workspace = true serde_json_lenient.workspace = true -settings.workspace = true strum.workspace = true thiserror.workspace = true -util.workspace = true uuid.workspace = true [dev-dependencies] -fs = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } -settings = { workspace = true, features = ["test-support"] } diff --git a/crates/theme/src/fallback_themes.rs b/crates/theme/src/fallback_themes.rs index 72b65f85c9ecb2776fc6066c8b926cfa4bd42929..ba7f600fb05cc160f8d2668cf549853c8ae39ebe 100644 --- a/crates/theme/src/fallback_themes.rs +++ b/crates/theme/src/fallback_themes.rs @@ -25,7 +25,8 @@ pub fn zed_default_themes() -> ThemeFamily { // If a theme customizes a foreground version of a status color, but does not // customize the background color, then use a partly-transparent version of the // foreground color for the background color. -pub(crate) fn apply_status_color_defaults(status: &mut StatusColorsRefinement) { +/// Applies default status color backgrounds from their foreground counterparts. +pub fn apply_status_color_defaults(status: &mut StatusColorsRefinement) { for (fg_color, bg_color) in [ (&status.deleted, &mut status.deleted_background), (&status.created, &mut status.created_background), @@ -42,7 +43,8 @@ pub(crate) fn apply_status_color_defaults(status: &mut StatusColorsRefinement) { } } -pub(crate) fn apply_theme_color_defaults( +/// Applies default theme color values derived from player colors. +pub fn apply_theme_color_defaults( theme_colors: &mut ThemeColorsRefinement, player_colors: &PlayerColors, ) { @@ -314,70 +316,68 @@ pub(crate) fn zed_default_dark() -> Theme { warning_border: yellow, }, player, - syntax: Arc::new(SyntaxTheme { - highlights: vec![ - ("attribute".into(), purple.into()), - ("boolean".into(), orange.into()), - ("comment".into(), gray.into()), - ("comment.doc".into(), gray.into()), - ("constant".into(), yellow.into()), - ("constructor".into(), blue.into()), - ("embedded".into(), HighlightStyle::default()), - ( - "emphasis".into(), - HighlightStyle { - font_style: Some(FontStyle::Italic), - ..HighlightStyle::default() - }, - ), - ( - "emphasis.strong".into(), - HighlightStyle { - font_weight: Some(FontWeight::BOLD), - ..HighlightStyle::default() - }, - ), - ("enum".into(), teal.into()), - ("function".into(), blue.into()), - ("function.method".into(), blue.into()), - ("function.definition".into(), blue.into()), - ("hint".into(), blue.into()), - ("keyword".into(), purple.into()), - ("label".into(), HighlightStyle::default()), - ("link_text".into(), blue.into()), - ( - "link_uri".into(), - HighlightStyle { - color: Some(teal), - font_style: Some(FontStyle::Italic), - ..HighlightStyle::default() - }, - ), - ("number".into(), orange.into()), - ("operator".into(), HighlightStyle::default()), - ("predictive".into(), HighlightStyle::default()), - ("preproc".into(), HighlightStyle::default()), - ("primary".into(), HighlightStyle::default()), - ("property".into(), red.into()), - ("punctuation".into(), HighlightStyle::default()), - ("punctuation.bracket".into(), HighlightStyle::default()), - ("punctuation.delimiter".into(), HighlightStyle::default()), - ("punctuation.list_marker".into(), HighlightStyle::default()), - ("punctuation.special".into(), HighlightStyle::default()), - ("string".into(), green.into()), - ("string.escape".into(), HighlightStyle::default()), - ("string.regex".into(), red.into()), - ("string.special".into(), HighlightStyle::default()), - ("string.special.symbol".into(), HighlightStyle::default()), - ("tag".into(), HighlightStyle::default()), - ("text.literal".into(), HighlightStyle::default()), - ("title".into(), HighlightStyle::default()), - ("type".into(), teal.into()), - ("variable".into(), HighlightStyle::default()), - ("variable.special".into(), red.into()), - ("variant".into(), HighlightStyle::default()), - ], - }), + syntax: Arc::new(SyntaxTheme::new(vec![ + ("attribute".into(), purple.into()), + ("boolean".into(), orange.into()), + ("comment".into(), gray.into()), + ("comment.doc".into(), gray.into()), + ("constant".into(), yellow.into()), + ("constructor".into(), blue.into()), + ("embedded".into(), HighlightStyle::default()), + ( + "emphasis".into(), + HighlightStyle { + font_style: Some(FontStyle::Italic), + ..HighlightStyle::default() + }, + ), + ( + "emphasis.strong".into(), + HighlightStyle { + font_weight: Some(FontWeight::BOLD), + ..HighlightStyle::default() + }, + ), + ("enum".into(), teal.into()), + ("function".into(), blue.into()), + ("function.method".into(), blue.into()), + ("function.definition".into(), blue.into()), + ("hint".into(), blue.into()), + ("keyword".into(), purple.into()), + ("label".into(), HighlightStyle::default()), + ("link_text".into(), blue.into()), + ( + "link_uri".into(), + HighlightStyle { + color: Some(teal), + font_style: Some(FontStyle::Italic), + ..HighlightStyle::default() + }, + ), + ("number".into(), orange.into()), + ("operator".into(), HighlightStyle::default()), + ("predictive".into(), HighlightStyle::default()), + ("preproc".into(), HighlightStyle::default()), + ("primary".into(), HighlightStyle::default()), + ("property".into(), red.into()), + ("punctuation".into(), HighlightStyle::default()), + ("punctuation.bracket".into(), HighlightStyle::default()), + ("punctuation.delimiter".into(), HighlightStyle::default()), + ("punctuation.list_marker".into(), HighlightStyle::default()), + ("punctuation.special".into(), HighlightStyle::default()), + ("string".into(), green.into()), + ("string.escape".into(), HighlightStyle::default()), + ("string.regex".into(), red.into()), + ("string.special".into(), HighlightStyle::default()), + ("string.special.symbol".into(), HighlightStyle::default()), + ("tag".into(), HighlightStyle::default()), + ("text.literal".into(), HighlightStyle::default()), + ("title".into(), HighlightStyle::default()), + ("type".into(), teal.into()), + ("variable".into(), HighlightStyle::default()), + ("variable.special".into(), red.into()), + ("variant".into(), HighlightStyle::default()), + ])), }, } } diff --git a/crates/theme/src/icon_theme.rs b/crates/theme/src/icon_theme.rs index 121ff9d7d4fbd841315b89e631606c7e67bc5cde..314978218194895d802028be19a7b3bdb454bf9c 100644 --- a/crates/theme/src/icon_theme.rs +++ b/crates/theme/src/icon_theme.rs @@ -115,7 +115,8 @@ const FILE_SUFFIXES_BY_ICON_KEY: &[(&str, &[&str])] = &[ "xlsx", ], ), - ("elixir", &["eex", "ex", "exs", "heex"]), + ("editorconfig", &["editorconfig"]), + ("elixir", &["eex", "ex", "exs", "heex", "leex", "neex"]), ("elm", &["elm"]), ( "erlang", @@ -328,6 +329,7 @@ const FILE_ICONS: &[(&str, &str)] = &[ ("diff", "icons/file_icons/diff.svg"), ("docker", "icons/file_icons/docker.svg"), ("document", "icons/file_icons/book.svg"), + ("editorconfig", "icons/file_icons/editorconfig.svg"), ("elixir", "icons/file_icons/elixir.svg"), ("elm", "icons/file_icons/elm.svg"), ("erlang", "icons/file_icons/erlang.svg"), @@ -416,7 +418,7 @@ fn icon_keys_by_association( } /// The name of the default icon theme. -pub(crate) const DEFAULT_ICON_THEME_NAME: &str = "Zed (Default)"; +pub const DEFAULT_ICON_THEME_NAME: &str = "Zed (Default)"; static DEFAULT_ICON_THEME: LazyLock> = LazyLock::new(|| { Arc::new(IconTheme { diff --git a/crates/theme/src/registry.rs b/crates/theme/src/registry.rs index c362b62704257fefde125e81ca1c056490263b0b..fbe535309773fa5c90c2031d44b420cf5fad2dc7 100644 --- a/crates/theme/src/registry.rs +++ b/crates/theme/src/registry.rs @@ -1,20 +1,16 @@ use std::sync::Arc; use std::{fmt::Debug, path::Path}; -use anyhow::{Context as _, Result}; +use anyhow::Result; use collections::HashMap; use derive_more::{Deref, DerefMut}; -use fs::Fs; -use futures::StreamExt; use gpui::{App, AssetSource, Global, SharedString}; use parking_lot::RwLock; use thiserror::Error; -use util::ResultExt; use crate::{ Appearance, AppearanceContent, ChevronIcons, DEFAULT_ICON_THEME_NAME, DirectoryIcons, - IconDefinition, IconTheme, Theme, ThemeFamily, ThemeFamilyContent, default_icon_theme, - read_icon_theme, read_user_theme, refine_theme_family, + IconDefinition, IconTheme, IconThemeFamilyContent, Theme, ThemeFamily, default_icon_theme, }; /// The metadata for a theme. @@ -83,6 +79,11 @@ impl ThemeRegistry { cx.set_global(GlobalThemeRegistry(Arc::new(ThemeRegistry::new(assets)))); } + /// Returns the asset source used by this registry. + pub fn assets(&self) -> &dyn AssetSource { + self.assets.as_ref() + } + /// Creates a new [`ThemeRegistry`] with the given [`AssetSource`]. pub fn new(assets: Box) -> Self { let registry = Self { @@ -118,28 +119,21 @@ impl ThemeRegistry { self.state.write().extensions_loaded = true; } - fn insert_theme_families(&self, families: impl IntoIterator) { + /// Inserts the given theme families into the registry. + pub fn insert_theme_families(&self, families: impl IntoIterator) { for family in families.into_iter() { self.insert_themes(family.themes); } } - fn insert_themes(&self, themes: impl IntoIterator) { + /// Inserts the given themes into the registry. + pub fn insert_themes(&self, themes: impl IntoIterator) { let mut state = self.state.write(); for theme in themes.into_iter() { state.themes.insert(theme.name.clone(), Arc::new(theme)); } } - #[allow(unused)] - fn insert_user_theme_families(&self, families: impl IntoIterator) { - for family in families.into_iter() { - let refined_family = refine_theme_family(family); - - self.insert_themes(refined_family.themes); - } - } - /// Removes the themes with the given names from the registry. pub fn remove_user_themes(&self, themes_to_remove: &[SharedString]) { self.state @@ -183,60 +177,6 @@ impl ThemeRegistry { .cloned() } - /// Loads the themes bundled with the Zed binary and adds them to the registry. - pub fn load_bundled_themes(&self) { - let theme_paths = self - .assets - .list("themes/") - .expect("failed to list theme assets") - .into_iter() - .filter(|path| path.ends_with(".json")); - - for path in theme_paths { - let Some(theme) = self.assets.load(&path).log_err().flatten() else { - continue; - }; - - let Some(theme_family) = serde_json::from_slice(&theme) - .with_context(|| format!("failed to parse theme at path \"{path}\"")) - .log_err() - else { - continue; - }; - - self.insert_user_theme_families([theme_family]); - } - } - - /// Loads the user themes from the specified directory and adds them to the registry. - pub async fn load_user_themes(&self, themes_path: &Path, fs: Arc) -> Result<()> { - let mut theme_paths = fs - .read_dir(themes_path) - .await - .with_context(|| format!("reading themes from {themes_path:?}"))?; - - while let Some(theme_path) = theme_paths.next().await { - let Some(theme_path) = theme_path.log_err() else { - continue; - }; - - self.load_user_theme(&theme_path, fs.clone()) - .await - .log_err(); - } - - Ok(()) - } - - /// Loads the user theme from the specified path and adds it to the registry. - pub async fn load_user_theme(&self, theme_path: &Path, fs: Arc) -> Result<()> { - let theme = read_user_theme(theme_path, fs).await?; - - self.insert_user_theme_families([theme]); - - Ok(()) - } - /// Returns the default icon theme. pub fn default_icon_theme(&self) -> Result, IconThemeNotFoundError> { self.get_icon_theme(DEFAULT_ICON_THEME_NAME) @@ -273,18 +213,15 @@ impl ThemeRegistry { .retain(|name, _| !icon_themes_to_remove.contains(name)) } - /// Loads the icon theme from the specified path and adds it to the registry. + /// Loads the icon theme from the icon theme family and adds it to the registry. /// /// The `icons_root_dir` parameter indicates the root directory from which /// the relative paths to icons in the theme should be resolved against. - pub async fn load_icon_theme( + pub fn load_icon_theme( &self, - icon_theme_path: &Path, + icon_theme_family: IconThemeFamilyContent, icons_root_dir: &Path, - fs: Arc, ) -> Result<()> { - let icon_theme_family = read_icon_theme(icon_theme_path, fs).await?; - let resolve_icon_path = |path: SharedString| { icons_root_dir .join(path.as_ref()) diff --git a/crates/theme/src/schema.rs b/crates/theme/src/schema.rs index 61cf869b951ac4d285e1eaca42e226a6ac3e4a6a..56b89314a3442613890322cb7b9239fc7fc5b77e 100644 --- a/crates/theme/src/schema.rs +++ b/crates/theme/src/schema.rs @@ -1,30 +1,11 @@ #![allow(missing_docs)] -use gpui::{HighlightStyle, Hsla}; +use gpui::Hsla; use palette::FromColor; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use settings::IntoGpui; -pub use settings::{FontWeightContent, WindowBackgroundContent}; - -use crate::{StatusColorsRefinement, ThemeColorsRefinement}; - -fn ensure_non_opaque(color: Hsla) -> Hsla { - const MAXIMUM_OPACITY: f32 = 0.7; - if color.a <= MAXIMUM_OPACITY { - color - } else { - Hsla { - a: MAXIMUM_OPACITY, - ..color - } - } -} - -fn ensure_opaque(color: Hsla) -> Hsla { - Hsla { a: 1.0, ..color } -} +/// The appearance of a theme in serialized content. #[derive(Debug, PartialEq, Clone, Copy, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum AppearanceContent { @@ -32,819 +13,8 @@ pub enum AppearanceContent { Dark, } -/// The content of a serialized theme family. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct ThemeFamilyContent { - pub name: String, - pub author: String, - pub themes: Vec, -} - -/// The content of a serialized theme. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct ThemeContent { - pub name: String, - pub appearance: AppearanceContent, - pub style: settings::ThemeStyleContent, -} - -/// Returns the syntax style overrides in the [`ThemeContent`]. -pub fn syntax_overrides(this: &settings::ThemeStyleContent) -> Vec<(String, HighlightStyle)> { - this.syntax - .iter() - .map(|(key, style)| { - ( - key.clone(), - HighlightStyle { - color: style - .color - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - background_color: style - .background_color - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - font_style: style.font_style.map(|s| s.into_gpui()), - font_weight: style.font_weight.map(|w| w.into_gpui()), - ..Default::default() - }, - ) - }) - .collect() -} - -pub fn status_colors_refinement(colors: &settings::StatusColorsContent) -> StatusColorsRefinement { - StatusColorsRefinement { - conflict: colors - .conflict - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - conflict_background: colors - .conflict_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - conflict_border: colors - .conflict_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - created: colors - .created - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - created_background: colors - .created_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - created_border: colors - .created_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - deleted: colors - .deleted - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - deleted_background: colors - .deleted_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - deleted_border: colors - .deleted_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - error: colors - .error - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - error_background: colors - .error_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - error_border: colors - .error_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - hidden: colors - .hidden - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - hidden_background: colors - .hidden_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - hidden_border: colors - .hidden_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - hint: colors - .hint - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - hint_background: colors - .hint_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - hint_border: colors - .hint_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - ignored: colors - .ignored - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - ignored_background: colors - .ignored_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - ignored_border: colors - .ignored_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - info: colors - .info - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - info_background: colors - .info_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - info_border: colors - .info_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - modified: colors - .modified - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - modified_background: colors - .modified_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - modified_border: colors - .modified_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - predictive: colors - .predictive - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - predictive_background: colors - .predictive_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - predictive_border: colors - .predictive_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - renamed: colors - .renamed - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - renamed_background: colors - .renamed_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - renamed_border: colors - .renamed_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - success: colors - .success - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - success_background: colors - .success_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - success_border: colors - .success_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - unreachable: colors - .unreachable - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - unreachable_background: colors - .unreachable_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - unreachable_border: colors - .unreachable_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - warning: colors - .warning - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - warning_background: colors - .warning_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - warning_border: colors - .warning_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - } -} - -pub fn theme_colors_refinement( - this: &settings::ThemeColorsContent, - status_colors: &StatusColorsRefinement, -) -> ThemeColorsRefinement { - let border = this - .border - .as_ref() - .and_then(|color| try_parse_color(color).ok()); - let editor_document_highlight_read_background = this - .editor_document_highlight_read_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()); - let scrollbar_thumb_background = this - .scrollbar_thumb_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - .or_else(|| { - this.deprecated_scrollbar_thumb_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - }); - let scrollbar_thumb_hover_background = this - .scrollbar_thumb_hover_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()); - let scrollbar_thumb_active_background = this - .scrollbar_thumb_active_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - .or(scrollbar_thumb_background); - let scrollbar_thumb_border = this - .scrollbar_thumb_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()); - let element_hover = this - .element_hover - .as_ref() - .and_then(|color| try_parse_color(color).ok()); - let panel_background = this - .panel_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()); - let search_match_background = this - .search_match_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()); - let search_active_match_background = this - .search_active_match_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - .or(search_match_background); - ThemeColorsRefinement { - border, - border_variant: this - .border_variant - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - border_focused: this - .border_focused - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - border_selected: this - .border_selected - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - border_transparent: this - .border_transparent - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - border_disabled: this - .border_disabled - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - elevated_surface_background: this - .elevated_surface_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - surface_background: this - .surface_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - background: this - .background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - element_background: this - .element_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - element_hover, - element_active: this - .element_active - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - element_selected: this - .element_selected - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - element_disabled: this - .element_disabled - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - element_selection_background: this - .element_selection_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - drop_target_background: this - .drop_target_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - drop_target_border: this - .drop_target_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - ghost_element_background: this - .ghost_element_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - ghost_element_hover: this - .ghost_element_hover - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - ghost_element_active: this - .ghost_element_active - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - ghost_element_selected: this - .ghost_element_selected - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - ghost_element_disabled: this - .ghost_element_disabled - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - text: this - .text - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - text_muted: this - .text_muted - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - text_placeholder: this - .text_placeholder - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - text_disabled: this - .text_disabled - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - text_accent: this - .text_accent - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - icon: this - .icon - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - icon_muted: this - .icon_muted - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - icon_disabled: this - .icon_disabled - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - icon_placeholder: this - .icon_placeholder - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - icon_accent: this - .icon_accent - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - debugger_accent: this - .debugger_accent - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - status_bar_background: this - .status_bar_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - title_bar_background: this - .title_bar_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - title_bar_inactive_background: this - .title_bar_inactive_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - toolbar_background: this - .toolbar_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - tab_bar_background: this - .tab_bar_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - tab_inactive_background: this - .tab_inactive_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - tab_active_background: this - .tab_active_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - search_match_background: search_match_background, - search_active_match_background: search_active_match_background, - panel_background, - panel_focused_border: this - .panel_focused_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - panel_indent_guide: this - .panel_indent_guide - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - panel_indent_guide_hover: this - .panel_indent_guide_hover - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - panel_indent_guide_active: this - .panel_indent_guide_active - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - panel_overlay_background: this - .panel_overlay_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - .or(panel_background.map(ensure_opaque)), - panel_overlay_hover: this - .panel_overlay_hover - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - .or(panel_background - .zip(element_hover) - .map(|(panel_bg, hover_bg)| panel_bg.blend(hover_bg)) - .map(ensure_opaque)), - pane_focused_border: this - .pane_focused_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - pane_group_border: this - .pane_group_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - .or(border), - scrollbar_thumb_background, - scrollbar_thumb_hover_background, - scrollbar_thumb_active_background, - scrollbar_thumb_border, - scrollbar_track_background: this - .scrollbar_track_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - scrollbar_track_border: this - .scrollbar_track_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - minimap_thumb_background: this - .minimap_thumb_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - .or(scrollbar_thumb_background.map(ensure_non_opaque)), - minimap_thumb_hover_background: this - .minimap_thumb_hover_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - .or(scrollbar_thumb_hover_background.map(ensure_non_opaque)), - minimap_thumb_active_background: this - .minimap_thumb_active_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - .or(scrollbar_thumb_active_background.map(ensure_non_opaque)), - minimap_thumb_border: this - .minimap_thumb_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - .or(scrollbar_thumb_border), - editor_foreground: this - .editor_foreground - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_background: this - .editor_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_gutter_background: this - .editor_gutter_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_subheader_background: this - .editor_subheader_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_active_line_background: this - .editor_active_line_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_highlighted_line_background: this - .editor_highlighted_line_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_debugger_active_line_background: this - .editor_debugger_active_line_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_line_number: this - .editor_line_number - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_hover_line_number: this - .editor_hover_line_number - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_active_line_number: this - .editor_active_line_number - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_invisible: this - .editor_invisible - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_wrap_guide: this - .editor_wrap_guide - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_active_wrap_guide: this - .editor_active_wrap_guide - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_indent_guide: this - .editor_indent_guide - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_indent_guide_active: this - .editor_indent_guide_active - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_document_highlight_read_background, - editor_document_highlight_write_background: this - .editor_document_highlight_write_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_document_highlight_bracket_background: this - .editor_document_highlight_bracket_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - // Fall back to `editor.document_highlight.read_background`, for backwards compatibility. - .or(editor_document_highlight_read_background), - terminal_background: this - .terminal_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_background: this - .terminal_ansi_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_foreground: this - .terminal_foreground - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_bright_foreground: this - .terminal_bright_foreground - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_dim_foreground: this - .terminal_dim_foreground - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_black: this - .terminal_ansi_black - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_bright_black: this - .terminal_ansi_bright_black - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_dim_black: this - .terminal_ansi_dim_black - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_red: this - .terminal_ansi_red - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_bright_red: this - .terminal_ansi_bright_red - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_dim_red: this - .terminal_ansi_dim_red - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_green: this - .terminal_ansi_green - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_bright_green: this - .terminal_ansi_bright_green - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_dim_green: this - .terminal_ansi_dim_green - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_yellow: this - .terminal_ansi_yellow - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_bright_yellow: this - .terminal_ansi_bright_yellow - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_dim_yellow: this - .terminal_ansi_dim_yellow - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_blue: this - .terminal_ansi_blue - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_bright_blue: this - .terminal_ansi_bright_blue - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_dim_blue: this - .terminal_ansi_dim_blue - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_magenta: this - .terminal_ansi_magenta - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_bright_magenta: this - .terminal_ansi_bright_magenta - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_dim_magenta: this - .terminal_ansi_dim_magenta - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_cyan: this - .terminal_ansi_cyan - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_bright_cyan: this - .terminal_ansi_bright_cyan - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_dim_cyan: this - .terminal_ansi_dim_cyan - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_white: this - .terminal_ansi_white - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_bright_white: this - .terminal_ansi_bright_white - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_dim_white: this - .terminal_ansi_dim_white - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - link_text_hover: this - .link_text_hover - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - version_control_added: this - .version_control_added - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - // Fall back to `created`, for backwards compatibility. - .or(status_colors.created), - version_control_deleted: this - .version_control_deleted - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - // Fall back to `deleted`, for backwards compatibility. - .or(status_colors.deleted), - version_control_modified: this - .version_control_modified - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - // Fall back to `modified`, for backwards compatibility. - .or(status_colors.modified), - version_control_renamed: this - .version_control_renamed - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - // Fall back to `modified`, for backwards compatibility. - .or(status_colors.modified), - version_control_conflict: this - .version_control_conflict - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - // Fall back to `ignored`, for backwards compatibility. - .or(status_colors.ignored), - version_control_ignored: this - .version_control_ignored - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - // Fall back to `conflict`, for backwards compatibility. - .or(status_colors.ignored), - version_control_word_added: this - .version_control_word_added - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - version_control_word_deleted: this - .version_control_word_deleted - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - #[allow(deprecated)] - version_control_conflict_marker_ours: this - .version_control_conflict_marker_ours - .as_ref() - .or(this.version_control_conflict_ours_background.as_ref()) - .and_then(|color| try_parse_color(color).ok()), - #[allow(deprecated)] - version_control_conflict_marker_theirs: this - .version_control_conflict_marker_theirs - .as_ref() - .or(this.version_control_conflict_theirs_background.as_ref()) - .and_then(|color| try_parse_color(color).ok()), - vim_normal_background: this - .vim_normal_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - vim_insert_background: this - .vim_insert_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - vim_replace_background: this - .vim_replace_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - vim_visual_background: this - .vim_visual_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - vim_visual_line_background: this - .vim_visual_line_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - vim_visual_block_background: this - .vim_visual_block_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - vim_yank_background: this - .vim_yank_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - .or(editor_document_highlight_read_background), - vim_helix_normal_background: this - .vim_helix_normal_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - vim_helix_select_background: this - .vim_helix_select_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - vim_normal_foreground: this - .vim_normal_foreground - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - vim_insert_foreground: this - .vim_insert_foreground - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - vim_replace_foreground: this - .vim_replace_foreground - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - vim_visual_foreground: this - .vim_visual_foreground - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - vim_visual_line_foreground: this - .vim_visual_line_foreground - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - vim_visual_block_foreground: this - .vim_visual_block_foreground - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - vim_helix_normal_foreground: this - .vim_helix_normal_foreground - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - vim_helix_select_foreground: this - .vim_helix_select_foreground - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - } -} - -pub(crate) fn try_parse_color(color: &str) -> anyhow::Result { +/// Parses a color string into an [`Hsla`] value. +pub fn try_parse_color(color: &str) -> anyhow::Result { let rgba = gpui::Rgba::try_from(color)?; let rgba = palette::rgb::Srgba::from_components((rgba.r, rgba.g, rgba.b, rgba.a)); let hsla = palette::Hsla::from_color(rgba); diff --git a/crates/theme/src/styles/accents.rs b/crates/theme/src/styles/accents.rs index 7e42ffe2e5bfa6449a64203ffcd5e49720382d06..751a12849d62c3a08fc274b2ff2f12b0fa3280cc 100644 --- a/crates/theme/src/styles/accents.rs +++ b/crates/theme/src/styles/accents.rs @@ -5,7 +5,6 @@ use serde::Deserialize; use crate::{ amber, blue, cyan, gold, grass, indigo, iris, jade, lime, orange, pink, purple, tomato, - try_parse_color, }; /// A collection of colors that are used to color indent aware lines in the editor. @@ -66,25 +65,4 @@ impl AccentColors { pub fn color_for_index(&self, index: u32) -> Hsla { self.0[index as usize % self.0.len()] } - - /// Merges the given accent colors into this [`AccentColors`] instance. - pub fn merge(&mut self, accent_colors: &[settings::AccentContent]) { - if accent_colors.is_empty() { - return; - } - - let colors = accent_colors - .iter() - .filter_map(|accent_color| { - accent_color - .0 - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - }) - .collect::>(); - - if !colors.is_empty() { - self.0 = Arc::from(colors); - } - } } diff --git a/crates/theme/src/styles/players.rs b/crates/theme/src/styles/players.rs index 439dbdd437aa64e034004a4495e64a96e76ce87e..9699bf87a552e430a6bd6adb4ae8307228f35422 100644 --- a/crates/theme/src/styles/players.rs +++ b/crates/theme/src/styles/players.rs @@ -3,7 +3,7 @@ use gpui::Hsla; use serde::Deserialize; -use crate::{amber, blue, jade, lime, orange, pink, purple, red, try_parse_color}; +use crate::{amber, blue, jade, lime, orange, pink, purple, red}; #[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq)] pub struct PlayerColor { @@ -148,40 +148,4 @@ impl PlayerColors { let len = self.0.len() - 1; self.0[(participant_index as usize % len) + 1] } - - /// Merges the given player colors into this [`PlayerColors`] instance. - pub fn merge(&mut self, user_player_colors: &[settings::PlayerColorContent]) { - if user_player_colors.is_empty() { - return; - } - - for (idx, player) in user_player_colors.iter().enumerate() { - let cursor = player - .cursor - .as_ref() - .and_then(|color| try_parse_color(color).ok()); - let background = player - .background - .as_ref() - .and_then(|color| try_parse_color(color).ok()); - let selection = player - .selection - .as_ref() - .and_then(|color| try_parse_color(color).ok()); - - if let Some(player_color) = self.0.get_mut(idx) { - *player_color = PlayerColor { - cursor: cursor.unwrap_or(player_color.cursor), - background: background.unwrap_or(player_color.background), - selection: selection.unwrap_or(player_color.selection), - }; - } else { - self.0.push(PlayerColor { - cursor: cursor.unwrap_or_default(), - background: background.unwrap_or_default(), - selection: selection.unwrap_or_default(), - }); - } - } - } } diff --git a/crates/theme/src/styles/syntax.rs b/crates/theme/src/styles/syntax.rs index 6a1615387835e0db1aefa03c63efd5c27ca2518d..faf21d54f1f581efa8e44e3e9b478ed32ef93ea9 100644 --- a/crates/theme/src/styles/syntax.rs +++ b/crates/theme/src/styles/syntax.rs @@ -1,15 +1,38 @@ #![allow(missing_docs)] -use std::sync::Arc; +use std::{ + collections::{BTreeMap, btree_map::Entry}, + sync::Arc, +}; -use gpui::{HighlightStyle, Hsla}; +use gpui::HighlightStyle; +#[cfg(any(test, feature = "test-support"))] +use gpui::Hsla; #[derive(Debug, PartialEq, Eq, Clone, Default)] pub struct SyntaxTheme { - pub highlights: Vec<(String, HighlightStyle)>, + pub(self) highlights: Vec, + pub(self) capture_name_map: BTreeMap, } impl SyntaxTheme { + pub fn new(highlights: impl IntoIterator) -> Self { + let (capture_names, highlights) = highlights.into_iter().unzip(); + + Self { + capture_name_map: Self::create_capture_name_map(capture_names), + highlights, + } + } + + fn create_capture_name_map(highlights: Vec) -> BTreeMap { + highlights + .into_iter() + .enumerate() + .map(|(i, key)| (key, i)) + .collect() + } + #[cfg(any(test, feature = "test-support"))] pub fn new_test(colors: impl IntoIterator) -> Self { Self::new_test_styles(colors.into_iter().map(|(key, color)| { @@ -27,34 +50,46 @@ impl SyntaxTheme { pub fn new_test_styles( colors: impl IntoIterator, ) -> Self { - Self { - highlights: colors + Self::new( + colors .into_iter() - .map(|(key, style)| (key.to_owned(), style)) - .collect(), - } + .map(|(key, style)| (key.to_owned(), style)), + ) } - pub fn get(&self, name: &str) -> HighlightStyle { - self.highlights - .iter() - .find_map(|entry| if entry.0 == name { Some(entry.1) } else { None }) - .unwrap_or_default() + pub fn get(&self, highlight_index: impl Into) -> Option<&HighlightStyle> { + self.highlights.get(highlight_index.into()) } - pub fn get_opt(&self, name: &str) -> Option { - self.highlights - .iter() - .find_map(|entry| if entry.0 == name { Some(entry.1) } else { None }) + pub fn style_for_name(&self, name: &str) -> Option { + self.capture_name_map + .get(name) + .map(|highlight_idx| self.highlights[*highlight_idx]) } - pub fn color(&self, name: &str) -> Hsla { - self.get(name).color.unwrap_or_default() + pub fn get_capture_name(&self, idx: impl Into) -> Option<&str> { + let idx = idx.into(); + self.capture_name_map + .iter() + .find(|(_, value)| **value == idx) + .map(|(key, _)| key.as_ref()) } - pub fn highlight_id(&self, name: &str) -> Option { - let ix = self.highlights.iter().position(|entry| entry.0 == name)?; - Some(ix as u32) + pub fn highlight_id(&self, capture_name: &str) -> Option { + self.capture_name_map + .range::(( + capture_name.split(".").next().map_or( + std::ops::Bound::Included(capture_name), + std::ops::Bound::Included, + ), + std::ops::Bound::Included(capture_name), + )) + .rfind(|(prefix, _)| { + capture_name + .strip_prefix(*prefix) + .is_some_and(|remainder| remainder.is_empty() || remainder.starts_with('.')) + }) + .map(|(_, index)| *index as u32) } /// Returns a new [`Arc`] with the given syntax styles merged in. @@ -63,33 +98,36 @@ impl SyntaxTheme { return base; } - let mut merged_highlights = base.highlights.clone(); + let mut base = Arc::try_unwrap(base).unwrap_or_else(|base| (*base).clone()); for (name, highlight) in user_syntax_styles { - if let Some((_, existing_highlight)) = merged_highlights - .iter_mut() - .find(|(existing_name, _)| existing_name == &name) - { - existing_highlight.color = highlight.color.or(existing_highlight.color); - existing_highlight.font_weight = - highlight.font_weight.or(existing_highlight.font_weight); - existing_highlight.font_style = - highlight.font_style.or(existing_highlight.font_style); - existing_highlight.background_color = highlight - .background_color - .or(existing_highlight.background_color); - existing_highlight.underline = highlight.underline.or(existing_highlight.underline); - existing_highlight.strikethrough = - highlight.strikethrough.or(existing_highlight.strikethrough); - existing_highlight.fade_out = highlight.fade_out.or(existing_highlight.fade_out); - } else { - merged_highlights.push((name, highlight)); + match base.capture_name_map.entry(name) { + Entry::Occupied(entry) => { + if let Some(existing_highlight) = base.highlights.get_mut(*entry.get()) { + existing_highlight.color = highlight.color.or(existing_highlight.color); + existing_highlight.font_weight = + highlight.font_weight.or(existing_highlight.font_weight); + existing_highlight.font_style = + highlight.font_style.or(existing_highlight.font_style); + existing_highlight.background_color = highlight + .background_color + .or(existing_highlight.background_color); + existing_highlight.underline = + highlight.underline.or(existing_highlight.underline); + existing_highlight.strikethrough = + highlight.strikethrough.or(existing_highlight.strikethrough); + existing_highlight.fade_out = + highlight.fade_out.or(existing_highlight.fade_out); + } + } + Entry::Vacant(vacant) => { + vacant.insert(base.highlights.len()); + base.highlights.push(highlight); + } } } - Arc::new(Self { - highlights: merged_highlights, - }) + Arc::new(base) } } diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index ca330beee3c9604278ce187e0609f60fbc58170e..faa18bd3ce9ed71f4afed6d21d577d48b14680fb 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -16,42 +16,34 @@ mod icon_theme_schema; mod registry; mod scale; mod schema; -mod settings; mod styles; +mod theme_settings_provider; +mod ui_density; -use std::path::Path; use std::sync::Arc; -use ::settings::DEFAULT_DARK_THEME; -use ::settings::IntoGpui; -use ::settings::Settings; -use ::settings::SettingsStore; -use anyhow::Result; -use fallback_themes::apply_status_color_defaults; -use fs::Fs; +use derive_more::{Deref, DerefMut}; use gpui::BorrowAppContext; use gpui::Global; use gpui::{ - App, AssetSource, HighlightStyle, Hsla, Pixels, Refineable, SharedString, WindowAppearance, - WindowBackgroundAppearance, px, + App, AssetSource, Hsla, Pixels, SharedString, WindowAppearance, WindowBackgroundAppearance, px, }; use serde::Deserialize; -use uuid::Uuid; pub use crate::default_colors::*; -use crate::fallback_themes::apply_theme_color_defaults; +pub use crate::fallback_themes::{apply_status_color_defaults, apply_theme_color_defaults}; pub use crate::font_family_cache::*; pub use crate::icon_theme::*; pub use crate::icon_theme_schema::*; pub use crate::registry::*; pub use crate::scale::*; pub use crate::schema::*; -pub use crate::settings::*; pub use crate::styles::*; -pub use ::settings::{ - FontStyleContent, HighlightStyleContent, StatusColorsContent, ThemeColorsContent, - ThemeStyleContent, -}; +pub use crate::theme_settings_provider::*; +pub use crate::ui_density::*; + +/// The name of the default dark theme. +pub const DEFAULT_DARK_THEME: &str = "One Dark"; /// Defines window border radius for platforms that use client side decorations. pub const CLIENT_SIDE_DECORATION_ROUNDING: Pixels = px(10.0); @@ -86,15 +78,6 @@ impl From for Appearance { } } -impl From for ThemeAppearanceMode { - fn from(value: Appearance) -> Self { - match value { - Appearance::Light => Self::Light, - Appearance::Dark => Self::Dark, - } - } -} - /// Which themes should be loaded. This is used primarily for testing. pub enum LoadThemes { /// Only load the base theme. @@ -106,84 +89,31 @@ pub enum LoadThemes { All(Box), } -/// Initialize the theme system. +/// Initialize the theme system with default themes. +/// +/// This sets up the [`ThemeRegistry`], [`FontFamilyCache`], [`SystemAppearance`], +/// and [`GlobalTheme`] with the default dark theme. It does NOT load bundled +/// themes from JSON or integrate with settings — use `theme_settings::init` for that. pub fn init(themes_to_load: LoadThemes, cx: &mut App) { SystemAppearance::init(cx); - let (assets, load_user_themes) = match themes_to_load { - LoadThemes::JustBase => (Box::new(()) as Box, false), - LoadThemes::All(assets) => (assets, true), + let assets = match themes_to_load { + LoadThemes::JustBase => Box::new(()) as Box, + LoadThemes::All(assets) => assets, }; ThemeRegistry::set_global(assets, cx); - - if load_user_themes { - ThemeRegistry::global(cx).load_bundled_themes(); - } - FontFamilyCache::init_global(cx); - let theme = GlobalTheme::configured_theme(cx); - let icon_theme = GlobalTheme::configured_icon_theme(cx); + let themes = ThemeRegistry::default_global(cx); + let theme = themes.get(DEFAULT_DARK_THEME).unwrap_or_else(|_| { + themes + .list() + .into_iter() + .next() + .map(|m| themes.get(&m.name).unwrap()) + .unwrap() + }); + let icon_theme = themes.default_icon_theme().unwrap(); cx.set_global(GlobalTheme { theme, icon_theme }); - - let settings = ThemeSettings::get_global(cx); - - let mut prev_buffer_font_size_settings = settings.buffer_font_size_settings(); - let mut prev_ui_font_size_settings = settings.ui_font_size_settings(); - let mut prev_agent_ui_font_size_settings = settings.agent_ui_font_size_settings(); - let mut prev_agent_buffer_font_size_settings = settings.agent_buffer_font_size_settings(); - let mut prev_theme_name = settings.theme.name(SystemAppearance::global(cx).0); - let mut prev_icon_theme_name = settings.icon_theme.name(SystemAppearance::global(cx).0); - let mut prev_theme_overrides = ( - settings.experimental_theme_overrides.clone(), - settings.theme_overrides.clone(), - ); - - cx.observe_global::(move |cx| { - let settings = ThemeSettings::get_global(cx); - - let buffer_font_size_settings = settings.buffer_font_size_settings(); - let ui_font_size_settings = settings.ui_font_size_settings(); - let agent_ui_font_size_settings = settings.agent_ui_font_size_settings(); - let agent_buffer_font_size_settings = settings.agent_buffer_font_size_settings(); - let theme_name = settings.theme.name(SystemAppearance::global(cx).0); - let icon_theme_name = settings.icon_theme.name(SystemAppearance::global(cx).0); - let theme_overrides = ( - settings.experimental_theme_overrides.clone(), - settings.theme_overrides.clone(), - ); - - if buffer_font_size_settings != prev_buffer_font_size_settings { - prev_buffer_font_size_settings = buffer_font_size_settings; - reset_buffer_font_size(cx); - } - - if ui_font_size_settings != prev_ui_font_size_settings { - prev_ui_font_size_settings = ui_font_size_settings; - reset_ui_font_size(cx); - } - - if agent_ui_font_size_settings != prev_agent_ui_font_size_settings { - prev_agent_ui_font_size_settings = agent_ui_font_size_settings; - reset_agent_ui_font_size(cx); - } - - if agent_buffer_font_size_settings != prev_agent_buffer_font_size_settings { - prev_agent_buffer_font_size_settings = agent_buffer_font_size_settings; - reset_agent_buffer_font_size(cx); - } - - if theme_name != prev_theme_name || theme_overrides != prev_theme_overrides { - prev_theme_name = theme_name; - prev_theme_overrides = theme_overrides; - GlobalTheme::reload_theme(cx); - } - - if icon_theme_name != prev_icon_theme_name { - prev_icon_theme_name = icon_theme_name; - GlobalTheme::reload_icon_theme(cx); - } - }) - .detach(); } /// Implementing this trait allows accessing the active theme. @@ -198,6 +128,39 @@ impl ActiveTheme for App { } } +/// The appearance of the system. +#[derive(Debug, Clone, Copy, Deref)] +pub struct SystemAppearance(pub Appearance); + +impl Default for SystemAppearance { + fn default() -> Self { + Self(Appearance::Dark) + } +} + +#[derive(Deref, DerefMut, Default)] +struct GlobalSystemAppearance(SystemAppearance); + +impl Global for GlobalSystemAppearance {} + +impl SystemAppearance { + /// Initializes the [`SystemAppearance`] for the application. + pub fn init(cx: &mut App) { + *cx.default_global::() = + GlobalSystemAppearance(SystemAppearance(cx.window_appearance().into())); + } + + /// Returns the global [`SystemAppearance`]. + pub fn global(cx: &App) -> Self { + cx.global::().0 + } + + /// Returns a mutable reference to the global [`SystemAppearance`]. + pub fn global_mut(cx: &mut App) -> &mut Self { + cx.global_mut::() + } +} + /// A theme family is a grouping of themes under a single name. /// /// For example, the "One" theme family contains the "One Light" and "One Dark" themes. @@ -219,118 +182,6 @@ pub struct ThemeFamily { pub scales: ColorScales, } -impl ThemeFamily { - // This is on ThemeFamily because we will have variables here we will need - // in the future to resolve @references. - /// Refines ThemeContent into a theme, merging it's contents with the base theme. - pub fn refine_theme(&self, theme: &ThemeContent) -> Theme { - let appearance = match theme.appearance { - AppearanceContent::Light => Appearance::Light, - AppearanceContent::Dark => Appearance::Dark, - }; - - let mut refined_status_colors = match theme.appearance { - AppearanceContent::Light => StatusColors::light(), - AppearanceContent::Dark => StatusColors::dark(), - }; - let mut status_colors_refinement = status_colors_refinement(&theme.style.status); - apply_status_color_defaults(&mut status_colors_refinement); - refined_status_colors.refine(&status_colors_refinement); - - let mut refined_player_colors = match theme.appearance { - AppearanceContent::Light => PlayerColors::light(), - AppearanceContent::Dark => PlayerColors::dark(), - }; - refined_player_colors.merge(&theme.style.players); - - let mut refined_theme_colors = match theme.appearance { - AppearanceContent::Light => ThemeColors::light(), - AppearanceContent::Dark => ThemeColors::dark(), - }; - let mut theme_colors_refinement = - theme_colors_refinement(&theme.style.colors, &status_colors_refinement); - apply_theme_color_defaults(&mut theme_colors_refinement, &refined_player_colors); - refined_theme_colors.refine(&theme_colors_refinement); - - let mut refined_accent_colors = match theme.appearance { - AppearanceContent::Light => AccentColors::light(), - AppearanceContent::Dark => AccentColors::dark(), - }; - refined_accent_colors.merge(&theme.style.accents); - - let syntax_highlights = theme - .style - .syntax - .iter() - .map(|(syntax_token, highlight)| { - ( - syntax_token.clone(), - HighlightStyle { - color: highlight - .color - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - background_color: highlight - .background_color - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - font_style: highlight.font_style.map(|s| s.into_gpui()), - font_weight: highlight.font_weight.map(|w| w.into_gpui()), - ..Default::default() - }, - ) - }) - .collect::>(); - let syntax_theme = SyntaxTheme::merge(Arc::new(SyntaxTheme::default()), syntax_highlights); - - let window_background_appearance = theme - .style - .window_background_appearance - .map(|w| w.into_gpui()) - .unwrap_or_default(); - - Theme { - id: uuid::Uuid::new_v4().to_string(), - name: theme.name.clone().into(), - appearance, - styles: ThemeStyles { - system: SystemColors::default(), - window_background_appearance, - accents: refined_accent_colors, - colors: refined_theme_colors, - status: refined_status_colors, - player: refined_player_colors, - syntax: syntax_theme, - }, - } - } -} - -/// Refines a [ThemeFamilyContent] and it's [ThemeContent]s into a [ThemeFamily]. -pub fn refine_theme_family(theme_family_content: ThemeFamilyContent) -> ThemeFamily { - let id = Uuid::new_v4().to_string(); - let name = theme_family_content.name.clone(); - let author = theme_family_content.author.clone(); - - let mut theme_family = ThemeFamily { - id, - name: name.into(), - author: author.into(), - themes: vec![], - scales: default_color_scales(), - }; - - let refined_themes = theme_family_content - .themes - .iter() - .map(|theme_content| theme_family.refine_theme(theme_content)) - .collect(); - - theme_family.themes = refined_themes; - - theme_family -} - /// A theme is the primary mechanism for defining the appearance of the UI. #[derive(Clone, Debug, PartialEq)] pub struct Theme { @@ -381,12 +232,6 @@ impl Theme { &self.styles.status } - /// Returns the color for the syntax node with the given name. - #[inline(always)] - pub fn syntax_color(&self, name: &str) -> Hsla { - self.syntax().color(name) - } - /// Returns the [`Appearance`] for the theme. #[inline(always)] pub fn appearance(&self) -> Appearance { @@ -416,40 +261,14 @@ impl Theme { } } -/// Asynchronously reads the user theme from the specified path. -pub async fn read_user_theme(theme_path: &Path, fs: Arc) -> Result { - let bytes = fs.load_bytes(theme_path).await?; - let theme_family: ThemeFamilyContent = serde_json_lenient::from_slice(&bytes)?; - - for theme in &theme_family.themes { - if theme - .style - .colors - .deprecated_scrollbar_thumb_background - .is_some() - { - log::warn!( - r#"Theme "{theme_name}" is using a deprecated style property: scrollbar_thumb.background. Use `scrollbar.thumb.background` instead."#, - theme_name = theme.name - ) - } - } - - Ok(theme_family) -} - -/// Asynchronously reads the icon theme from the specified path. -pub async fn read_icon_theme( - icon_theme_path: &Path, - fs: Arc, -) -> Result { - let bytes = fs.load_bytes(icon_theme_path).await?; - let icon_theme_family: IconThemeFamilyContent = serde_json_lenient::from_slice(&bytes)?; +/// Deserializes an icon theme from the given bytes. +pub fn deserialize_icon_theme(bytes: &[u8]) -> anyhow::Result { + let icon_theme_family: IconThemeFamilyContent = serde_json_lenient::from_slice(bytes)?; Ok(icon_theme_family) } -/// The active theme +/// The active theme. pub struct GlobalTheme { theme: Arc, icon_theme: Arc, @@ -457,72 +276,27 @@ pub struct GlobalTheme { impl Global for GlobalTheme {} impl GlobalTheme { - fn configured_theme(cx: &mut App) -> Arc { - let themes = ThemeRegistry::default_global(cx); - let theme_settings = ThemeSettings::get_global(cx); - let system_appearance = SystemAppearance::global(cx); - - let theme_name = theme_settings.theme.name(*system_appearance); - - let theme = match themes.get(&theme_name.0) { - Ok(theme) => theme, - Err(err) => { - if themes.extensions_loaded() { - log::error!("{err}"); - } - themes - .get(default_theme(*system_appearance)) - // fallback for tests. - .unwrap_or_else(|_| themes.get(DEFAULT_DARK_THEME).unwrap()) - } - }; - theme_settings.apply_theme_overrides(theme) + /// Creates a new [`GlobalTheme`] with the given theme and icon theme. + pub fn new(theme: Arc, icon_theme: Arc) -> Self { + Self { theme, icon_theme } } - /// Reloads the current theme. - /// - /// Reads the [`ThemeSettings`] to know which theme should be loaded, - /// taking into account the current [`SystemAppearance`]. - pub fn reload_theme(cx: &mut App) { - let theme = Self::configured_theme(cx); + /// Updates the active theme. + pub fn update_theme(cx: &mut App, theme: Arc) { cx.update_global::(|this, _| this.theme = theme); - cx.refresh_windows(); - } - - fn configured_icon_theme(cx: &mut App) -> Arc { - let themes = ThemeRegistry::default_global(cx); - let theme_settings = ThemeSettings::get_global(cx); - let system_appearance = SystemAppearance::global(cx); - - let icon_theme_name = theme_settings.icon_theme.name(*system_appearance); - - match themes.get_icon_theme(&icon_theme_name.0) { - Ok(theme) => theme, - Err(err) => { - if themes.extensions_loaded() { - log::error!("{err}"); - } - themes.get_icon_theme(DEFAULT_ICON_THEME_NAME).unwrap() - } - } } - /// Reloads the current icon theme. - /// - /// Reads the [`ThemeSettings`] to know which icon theme should be loaded, - /// taking into account the current [`SystemAppearance`]. - pub fn reload_icon_theme(cx: &mut App) { - let icon_theme = Self::configured_icon_theme(cx); + /// Updates the active icon theme. + pub fn update_icon_theme(cx: &mut App, icon_theme: Arc) { cx.update_global::(|this, _| this.icon_theme = icon_theme); - cx.refresh_windows(); } - /// the active theme + /// Returns the active theme. pub fn theme(cx: &App) -> &Arc { &cx.global::().theme } - /// the active icon theme + /// Returns the active icon theme. pub fn icon_theme(cx: &App) -> &Arc { &cx.global::().icon_theme } diff --git a/crates/theme/src/theme_settings_provider.rs b/crates/theme/src/theme_settings_provider.rs new file mode 100644 index 0000000000000000000000000000000000000000..f3e05bc77bdd91de46024951aa3bef1f01736502 --- /dev/null +++ b/crates/theme/src/theme_settings_provider.rs @@ -0,0 +1,43 @@ +use gpui::{App, Font, Global, Pixels}; + +use crate::UiDensity; + +/// Trait for providing theme-related settings (fonts, font sizes, UI density) +/// without coupling to the concrete settings infrastructure. +/// +/// A concrete implementation is registered as a global by the `theme_settings` crate. +pub trait ThemeSettingsProvider: Send + Sync + 'static { + /// Returns the font used for UI elements. + fn ui_font<'a>(&'a self, cx: &'a App) -> &'a Font; + + /// Returns the font used for buffers and the terminal. + fn buffer_font<'a>(&'a self, cx: &'a App) -> &'a Font; + + /// Returns the UI font size in pixels. + fn ui_font_size(&self, cx: &App) -> Pixels; + + /// Returns the buffer font size in pixels. + fn buffer_font_size(&self, cx: &App) -> Pixels; + + /// Returns the current UI density setting. + fn ui_density(&self, cx: &App) -> UiDensity; +} + +struct GlobalThemeSettingsProvider(Box); + +impl Global for GlobalThemeSettingsProvider {} + +/// Registers the global [`ThemeSettingsProvider`] implementation. +/// +/// This should be called during application initialization by the crate +/// that owns the concrete theme settings (e.g. `theme_settings`). +pub fn set_theme_settings_provider(provider: Box, cx: &mut App) { + cx.set_global(GlobalThemeSettingsProvider(provider)); +} + +/// Returns the global [`ThemeSettingsProvider`]. +/// +/// Panics if no provider has been registered via [`set_theme_settings_provider`]. +pub fn theme_settings(cx: &App) -> &dyn ThemeSettingsProvider { + &*cx.global::().0 +} diff --git a/crates/theme/src/ui_density.rs b/crates/theme/src/ui_density.rs new file mode 100644 index 0000000000000000000000000000000000000000..5510e330e55c5b63ca125ff3be9dad2f0357e5c2 --- /dev/null +++ b/crates/theme/src/ui_density.rs @@ -0,0 +1,65 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Specifies the density of the UI. +/// Note: This setting is still experimental. See [this tracking issue](https://github.com/zed-industries/zed/issues/18078) +#[derive( + Debug, + Default, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Clone, + Copy, + Serialize, + Deserialize, + JsonSchema, +)] +#[serde(rename_all = "snake_case")] +pub enum UiDensity { + /// A denser UI with tighter spacing and smaller elements. + #[serde(alias = "compact")] + Compact, + #[default] + #[serde(alias = "default")] + /// The default UI density. + Default, + #[serde(alias = "comfortable")] + /// A looser UI with more spacing and larger elements. + Comfortable, +} + +impl UiDensity { + /// The spacing ratio of a given density. + /// TODO: Standardize usage throughout the app or remove + pub fn spacing_ratio(self) -> f32 { + match self { + UiDensity::Compact => 0.75, + UiDensity::Default => 1.0, + UiDensity::Comfortable => 1.25, + } + } +} + +impl From for UiDensity { + fn from(s: String) -> Self { + match s.as_str() { + "compact" => Self::Compact, + "default" => Self::Default, + "comfortable" => Self::Comfortable, + _ => Self::default(), + } + } +} + +impl From for String { + fn from(val: UiDensity) -> Self { + match val { + UiDensity::Compact => "compact".to_string(), + UiDensity::Default => "default".to_string(), + UiDensity::Comfortable => "comfortable".to_string(), + } + } +} diff --git a/crates/theme_extension/Cargo.toml b/crates/theme_extension/Cargo.toml index d94e15914b2dfbc8250641e8957366c27c2616a4..ca5b71de20b2166b81a14b79d81f581027245d6a 100644 --- a/crates/theme_extension/Cargo.toml +++ b/crates/theme_extension/Cargo.toml @@ -17,3 +17,4 @@ extension.workspace = true fs.workspace = true gpui.workspace = true theme.workspace = true +theme_settings.workspace = true diff --git a/crates/theme_extension/src/theme_extension.rs b/crates/theme_extension/src/theme_extension.rs index 10df2349c86decbadaa010778a95d04af36a6aab..85351a91f37a5b776b9db0f0bbbc4c05d3fc4616 100644 --- a/crates/theme_extension/src/theme_extension.rs +++ b/crates/theme_extension/src/theme_extension.rs @@ -5,7 +5,8 @@ use anyhow::Result; use extension::{ExtensionHostProxy, ExtensionThemeProxy}; use fs::Fs; use gpui::{App, BackgroundExecutor, SharedString, Task}; -use theme::{GlobalTheme, ThemeRegistry}; +use theme::{ThemeRegistry, deserialize_icon_theme}; +use theme_settings; pub fn init( extension_host_proxy: Arc, @@ -30,7 +31,8 @@ impl ExtensionThemeProxy for ThemeRegistryProxy { fn list_theme_names(&self, theme_path: PathBuf, fs: Arc) -> Task>> { self.executor.spawn(async move { - let themes = theme::read_user_theme(&theme_path, fs).await?; + let themes = + theme_settings::deserialize_user_theme(&fs.load_bytes(&theme_path).await?)?; Ok(themes.themes.into_iter().map(|theme| theme.name).collect()) }) } @@ -41,12 +43,13 @@ impl ExtensionThemeProxy for ThemeRegistryProxy { fn load_user_theme(&self, theme_path: PathBuf, fs: Arc) -> Task> { let theme_registry = self.theme_registry.clone(); - self.executor - .spawn(async move { theme_registry.load_user_theme(&theme_path, fs).await }) + self.executor.spawn(async move { + theme_settings::load_user_theme(&theme_registry, &fs.load_bytes(&theme_path).await?) + }) } fn reload_current_theme(&self, cx: &mut App) { - GlobalTheme::reload_theme(cx) + theme_settings::reload_theme(cx) } fn list_icon_theme_names( @@ -55,7 +58,8 @@ impl ExtensionThemeProxy for ThemeRegistryProxy { fs: Arc, ) -> Task>> { self.executor.spawn(async move { - let icon_theme_family = theme::read_icon_theme(&icon_theme_path, fs).await?; + let icon_theme_family = + theme::deserialize_icon_theme(&fs.load_bytes(&icon_theme_path).await?)?; Ok(icon_theme_family .themes .into_iter() @@ -76,13 +80,13 @@ impl ExtensionThemeProxy for ThemeRegistryProxy { ) -> Task> { let theme_registry = self.theme_registry.clone(); self.executor.spawn(async move { - theme_registry - .load_icon_theme(&icon_theme_path, &icons_root_dir, fs) - .await + let icon_theme_family = + deserialize_icon_theme(&fs.load_bytes(&icon_theme_path).await?)?; + theme_registry.load_icon_theme(icon_theme_family, &icons_root_dir) }) } fn reload_current_icon_theme(&self, cx: &mut App) { - GlobalTheme::reload_icon_theme(cx) + theme_settings::reload_icon_theme(cx) } } diff --git a/crates/theme_importer/Cargo.toml b/crates/theme_importer/Cargo.toml index a91ffc44544f898be35c4514910a6081b10b4a26..a0b86a286de965143ba3ade4ee4cdff56cf773d4 100644 --- a/crates/theme_importer/Cargo.toml +++ b/crates/theme_importer/Cargo.toml @@ -22,4 +22,5 @@ serde_json_lenient.workspace = true simplelog.workspace= true strum = { workspace = true, features = ["derive"] } theme.workspace = true +theme_settings.workspace = true vscode_theme = "0.2.0" diff --git a/crates/theme_importer/src/vscode/converter.rs b/crates/theme_importer/src/vscode/converter.rs index b052e865265368234d7a1bed42957a714ca9d5bb..70b7c0e9f663c64d73cf9360dd7733c12f1fb5fe 100644 --- a/crates/theme_importer/src/vscode/converter.rs +++ b/crates/theme_importer/src/vscode/converter.rs @@ -1,7 +1,7 @@ use anyhow::Result; use collections::IndexMap; use strum::IntoEnumIterator; -use theme::{ +use theme_settings::{ FontStyleContent, FontWeightContent, HighlightStyleContent, StatusColorsContent, ThemeColorsContent, ThemeContent, ThemeStyleContent, WindowBackgroundContent, }; diff --git a/crates/theme_selector/Cargo.toml b/crates/theme_selector/Cargo.toml index 1a563e81f202b484c846ed620aee3edd122fc80b..41e0e7681436f1fd8d6bfe743528af7d4f3d3ad6 100644 --- a/crates/theme_selector/Cargo.toml +++ b/crates/theme_selector/Cargo.toml @@ -22,6 +22,7 @@ serde.workspace = true settings.workspace = true telemetry.workspace = true theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true diff --git a/crates/theme_selector/src/icon_theme_selector.rs b/crates/theme_selector/src/icon_theme_selector.rs index 1ddd6879405ad69a75e038da608d034f58bb5eff..13d6a87c4ac9911bef7a86c9df84171644ca6cf9 100644 --- a/crates/theme_selector/src/icon_theme_selector.rs +++ b/crates/theme_selector/src/icon_theme_selector.rs @@ -7,10 +7,8 @@ use gpui::{ use picker::{Picker, PickerDelegate}; use settings::{Settings as _, SettingsStore, update_settings_file}; use std::sync::Arc; -use theme::{ - Appearance, IconThemeName, IconThemeSelection, SystemAppearance, ThemeMeta, ThemeRegistry, - ThemeSettings, -}; +use theme::{Appearance, SystemAppearance, ThemeMeta, ThemeRegistry}; +use theme_settings::{IconThemeName, IconThemeSelection, ThemeSettings}; use ui::{ListItem, ListItemSpacing, prelude::*, v_flex}; use util::ResultExt; use workspace::{ModalView, ui::HighlightedLabel}; @@ -176,7 +174,7 @@ impl PickerDelegate for IconThemeSelectorDelegate { let appearance = Appearance::from(window.appearance()); update_settings_file(self.fs.clone(), cx, move |settings, _| { - theme::set_icon_theme(settings, theme_name, appearance); + theme_settings::set_icon_theme(settings, theme_name, appearance); }); self.selector diff --git a/crates/theme_selector/src/theme_selector.rs b/crates/theme_selector/src/theme_selector.rs index f3c32c8f2f50cbec820e043a701f382e6ac22d0a..fb4d68a9da6f4a96e52fef288e58bdec90cae6fa 100644 --- a/crates/theme_selector/src/theme_selector.rs +++ b/crates/theme_selector/src/theme_selector.rs @@ -9,9 +9,9 @@ use gpui::{ use picker::{Picker, PickerDelegate}; use settings::{Settings, SettingsStore, update_settings_file}; use std::sync::Arc; -use theme::{ - Appearance, SystemAppearance, Theme, ThemeAppearanceMode, ThemeMeta, ThemeName, ThemeRegistry, - ThemeSelection, ThemeSettings, +use theme::{Appearance, SystemAppearance, Theme, ThemeMeta, ThemeRegistry}; +use theme_settings::{ + ThemeAppearanceMode, ThemeName, ThemeSelection, ThemeSettings, appearance_to_mode, }; use ui::{ListItem, ListItemSpacing, prelude::*, v_flex}; use util::ResultExt; @@ -233,7 +233,7 @@ impl ThemeSelectorDelegate { /// Overrides the global (in-memory) theme settings. /// /// Note that this does **not** update the user's `settings.json` file (see the -/// [`ThemeSelectorDelegate::confirm`] method and [`theme::set_theme`] function). +/// [`ThemeSelectorDelegate::confirm`] method and [`theme_settings::set_theme`] function). fn override_global_theme( store: &mut SettingsStore, new_theme: &Theme, @@ -303,7 +303,7 @@ fn update_mode_if_new_appearance_is_different_from_system( if original_mode == &ThemeAppearanceMode::System && system_appearance == new_appearance { ThemeAppearanceMode::System } else { - ThemeAppearanceMode::from(new_appearance) + appearance_to_mode(new_appearance) } } @@ -360,7 +360,7 @@ impl PickerDelegate for ThemeSelectorDelegate { telemetry::event!("Settings Changed", setting = "theme", value = theme_name); update_settings_file(self.fs.clone(), cx, move |settings, _| { - theme::set_theme(settings, theme_name, theme_appearance, system_appearance); + theme_settings::set_theme(settings, theme_name, theme_appearance, system_appearance); }); self.selector diff --git a/crates/theme_settings/Cargo.toml b/crates/theme_settings/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..dfe4fa0f79fb437a2b03c680642ac6b19a91d251 --- /dev/null +++ b/crates/theme_settings/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "theme_settings" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[features] +default = [] +test-support = ["gpui/test-support", "settings/test-support", "theme/test-support"] + +[lib] +path = "src/theme_settings.rs" +doctest = false + +[dependencies] +anyhow.workspace = true +collections.workspace = true +gpui.workspace = true +gpui_util.workspace = true +log.workspace = true +palette = { workspace = true, default-features = false, features = ["std"] } +refineable.workspace = true +schemars.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_json_lenient.workspace = true +settings.workspace = true +theme.workspace = true +uuid.workspace = true + +[dev-dependencies] +gpui = { workspace = true, features = ["test-support"] } +settings = { workspace = true, features = ["test-support"] } diff --git a/crates/theme_settings/LICENSE-GPL b/crates/theme_settings/LICENSE-GPL new file mode 120000 index 0000000000000000000000000000000000000000..89e542f750cd3860a0598eff0dc34b56d7336dc4 --- /dev/null +++ b/crates/theme_settings/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/theme_settings/src/schema.rs b/crates/theme_settings/src/schema.rs new file mode 100644 index 0000000000000000000000000000000000000000..93eb4d30aa7ace9e10da3a0002dae3c6a6907d21 --- /dev/null +++ b/crates/theme_settings/src/schema.rs @@ -0,0 +1,850 @@ +#![allow(missing_docs)] + +use gpui::{HighlightStyle, Hsla}; +use palette::FromColor; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use settings::IntoGpui; +pub use settings::{ + FontStyleContent, HighlightStyleContent, StatusColorsContent, ThemeColorsContent, + ThemeStyleContent, +}; +pub use settings::{FontWeightContent, WindowBackgroundContent}; + +use theme::{StatusColorsRefinement, ThemeColorsRefinement}; + +/// The content of a serialized theme family. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct ThemeFamilyContent { + pub name: String, + pub author: String, + pub themes: Vec, +} + +/// The content of a serialized theme. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct ThemeContent { + pub name: String, + pub appearance: theme::AppearanceContent, + pub style: settings::ThemeStyleContent, +} + +/// Returns the syntax style overrides in the [`ThemeContent`]. +pub fn syntax_overrides(this: &settings::ThemeStyleContent) -> Vec<(String, HighlightStyle)> { + this.syntax + .iter() + .map(|(key, style)| { + ( + key.clone(), + HighlightStyle { + color: style + .color + .as_ref() + .and_then(|color| theme::try_parse_color(color).ok()), + background_color: style + .background_color + .as_ref() + .and_then(|color| theme::try_parse_color(color).ok()), + font_style: style.font_style.map(|s| s.into_gpui()), + font_weight: style.font_weight.map(|w| w.into_gpui()), + ..Default::default() + }, + ) + }) + .collect() +} + +pub fn status_colors_refinement(colors: &settings::StatusColorsContent) -> StatusColorsRefinement { + StatusColorsRefinement { + conflict: colors + .conflict + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + conflict_background: colors + .conflict_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + conflict_border: colors + .conflict_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + created: colors + .created + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + created_background: colors + .created_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + created_border: colors + .created_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + deleted: colors + .deleted + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + deleted_background: colors + .deleted_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + deleted_border: colors + .deleted_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + error: colors + .error + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + error_background: colors + .error_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + error_border: colors + .error_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + hidden: colors + .hidden + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + hidden_background: colors + .hidden_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + hidden_border: colors + .hidden_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + hint: colors + .hint + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + hint_background: colors + .hint_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + hint_border: colors + .hint_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + ignored: colors + .ignored + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + ignored_background: colors + .ignored_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + ignored_border: colors + .ignored_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + info: colors + .info + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + info_background: colors + .info_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + info_border: colors + .info_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + modified: colors + .modified + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + modified_background: colors + .modified_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + modified_border: colors + .modified_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + predictive: colors + .predictive + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + predictive_background: colors + .predictive_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + predictive_border: colors + .predictive_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + renamed: colors + .renamed + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + renamed_background: colors + .renamed_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + renamed_border: colors + .renamed_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + success: colors + .success + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + success_background: colors + .success_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + success_border: colors + .success_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + unreachable: colors + .unreachable + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + unreachable_background: colors + .unreachable_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + unreachable_border: colors + .unreachable_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + warning: colors + .warning + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + warning_background: colors + .warning_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + warning_border: colors + .warning_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + } +} + +pub fn theme_colors_refinement( + this: &settings::ThemeColorsContent, + status_colors: &StatusColorsRefinement, +) -> ThemeColorsRefinement { + let border = this + .border + .as_ref() + .and_then(|color| try_parse_color(color).ok()); + let editor_document_highlight_read_background = this + .editor_document_highlight_read_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()); + let scrollbar_thumb_background = this + .scrollbar_thumb_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or_else(|| { + this.deprecated_scrollbar_thumb_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + }); + let scrollbar_thumb_hover_background = this + .scrollbar_thumb_hover_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()); + let scrollbar_thumb_active_background = this + .scrollbar_thumb_active_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(scrollbar_thumb_background); + let scrollbar_thumb_border = this + .scrollbar_thumb_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()); + let element_hover = this + .element_hover + .as_ref() + .and_then(|color| try_parse_color(color).ok()); + let panel_background = this + .panel_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()); + let search_match_background = this + .search_match_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()); + let search_active_match_background = this + .search_active_match_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(search_match_background); + ThemeColorsRefinement { + border, + border_variant: this + .border_variant + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + border_focused: this + .border_focused + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + border_selected: this + .border_selected + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + border_transparent: this + .border_transparent + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + border_disabled: this + .border_disabled + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + elevated_surface_background: this + .elevated_surface_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + surface_background: this + .surface_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + background: this + .background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + element_background: this + .element_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + element_hover, + element_active: this + .element_active + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + element_selected: this + .element_selected + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + element_disabled: this + .element_disabled + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + element_selection_background: this + .element_selection_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + drop_target_background: this + .drop_target_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + drop_target_border: this + .drop_target_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + ghost_element_background: this + .ghost_element_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + ghost_element_hover: this + .ghost_element_hover + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + ghost_element_active: this + .ghost_element_active + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + ghost_element_selected: this + .ghost_element_selected + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + ghost_element_disabled: this + .ghost_element_disabled + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + text: this + .text + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + text_muted: this + .text_muted + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + text_placeholder: this + .text_placeholder + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + text_disabled: this + .text_disabled + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + text_accent: this + .text_accent + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + icon: this + .icon + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + icon_muted: this + .icon_muted + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + icon_disabled: this + .icon_disabled + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + icon_placeholder: this + .icon_placeholder + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + icon_accent: this + .icon_accent + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + debugger_accent: this + .debugger_accent + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + status_bar_background: this + .status_bar_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + title_bar_background: this + .title_bar_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + title_bar_inactive_background: this + .title_bar_inactive_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + toolbar_background: this + .toolbar_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + tab_bar_background: this + .tab_bar_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + tab_inactive_background: this + .tab_inactive_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + tab_active_background: this + .tab_active_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + search_match_background, + search_active_match_background, + panel_background, + panel_focused_border: this + .panel_focused_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + panel_indent_guide: this + .panel_indent_guide + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + panel_indent_guide_hover: this + .panel_indent_guide_hover + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + panel_indent_guide_active: this + .panel_indent_guide_active + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + panel_overlay_background: this + .panel_overlay_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(panel_background.map(ensure_opaque)), + panel_overlay_hover: this + .panel_overlay_hover + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(panel_background + .zip(element_hover) + .map(|(panel_bg, hover_bg)| panel_bg.blend(hover_bg)) + .map(ensure_opaque)), + pane_focused_border: this + .pane_focused_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + pane_group_border: this + .pane_group_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(border), + scrollbar_thumb_background, + scrollbar_thumb_hover_background, + scrollbar_thumb_active_background, + scrollbar_thumb_border, + scrollbar_track_background: this + .scrollbar_track_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + scrollbar_track_border: this + .scrollbar_track_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + minimap_thumb_background: this + .minimap_thumb_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(scrollbar_thumb_background.map(ensure_non_opaque)), + minimap_thumb_hover_background: this + .minimap_thumb_hover_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(scrollbar_thumb_hover_background.map(ensure_non_opaque)), + minimap_thumb_active_background: this + .minimap_thumb_active_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(scrollbar_thumb_active_background.map(ensure_non_opaque)), + minimap_thumb_border: this + .minimap_thumb_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(scrollbar_thumb_border), + editor_foreground: this + .editor_foreground + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_background: this + .editor_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_gutter_background: this + .editor_gutter_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_subheader_background: this + .editor_subheader_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_active_line_background: this + .editor_active_line_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_highlighted_line_background: this + .editor_highlighted_line_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_debugger_active_line_background: this + .editor_debugger_active_line_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_line_number: this + .editor_line_number + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_hover_line_number: this + .editor_hover_line_number + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_active_line_number: this + .editor_active_line_number + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_invisible: this + .editor_invisible + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_wrap_guide: this + .editor_wrap_guide + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_active_wrap_guide: this + .editor_active_wrap_guide + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_indent_guide: this + .editor_indent_guide + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_indent_guide_active: this + .editor_indent_guide_active + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_document_highlight_read_background, + editor_document_highlight_write_background: this + .editor_document_highlight_write_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_document_highlight_bracket_background: this + .editor_document_highlight_bracket_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(editor_document_highlight_read_background), + terminal_background: this + .terminal_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_background: this + .terminal_ansi_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_foreground: this + .terminal_foreground + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_bright_foreground: this + .terminal_bright_foreground + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_dim_foreground: this + .terminal_dim_foreground + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_black: this + .terminal_ansi_black + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_bright_black: this + .terminal_ansi_bright_black + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_dim_black: this + .terminal_ansi_dim_black + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_red: this + .terminal_ansi_red + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_bright_red: this + .terminal_ansi_bright_red + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_dim_red: this + .terminal_ansi_dim_red + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_green: this + .terminal_ansi_green + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_bright_green: this + .terminal_ansi_bright_green + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_dim_green: this + .terminal_ansi_dim_green + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_yellow: this + .terminal_ansi_yellow + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_bright_yellow: this + .terminal_ansi_bright_yellow + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_dim_yellow: this + .terminal_ansi_dim_yellow + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_blue: this + .terminal_ansi_blue + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_bright_blue: this + .terminal_ansi_bright_blue + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_dim_blue: this + .terminal_ansi_dim_blue + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_magenta: this + .terminal_ansi_magenta + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_bright_magenta: this + .terminal_ansi_bright_magenta + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_dim_magenta: this + .terminal_ansi_dim_magenta + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_cyan: this + .terminal_ansi_cyan + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_bright_cyan: this + .terminal_ansi_bright_cyan + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_dim_cyan: this + .terminal_ansi_dim_cyan + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_white: this + .terminal_ansi_white + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_bright_white: this + .terminal_ansi_bright_white + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_dim_white: this + .terminal_ansi_dim_white + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + link_text_hover: this + .link_text_hover + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + version_control_added: this + .version_control_added + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(status_colors.created), + version_control_deleted: this + .version_control_deleted + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(status_colors.deleted), + version_control_modified: this + .version_control_modified + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(status_colors.modified), + version_control_renamed: this + .version_control_renamed + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(status_colors.modified), + version_control_conflict: this + .version_control_conflict + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(status_colors.ignored), + version_control_ignored: this + .version_control_ignored + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(status_colors.ignored), + version_control_word_added: this + .version_control_word_added + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + version_control_word_deleted: this + .version_control_word_deleted + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + #[allow(deprecated)] + version_control_conflict_marker_ours: this + .version_control_conflict_marker_ours + .as_ref() + .or(this.version_control_conflict_ours_background.as_ref()) + .and_then(|color| try_parse_color(color).ok()), + #[allow(deprecated)] + version_control_conflict_marker_theirs: this + .version_control_conflict_marker_theirs + .as_ref() + .or(this.version_control_conflict_theirs_background.as_ref()) + .and_then(|color| try_parse_color(color).ok()), + vim_normal_background: this + .vim_normal_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_insert_background: this + .vim_insert_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_replace_background: this + .vim_replace_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_visual_background: this + .vim_visual_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_visual_line_background: this + .vim_visual_line_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_visual_block_background: this + .vim_visual_block_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_yank_background: this + .vim_yank_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(editor_document_highlight_read_background), + vim_helix_normal_background: this + .vim_helix_normal_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_helix_select_background: this + .vim_helix_select_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_normal_foreground: this + .vim_normal_foreground + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_insert_foreground: this + .vim_insert_foreground + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_replace_foreground: this + .vim_replace_foreground + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_visual_foreground: this + .vim_visual_foreground + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_visual_line_foreground: this + .vim_visual_line_foreground + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_visual_block_foreground: this + .vim_visual_block_foreground + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_helix_normal_foreground: this + .vim_helix_normal_foreground + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_helix_select_foreground: this + .vim_helix_select_foreground + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + } +} + +fn ensure_non_opaque(color: Hsla) -> Hsla { + const MAXIMUM_OPACITY: f32 = 0.7; + if color.a <= MAXIMUM_OPACITY { + color + } else { + Hsla { + a: MAXIMUM_OPACITY, + ..color + } + } +} + +fn ensure_opaque(color: Hsla) -> Hsla { + Hsla { a: 1.0, ..color } +} + +fn try_parse_color(color: &str) -> anyhow::Result { + let rgba = gpui::Rgba::try_from(color)?; + let rgba = palette::rgb::Srgba::from_components((rgba.r, rgba.g, rgba.b, rgba.a)); + let hsla = palette::Hsla::from_color(rgba); + + let hsla = gpui::hsla( + hsla.hue.into_positive_degrees() / 360., + hsla.saturation, + hsla.lightness, + hsla.alpha, + ); + + Ok(hsla) +} diff --git a/crates/theme/src/settings.rs b/crates/theme_settings/src/settings.rs similarity index 83% rename from crates/theme/src/settings.rs rename to crates/theme_settings/src/settings.rs index c09d3daf6074f24248de12e56ebc2122e2c123e7..cda63ab9c8aa10d0f006f3bf371aab6491dff6de 100644 --- a/crates/theme/src/settings.rs +++ b/crates/theme_settings/src/settings.rs @@ -1,9 +1,8 @@ -use crate::{ - Appearance, DEFAULT_ICON_THEME_NAME, SyntaxTheme, Theme, status_colors_refinement, - syntax_overrides, theme_colors_refinement, -}; +#![allow(missing_docs)] + +use crate::schema::{status_colors_refinement, syntax_overrides, theme_colors_refinement}; +use crate::{merge_accent_colors, merge_player_colors}; use collections::HashMap; -use derive_more::{Deref, DerefMut}; use gpui::{ App, Context, Font, FontFallbacks, FontStyle, Global, Pixels, Subscription, Window, px, }; @@ -13,82 +12,24 @@ use serde::{Deserialize, Serialize}; pub use settings::{FontFamilyName, IconThemeName, ThemeAppearanceMode, ThemeName}; use settings::{IntoGpui, RegisterSetting, Settings, SettingsContent}; use std::sync::Arc; +use theme::{Appearance, DEFAULT_ICON_THEME_NAME, SyntaxTheme, Theme, UiDensity}; const MIN_FONT_SIZE: Pixels = px(6.0); const MAX_FONT_SIZE: Pixels = px(100.0); const MIN_LINE_HEIGHT: f32 = 1.0; -#[derive( - Debug, - Default, - PartialEq, - Eq, - PartialOrd, - Ord, - Hash, - Clone, - Copy, - Serialize, - Deserialize, - JsonSchema, -)] - -/// Specifies the density of the UI. -/// Note: This setting is still experimental. See [this tracking issue](https://github.com/zed-industries/zed/issues/18078) -#[serde(rename_all = "snake_case")] -pub enum UiDensity { - /// A denser UI with tighter spacing and smaller elements. - #[serde(alias = "compact")] - Compact, - #[default] - #[serde(alias = "default")] - /// The default UI density. - Default, - #[serde(alias = "comfortable")] - /// A looser UI with more spacing and larger elements. - Comfortable, -} - -impl UiDensity { - /// The spacing ratio of a given density. - /// TODO: Standardize usage throughout the app or remove - pub fn spacing_ratio(self) -> f32 { - match self { - UiDensity::Compact => 0.75, - UiDensity::Default => 1.0, - UiDensity::Comfortable => 1.25, - } - } -} - -impl From for UiDensity { - fn from(s: String) -> Self { - match s.as_str() { - "compact" => Self::Compact, - "default" => Self::Default, - "comfortable" => Self::Comfortable, - _ => Self::default(), - } - } -} - -impl From for String { - fn from(val: UiDensity) -> Self { - match val { - UiDensity::Compact => "compact".to_string(), - UiDensity::Default => "default".to_string(), - UiDensity::Comfortable => "comfortable".to_string(), - } +pub(crate) fn ui_density_from_settings(val: settings::UiDensity) -> UiDensity { + match val { + settings::UiDensity::Compact => UiDensity::Compact, + settings::UiDensity::Default => UiDensity::Default, + settings::UiDensity::Comfortable => UiDensity::Comfortable, } } -impl From for UiDensity { - fn from(val: settings::UiDensity) -> Self { - match val { - settings::UiDensity::Compact => Self::Compact, - settings::UiDensity::Default => Self::Default, - settings::UiDensity::Comfortable => Self::Comfortable, - } +pub fn appearance_to_mode(appearance: Appearance) -> ThemeAppearanceMode { + match appearance { + Appearance::Light => ThemeAppearanceMode::Light, + Appearance::Dark => ThemeAppearanceMode::Dark, } } @@ -145,39 +86,6 @@ pub fn default_theme(appearance: Appearance) -> &'static str { } } -/// The appearance of the system. -#[derive(Debug, Clone, Copy, Deref)] -pub struct SystemAppearance(pub Appearance); - -impl Default for SystemAppearance { - fn default() -> Self { - Self(Appearance::Dark) - } -} - -#[derive(Deref, DerefMut, Default)] -struct GlobalSystemAppearance(SystemAppearance); - -impl Global for GlobalSystemAppearance {} - -impl SystemAppearance { - /// Initializes the [`SystemAppearance`] for the application. - pub fn init(cx: &mut App) { - *cx.default_global::() = - GlobalSystemAppearance(SystemAppearance(cx.window_appearance().into())); - } - - /// Returns the global [`SystemAppearance`]. - pub fn global(cx: &App) -> Self { - cx.global::().0 - } - - /// Returns a mutable reference to the global [`SystemAppearance`]. - pub fn global_mut(cx: &mut App) -> &mut Self { - cx.global_mut::() - } -} - #[derive(Default)] struct BufferFontSize(Pixels); @@ -327,21 +235,16 @@ pub fn set_theme( *theme = theme_name; } settings::ThemeSelection::Dynamic { mode, light, dark } => { - // Update the appropriate theme slot based on appearance. match theme_appearance { Appearance::Light => *light = theme_name, Appearance::Dark => *dark = theme_name, } - // Don't update the theme mode if it is set to system and the new theme has the same - // appearance. let should_update_mode = !(mode == &ThemeAppearanceMode::System && theme_appearance == system_appearance); if should_update_mode { - // Update the mode to the specified appearance (otherwise we might set the theme and - // nothing gets updated because the system specified the other mode appearance). - *mode = ThemeAppearanceMode::from(theme_appearance); + *mode = appearance_to_mode(theme_appearance); } } } @@ -379,9 +282,6 @@ pub fn set_mode(content: &mut SettingsContent, mode: ThemeAppearanceMode) { if let Some(selection) = theme.theme.as_mut() { match selection { settings::ThemeSelection::Static(_) => { - // If the theme was previously set to a single static theme, - // reset to the default dynamic light/dark pair and let users - // customize light/dark themes explicitly afterward. *selection = settings::ThemeSelection::Dynamic { mode: ThemeAppearanceMode::System, light: ThemeName(settings::DEFAULT_LIGHT_THEME.into()), @@ -404,9 +304,6 @@ pub fn set_mode(content: &mut SettingsContent, mode: ThemeAppearanceMode) { if let Some(selection) = theme.icon_theme.as_mut() { match selection { settings::IconThemeSelection::Static(icon_theme) => { - // If the icon theme was previously set to a single static - // theme, we don't know whether it was a light or dark - // theme, so we just use it for both. *selection = settings::IconThemeSelection::Dynamic { mode, light: icon_theme.clone(), @@ -424,7 +321,6 @@ pub fn set_mode(content: &mut SettingsContent, mode: ThemeAppearanceMode) { ))); } } -// } /// The buffer's line height. #[derive(Clone, Copy, Debug, PartialEq, Default)] @@ -530,7 +426,6 @@ impl ThemeSettings { self.agent_buffer_font_size } - // TODO: Rename: `line_height` -> `buffer_line_height` /// Returns the buffer's line height. pub fn line_height(&self) -> f32 { f32::max(self.buffer_line_height.value(), MIN_LINE_HEIGHT) @@ -538,7 +433,6 @@ impl ThemeSettings { /// Applies the theme overrides, if there are any, to the current theme. pub fn apply_theme_overrides(&self, mut arc_theme: Arc) -> Arc { - // Apply the old overrides setting first, so that the new setting can override those. if let Some(experimental_theme_overrides) = &self.experimental_theme_overrides { let mut theme = (*arc_theme).clone(); ThemeSettings::modify_theme(&mut theme, experimental_theme_overrides); @@ -566,11 +460,11 @@ impl ThemeSettings { &status_color_refinement, )); base_theme.styles.status.refine(&status_color_refinement); - base_theme.styles.player.merge(&theme_overrides.players); - base_theme.styles.accents.merge(&theme_overrides.accents); + merge_player_colors(&mut base_theme.styles.player, &theme_overrides.players); + merge_accent_colors(&mut base_theme.styles.accents, &theme_overrides.accents); base_theme.styles.syntax = SyntaxTheme::merge( base_theme.styles.syntax.clone(), - syntax_overrides(&theme_overrides), + syntax_overrides(theme_overrides), ); } } @@ -614,7 +508,6 @@ pub fn reset_buffer_font_size(cx: &mut App) { } } -// TODO: Make private, change usages to use `get_ui_font_size` instead. #[allow(missing_docs)] pub fn setup_ui_font(window: &mut Window, cx: &mut App) -> gpui::Font { let (ui_font, ui_font_size) = { @@ -734,7 +627,7 @@ impl settings::Settings for ThemeSettings { experimental_theme_overrides: content.experimental_theme_overrides.clone(), theme_overrides: content.theme_overrides.clone(), icon_theme: icon_theme_selection, - ui_density: content.ui_density.unwrap_or_default().into(), + ui_density: ui_density_from_settings(content.ui_density.unwrap_or_default()), unnecessary_code_fade: content.unnecessary_code_fade.unwrap().0.clamp(0.0, 0.9), } } diff --git a/crates/theme_settings/src/theme_settings.rs b/crates/theme_settings/src/theme_settings.rs new file mode 100644 index 0000000000000000000000000000000000000000..f5bc96ba02a63088b6311055899b39de65ea9de2 --- /dev/null +++ b/crates/theme_settings/src/theme_settings.rs @@ -0,0 +1,412 @@ +#![deny(missing_docs)] + +//! # Theme Settings +//! +//! This crate provides theme settings integration for Zed, +//! bridging the theme system with the settings infrastructure. + +mod schema; +mod settings; + +use std::sync::Arc; + +use ::settings::{IntoGpui, Settings, SettingsStore}; +use anyhow::{Context as _, Result}; +use gpui::{App, Font, HighlightStyle, Pixels, Refineable}; +use gpui_util::ResultExt; +use theme::{ + AccentColors, Appearance, AppearanceContent, DEFAULT_DARK_THEME, DEFAULT_ICON_THEME_NAME, + GlobalTheme, LoadThemes, PlayerColor, PlayerColors, StatusColors, SyntaxTheme, + SystemAppearance, SystemColors, Theme, ThemeColors, ThemeFamily, ThemeRegistry, + ThemeSettingsProvider, ThemeStyles, default_color_scales, try_parse_color, +}; + +pub use crate::schema::{ + FontStyleContent, FontWeightContent, HighlightStyleContent, StatusColorsContent, + ThemeColorsContent, ThemeContent, ThemeFamilyContent, ThemeStyleContent, + WindowBackgroundContent, status_colors_refinement, syntax_overrides, theme_colors_refinement, +}; +pub use crate::settings::{ + AgentFontSize, BufferLineHeight, FontFamilyName, IconThemeName, IconThemeSelection, + ThemeAppearanceMode, ThemeName, ThemeSelection, ThemeSettings, adjust_agent_buffer_font_size, + adjust_agent_ui_font_size, adjust_buffer_font_size, adjust_ui_font_size, adjusted_font_size, + appearance_to_mode, clamp_font_size, default_theme, observe_buffer_font_size_adjustment, + reset_agent_buffer_font_size, reset_agent_ui_font_size, reset_buffer_font_size, + reset_ui_font_size, set_icon_theme, set_mode, set_theme, setup_ui_font, +}; +pub use theme::UiDensity; + +struct ThemeSettingsProviderImpl; + +impl ThemeSettingsProvider for ThemeSettingsProviderImpl { + fn ui_font<'a>(&'a self, cx: &'a App) -> &'a Font { + &ThemeSettings::get_global(cx).ui_font + } + + fn buffer_font<'a>(&'a self, cx: &'a App) -> &'a Font { + &ThemeSettings::get_global(cx).buffer_font + } + + fn ui_font_size(&self, cx: &App) -> Pixels { + ThemeSettings::get_global(cx).ui_font_size(cx) + } + + fn buffer_font_size(&self, cx: &App) -> Pixels { + ThemeSettings::get_global(cx).buffer_font_size(cx) + } + + fn ui_density(&self, cx: &App) -> UiDensity { + ThemeSettings::get_global(cx).ui_density + } +} + +/// Initialize the theme system with settings integration. +/// +/// This is the full initialization for the application. It calls [`theme::init`] +/// and then wires up settings observation for theme/font changes. +pub fn init(themes_to_load: LoadThemes, cx: &mut App) { + let load_user_themes = matches!(&themes_to_load, LoadThemes::All(_)); + + theme::init(themes_to_load, cx); + theme::set_theme_settings_provider(Box::new(ThemeSettingsProviderImpl), cx); + + if load_user_themes { + let registry = ThemeRegistry::global(cx); + load_bundled_themes(®istry); + } + + let theme = configured_theme(cx); + let icon_theme = configured_icon_theme(cx); + GlobalTheme::update_theme(cx, theme); + GlobalTheme::update_icon_theme(cx, icon_theme); + + let settings = ThemeSettings::get_global(cx); + + let mut prev_buffer_font_size_settings = settings.buffer_font_size_settings(); + let mut prev_ui_font_size_settings = settings.ui_font_size_settings(); + let mut prev_agent_ui_font_size_settings = settings.agent_ui_font_size_settings(); + let mut prev_agent_buffer_font_size_settings = settings.agent_buffer_font_size_settings(); + let mut prev_theme_name = settings.theme.name(SystemAppearance::global(cx).0); + let mut prev_icon_theme_name = settings.icon_theme.name(SystemAppearance::global(cx).0); + let mut prev_theme_overrides = ( + settings.experimental_theme_overrides.clone(), + settings.theme_overrides.clone(), + ); + + cx.observe_global::(move |cx| { + let settings = ThemeSettings::get_global(cx); + + let buffer_font_size_settings = settings.buffer_font_size_settings(); + let ui_font_size_settings = settings.ui_font_size_settings(); + let agent_ui_font_size_settings = settings.agent_ui_font_size_settings(); + let agent_buffer_font_size_settings = settings.agent_buffer_font_size_settings(); + let theme_name = settings.theme.name(SystemAppearance::global(cx).0); + let icon_theme_name = settings.icon_theme.name(SystemAppearance::global(cx).0); + let theme_overrides = ( + settings.experimental_theme_overrides.clone(), + settings.theme_overrides.clone(), + ); + + if buffer_font_size_settings != prev_buffer_font_size_settings { + prev_buffer_font_size_settings = buffer_font_size_settings; + reset_buffer_font_size(cx); + } + + if ui_font_size_settings != prev_ui_font_size_settings { + prev_ui_font_size_settings = ui_font_size_settings; + reset_ui_font_size(cx); + } + + if agent_ui_font_size_settings != prev_agent_ui_font_size_settings { + prev_agent_ui_font_size_settings = agent_ui_font_size_settings; + reset_agent_ui_font_size(cx); + } + + if agent_buffer_font_size_settings != prev_agent_buffer_font_size_settings { + prev_agent_buffer_font_size_settings = agent_buffer_font_size_settings; + reset_agent_buffer_font_size(cx); + } + + if theme_name != prev_theme_name || theme_overrides != prev_theme_overrides { + prev_theme_name = theme_name; + prev_theme_overrides = theme_overrides; + reload_theme(cx); + } + + if icon_theme_name != prev_icon_theme_name { + prev_icon_theme_name = icon_theme_name; + reload_icon_theme(cx); + } + }) + .detach(); +} + +fn configured_theme(cx: &mut App) -> Arc { + let themes = ThemeRegistry::default_global(cx); + let theme_settings = ThemeSettings::get_global(cx); + let system_appearance = SystemAppearance::global(cx); + + let theme_name = theme_settings.theme.name(*system_appearance); + + let theme = match themes.get(&theme_name.0) { + Ok(theme) => theme, + Err(err) => { + if themes.extensions_loaded() { + log::error!("{err}"); + } + themes + .get(default_theme(*system_appearance)) + .unwrap_or_else(|_| themes.get(DEFAULT_DARK_THEME).unwrap()) + } + }; + theme_settings.apply_theme_overrides(theme) +} + +fn configured_icon_theme(cx: &mut App) -> Arc { + let themes = ThemeRegistry::default_global(cx); + let theme_settings = ThemeSettings::get_global(cx); + let system_appearance = SystemAppearance::global(cx); + + let icon_theme_name = theme_settings.icon_theme.name(*system_appearance); + + match themes.get_icon_theme(&icon_theme_name.0) { + Ok(theme) => theme, + Err(err) => { + if themes.extensions_loaded() { + log::error!("{err}"); + } + themes.get_icon_theme(DEFAULT_ICON_THEME_NAME).unwrap() + } + } +} + +/// Reloads the current theme from settings. +pub fn reload_theme(cx: &mut App) { + let theme = configured_theme(cx); + GlobalTheme::update_theme(cx, theme); + cx.refresh_windows(); +} + +/// Reloads the current icon theme from settings. +pub fn reload_icon_theme(cx: &mut App) { + let icon_theme = configured_icon_theme(cx); + GlobalTheme::update_icon_theme(cx, icon_theme); + cx.refresh_windows(); +} + +/// Loads the themes bundled with the Zed binary into the registry. +pub fn load_bundled_themes(registry: &ThemeRegistry) { + let theme_paths = registry + .assets() + .list("themes/") + .expect("failed to list theme assets") + .into_iter() + .filter(|path| path.ends_with(".json")); + + for path in theme_paths { + let Some(theme) = registry.assets().load(&path).log_err().flatten() else { + continue; + }; + + let Some(theme_family) = serde_json::from_slice(&theme) + .with_context(|| format!("failed to parse theme at path \"{path}\"")) + .log_err() + else { + continue; + }; + + let refined = refine_theme_family(theme_family); + registry.insert_theme_families([refined]); + } +} + +/// Loads a user theme from the given bytes into the registry. +pub fn load_user_theme(registry: &ThemeRegistry, bytes: &[u8]) -> Result<()> { + let theme = deserialize_user_theme(bytes)?; + let refined = refine_theme_family(theme); + registry.insert_theme_families([refined]); + Ok(()) +} + +/// Deserializes a user theme from the given bytes. +pub fn deserialize_user_theme(bytes: &[u8]) -> Result { + let theme_family: ThemeFamilyContent = serde_json_lenient::from_slice(bytes)?; + + for theme in &theme_family.themes { + if theme + .style + .colors + .deprecated_scrollbar_thumb_background + .is_some() + { + log::warn!( + r#"Theme "{theme_name}" is using a deprecated style property: scrollbar_thumb.background. Use `scrollbar.thumb.background` instead."#, + theme_name = theme.name + ) + } + } + + Ok(theme_family) +} + +/// Refines a [`ThemeFamilyContent`] and its [`ThemeContent`]s into a [`ThemeFamily`]. +pub fn refine_theme_family(theme_family_content: ThemeFamilyContent) -> ThemeFamily { + let id = uuid::Uuid::new_v4().to_string(); + let name = theme_family_content.name.clone(); + let author = theme_family_content.author.clone(); + + let themes: Vec = theme_family_content + .themes + .iter() + .map(|theme_content| refine_theme(theme_content)) + .collect(); + + ThemeFamily { + id, + name: name.into(), + author: author.into(), + themes, + scales: default_color_scales(), + } +} + +/// Refines a [`ThemeContent`] into a [`Theme`]. +pub fn refine_theme(theme: &ThemeContent) -> Theme { + let appearance = match theme.appearance { + AppearanceContent::Light => Appearance::Light, + AppearanceContent::Dark => Appearance::Dark, + }; + + let mut refined_status_colors = match theme.appearance { + AppearanceContent::Light => StatusColors::light(), + AppearanceContent::Dark => StatusColors::dark(), + }; + let mut status_colors_refinement = status_colors_refinement(&theme.style.status); + theme::apply_status_color_defaults(&mut status_colors_refinement); + refined_status_colors.refine(&status_colors_refinement); + + let mut refined_player_colors = match theme.appearance { + AppearanceContent::Light => PlayerColors::light(), + AppearanceContent::Dark => PlayerColors::dark(), + }; + merge_player_colors(&mut refined_player_colors, &theme.style.players); + + let mut refined_theme_colors = match theme.appearance { + AppearanceContent::Light => ThemeColors::light(), + AppearanceContent::Dark => ThemeColors::dark(), + }; + let mut theme_colors_refinement = + theme_colors_refinement(&theme.style.colors, &status_colors_refinement); + theme::apply_theme_color_defaults(&mut theme_colors_refinement, &refined_player_colors); + refined_theme_colors.refine(&theme_colors_refinement); + + let mut refined_accent_colors = match theme.appearance { + AppearanceContent::Light => AccentColors::light(), + AppearanceContent::Dark => AccentColors::dark(), + }; + merge_accent_colors(&mut refined_accent_colors, &theme.style.accents); + + let syntax_highlights = theme.style.syntax.iter().map(|(syntax_token, highlight)| { + ( + syntax_token.clone(), + HighlightStyle { + color: highlight + .color + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + background_color: highlight + .background_color + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + font_style: highlight.font_style.map(|s| s.into_gpui()), + font_weight: highlight.font_weight.map(|w| w.into_gpui()), + ..Default::default() + }, + ) + }); + let syntax_theme = Arc::new(SyntaxTheme::new(syntax_highlights)); + + let window_background_appearance = theme + .style + .window_background_appearance + .map(|w| w.into_gpui()) + .unwrap_or_default(); + + Theme { + id: uuid::Uuid::new_v4().to_string(), + name: theme.name.clone().into(), + appearance, + styles: ThemeStyles { + system: SystemColors::default(), + window_background_appearance, + accents: refined_accent_colors, + colors: refined_theme_colors, + status: refined_status_colors, + player: refined_player_colors, + syntax: syntax_theme, + }, + } +} + +/// Merges player color overrides into the given [`PlayerColors`]. +pub fn merge_player_colors( + player_colors: &mut PlayerColors, + user_player_colors: &[::settings::PlayerColorContent], +) { + if user_player_colors.is_empty() { + return; + } + + for (idx, player) in user_player_colors.iter().enumerate() { + let cursor = player + .cursor + .as_ref() + .and_then(|color| try_parse_color(color).ok()); + let background = player + .background + .as_ref() + .and_then(|color| try_parse_color(color).ok()); + let selection = player + .selection + .as_ref() + .and_then(|color| try_parse_color(color).ok()); + + if let Some(player_color) = player_colors.0.get_mut(idx) { + *player_color = PlayerColor { + cursor: cursor.unwrap_or(player_color.cursor), + background: background.unwrap_or(player_color.background), + selection: selection.unwrap_or(player_color.selection), + }; + } else { + player_colors.0.push(PlayerColor { + cursor: cursor.unwrap_or_default(), + background: background.unwrap_or_default(), + selection: selection.unwrap_or_default(), + }); + } + } +} + +/// Merges accent color overrides into the given [`AccentColors`]. +pub fn merge_accent_colors( + accent_colors: &mut AccentColors, + user_accent_colors: &[::settings::AccentContent], +) { + if user_accent_colors.is_empty() { + return; + } + + let colors = user_accent_colors + .iter() + .filter_map(|accent_color| { + accent_color + .0 + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + }) + .collect::>(); + + if !colors.is_empty() { + accent_colors.0 = Arc::from(colors); + } +} diff --git a/crates/title_bar/src/collab.rs b/crates/title_bar/src/collab.rs index d740dd90984cd3cbbfd058f7a00a07bb7326f0cd..474d0d287e47dcc6aad0b0f5c57fce382ebf2ca9 100644 --- a/crates/title_bar/src/collab.rs +++ b/crates/title_bar/src/collab.rs @@ -383,10 +383,29 @@ impl TitleBar { ConnectionQuality::Poor => (IconName::SignalMedium, Some(Color::Warning), "Poor"), ConnectionQuality::Lost => (IconName::SignalLow, Some(Color::Error), "Lost"), }; + let quality_label: SharedString = quality_label.into(); + + children.push( + h_flex() + .gap_1() + .child( + IconButton::new("leave-call", IconName::Exit) + .style(ButtonStyle::Subtle) + .tooltip(Tooltip::text("Leave Call")) + .icon_size(IconSize::Small) + .on_click(move |_, _window, cx| { + ActiveCall::global(cx) + .update(cx, |call, cx| call.hang_up(cx)) + .detach_and_log_err(cx); + }), + ) + .child(Divider::vertical().color(DividerColor::Border)) + .into_any_element(), + ); + children.push( IconButton::new("call-quality", signal_icon) - .style(ButtonStyle::Subtle) .icon_size(IconSize::Small) .when_some(signal_color, |button, color| button.icon_color(color)) .tooltip(move |_window, cx| { @@ -413,23 +432,6 @@ impl TitleBar { }) .into_any_element(), ); - children.push( - h_flex() - .gap_1() - .child( - IconButton::new("leave-call", IconName::Exit) - .style(ButtonStyle::Subtle) - .tooltip(Tooltip::text("Leave Call")) - .icon_size(IconSize::Small) - .on_click(move |_, _window, cx| { - ActiveCall::global(cx) - .update(cx, |call, cx| call.hang_up(cx)) - .detach_and_log_err(cx); - }), - ) - .child(Divider::vertical().color(DividerColor::Border)) - .into_any_element(), - ); if is_local && can_share_projects && !is_connecting_to_project { let is_sharing_disabled = channel.is_some_and(|channel| match channel.visibility { diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 9c12e0ca5a0042d7679f5807bab81efbe0ead1eb..42c348bacb680e2a09586d0dc0279fc8c95d1604 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -27,9 +27,9 @@ use client::{Client, UserStore, zed_urls}; use cloud_api_types::Plan; use gpui::{ - Action, AnyElement, App, Context, Corner, Element, Empty, Entity, Focusable, - InteractiveElement, IntoElement, MouseButton, ParentElement, Render, - StatefulInteractiveElement, Styled, Subscription, WeakEntity, Window, actions, div, + Action, AnyElement, App, Context, Corner, Element, Entity, Focusable, InteractiveElement, + IntoElement, MouseButton, ParentElement, Render, StatefulInteractiveElement, Styled, + Subscription, WeakEntity, Window, actions, div, }; use onboarding_banner::OnboardingBanner; use project::{Project, git_store::GitStoreEvent, trusted_worktrees::TrustedWorktrees}; @@ -49,6 +49,7 @@ use util::ResultExt; use workspace::{ MultiWorkspace, ToggleWorktreeSecurity, Workspace, WorkspaceId, notifications::NotifyResultExt, }; + use zed_actions::OpenRemote; pub use onboarding_banner::restore_banner; @@ -81,7 +82,8 @@ pub fn init(cx: &mut App) { let Some(window) = window else { return; }; - let item = cx.new(|cx| TitleBar::new("title-bar", workspace, window, cx)); + let multi_workspace = workspace.multi_workspace().cloned(); + let item = cx.new(|cx| TitleBar::new("title-bar", workspace, multi_workspace, window, cx)); workspace.set_titlebar_item(item.into(), window, cx); workspace.register_action(|workspace, _: &SimulateUpdateAvailable, _window, cx| { @@ -161,7 +163,21 @@ pub struct TitleBar { impl Render for TitleBar { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + if self.multi_workspace.is_none() { + if let Some(mw) = self + .workspace + .upgrade() + .and_then(|ws| ws.read(cx).multi_workspace().cloned()) + { + self.multi_workspace = Some(mw.clone()); + self.platform_titlebar.update(cx, |titlebar, _cx| { + titlebar.set_multi_workspace(mw); + }); + } + } + let title_bar_settings = *TitleBarSettings::get_global(cx); + let button_layout = title_bar_settings.button_layout; let show_menus = show_menus(cx); @@ -257,7 +273,6 @@ impl Render for TitleBar { user.is_none() && TitleBarSettings::get_global(cx).show_sign_in, |this| this.child(self.render_sign_in_button(cx)), ) - .child(self.render_organization_menu_button(cx)) .when(TitleBarSettings::get_global(cx).show_user_menu, |this| { this.child(self.render_user_menu_button(cx)) }) @@ -266,6 +281,7 @@ impl Render for TitleBar { if show_menus { self.platform_titlebar.update(cx, |this, _| { + this.set_button_layout(button_layout); this.set_children( self.application_menu .clone() @@ -293,6 +309,7 @@ impl Render for TitleBar { .into_any_element() } else { self.platform_titlebar.update(cx, |this, _| { + this.set_button_layout(button_layout); this.set_children(children); }); self.platform_titlebar.clone().into_any_element() @@ -304,6 +321,7 @@ impl TitleBar { pub fn new( id: impl Into, workspace: &Workspace, + multi_workspace: Option>, window: &mut Window, cx: &mut Context, ) -> Self { @@ -360,6 +378,7 @@ impl TitleBar { }), ); subscriptions.push(cx.observe(&user_store, |_a, _, cx| cx.notify())); + subscriptions.push(cx.observe_button_layout_changed(window, |_, _, cx| cx.notify())); if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) { subscriptions.push(cx.subscribe(&trusted_worktrees, |_, _, _, cx| { cx.notify(); @@ -380,52 +399,19 @@ impl TitleBar { }); let update_version = cx.new(|cx| UpdateVersion::new(cx)); - let platform_titlebar = cx.new(|cx| PlatformTitleBar::new(id, cx)); - - // Set up observer to sync sidebar state from MultiWorkspace to PlatformTitleBar. - { - let platform_titlebar = platform_titlebar.clone(); - let window_handle = window.window_handle(); - cx.spawn(async move |this: WeakEntity, cx| { - let Some(multi_workspace_handle) = window_handle.downcast::() - else { - return; - }; - - let _ = cx.update(|cx| { - let Ok(multi_workspace) = multi_workspace_handle.entity(cx) else { - return; - }; - - let is_open = multi_workspace.read(cx).sidebar_open(); - platform_titlebar.update(cx, |titlebar, cx| { - titlebar.set_workspace_sidebar_open(is_open, cx); - }); - - let platform_titlebar = platform_titlebar.clone(); - let subscription = cx.observe(&multi_workspace, move |mw, cx| { - let is_open = mw.read(cx).sidebar_open(); - platform_titlebar.update(cx, |titlebar, cx| { - titlebar.set_workspace_sidebar_open(is_open, cx); - }); - }); - - if let Some(this) = this.upgrade() { - this.update(cx, |this, _| { - this._subscriptions.push(subscription); - this.multi_workspace = Some(multi_workspace.downgrade()); - }); - } - }); - }) - .detach(); - } + let platform_titlebar = cx.new(|cx| { + let mut titlebar = PlatformTitleBar::new(id, cx); + if let Some(mw) = multi_workspace.clone() { + titlebar = titlebar.with_multi_workspace(mw); + } + titlebar + }); let mut this = Self { platform_titlebar, application_menu, workspace: workspace.weak_handle(), - multi_workspace: None, + multi_workspace, project, user_store, client, @@ -732,7 +718,13 @@ impl TitleBar { "Open Recent Project".to_string() }; - let is_sidebar_open = self.platform_titlebar.read(cx).is_workspace_sidebar_open(); + let is_sidebar_open = self + .multi_workspace + .as_ref() + .and_then(|mw| mw.upgrade()) + .map(|mw| mw.read(cx).sidebar_open()) + .unwrap_or(false) + && PlatformTitleBar::is_multi_workspace_enabled(cx); let is_threads_list_view_active = self .multi_workspace @@ -1086,110 +1078,80 @@ impl TitleBar { }) } - pub fn render_organization_menu_button(&mut self, cx: &mut Context) -> AnyElement { - let Some(organization) = self.user_store.read(cx).current_organization() else { - return Empty.into_any_element(); - }; - - PopoverMenu::new("organization-menu") - .anchor(Corner::TopRight) - .menu({ - let user_store = self.user_store.clone(); - move |window, cx| { - ContextMenu::build(window, cx, |mut menu, _window, cx| { - menu = menu.header("Organizations").separator(); - - let current_organization = user_store.read(cx).current_organization(); - - for organization in user_store.read(cx).organizations() { - let organization = organization.clone(); - let plan = user_store.read(cx).plan_for_organization(&organization.id); - - let is_current = - current_organization - .as_ref() - .is_some_and(|current_organization| { - current_organization.id == organization.id - }); - - menu = menu.custom_entry( - { - let organization = organization.clone(); - move |_window, _cx| { - h_flex() - .w_full() - .gap_1() - .child( - div() - .flex_none() - .when(!is_current, |parent| parent.invisible()) - .child(Icon::new(IconName::Check)), - ) - .child( - h_flex() - .w_full() - .gap_3() - .justify_between() - .child(Label::new(&organization.name)) - .child(PlanChip::new( - plan.unwrap_or(Plan::ZedFree), - )), - ) - .into_any_element() - } - }, - { - let user_store = user_store.clone(); - let organization = organization.clone(); - move |_window, cx| { - user_store.update(cx, |user_store, cx| { - user_store - .set_current_organization(organization.clone(), cx); - }); - } - }, - ); - } - - menu - }) - .into() - } - }) - .trigger_with_tooltip( - Button::new("organization-menu", &organization.name) - .selected_style(ButtonStyle::Tinted(TintColor::Accent)) - .label_size(LabelSize::Small), - Tooltip::text("Toggle Organization Menu"), - ) - .anchor(gpui::Corner::TopRight) - .into_any_element() - } - pub fn render_user_menu_button(&mut self, cx: &mut Context) -> impl Element { - let show_update_badge = self.update_version.read(cx).show_update_in_menu_bar(); + let show_update_button = self.update_version.read(cx).show_update_in_menu_bar(); - let user_store = self.user_store.read(cx); - let user = user_store.current_user(); + let user_store = self.user_store.clone(); + let user_store_read = user_store.read(cx); + let user = user_store_read.current_user(); let user_avatar = user.as_ref().map(|u| u.avatar_uri.clone()); let user_login = user.as_ref().map(|u| u.github_login.clone()); let is_signed_in = user.is_some(); - let has_subscription_period = user_store.subscription_period().is_some(); - let plan = user_store.plan().filter(|_| { + let has_subscription_period = user_store_read.subscription_period().is_some(); + let plan = user_store_read.plan().filter(|_| { // Since the user might be on the legacy free plan we filter based on whether we have a subscription period. has_subscription_period }); + let has_organization = user_store_read.current_organization().is_some(); + + let current_organization = user_store_read.current_organization(); + let business_organization = current_organization + .as_ref() + .filter(|organization| !organization.is_personal); + let organizations: Vec<_> = user_store_read + .organizations() + .iter() + .map(|org| { + let plan = user_store_read.plan_for_organization(&org.id); + (org.clone(), plan) + }) + .collect(); + + let show_user_picture = TitleBarSettings::get_global(cx).show_user_picture; + + let trigger = if is_signed_in && show_user_picture { + let avatar = user_avatar.map(|avatar| Avatar::new(avatar)).map(|avatar| { + if show_update_button { + avatar.indicator( + div() + .absolute() + .bottom_0() + .right_0() + .child(Indicator::dot().color(Color::Accent)), + ) + } else { + avatar + } + }); + + ButtonLike::new("user-menu").child( + h_flex() + .when_some(business_organization, |this, organization| { + this.gap_2() + .child(Label::new(&organization.name).size(LabelSize::Small)) + }) + .children(avatar), + ) + } else { + ButtonLike::new("user-menu") + .child(Icon::new(IconName::ChevronDown).size(IconSize::Small)) + }; + PopoverMenu::new("user-menu") - .anchor(Corner::TopRight) + .trigger(trigger) .menu(move |window, cx| { - ContextMenu::build(window, cx, |menu, _, _cx| { - let user_login = user_login.clone(); + let user_login = user_login.clone(); + let current_organization = current_organization.clone(); + let organizations = organizations.clone(); + let user_store = user_store.clone(); + ContextMenu::build(window, cx, |menu, _, _cx| { menu.when(is_signed_in, |this| { + let user_login = user_login.clone(); this.custom_entry( move |_window, _cx| { let user_login = user_login.clone().unwrap_or_default(); @@ -1207,7 +1169,7 @@ impl TitleBar { ) .separator() }) - .when(show_update_badge, |this| { + .when(show_update_button, |this| { this.custom_entry( move |_window, _cx| { h_flex() @@ -1228,6 +1190,58 @@ impl TitleBar { ) .separator() }) + .when(has_organization, |this| { + let mut this = this.header("Organization"); + + for (organization, plan) in &organizations { + let organization = organization.clone(); + let plan = *plan; + + let is_current = + current_organization + .as_ref() + .is_some_and(|current_organization| { + current_organization.id == organization.id + }); + + this = this.custom_entry( + { + let organization = organization.clone(); + move |_window, _cx| { + h_flex() + .w_full() + .gap_4() + .justify_between() + .child( + h_flex() + .gap_1() + .child(Label::new(&organization.name)) + .when(is_current, |this| { + this.child( + Icon::new(IconName::Check) + .color(Color::Accent), + ) + }), + ) + .child(PlanChip::new(plan.unwrap_or(Plan::ZedFree))) + .into_any_element() + } + }, + { + let user_store = user_store.clone(); + let organization = organization.clone(); + move |_window, cx| { + user_store.update(cx, |user_store, cx| { + user_store + .set_current_organization(organization.clone(), cx); + }); + } + }, + ); + } + + this.separator() + }) .action("Settings", zed_actions::OpenSettings.boxed_clone()) .action("Keymap", Box::new(zed_actions::OpenKeymap)) .action( @@ -1249,37 +1263,6 @@ impl TitleBar { }) .into() }) - .map(|this| { - if is_signed_in && TitleBarSettings::get_global(cx).show_user_picture { - let avatar = - user_avatar - .clone() - .map(|avatar| Avatar::new(avatar)) - .map(|avatar| { - if show_update_badge { - avatar.indicator( - div() - .absolute() - .bottom_0() - .right_0() - .child(Indicator::dot().color(Color::Accent)), - ) - } else { - avatar - } - }); - this.trigger_with_tooltip( - ButtonLike::new("user-menu").children(avatar), - Tooltip::text("Toggle User Menu"), - ) - } else { - this.trigger_with_tooltip( - IconButton::new("user-menu", IconName::ChevronDown) - .icon_size(IconSize::Small), - Tooltip::text("Toggle User Menu"), - ) - } - }) - .anchor(gpui::Corner::TopRight) + .anchor(Corner::TopRight) } } diff --git a/crates/title_bar/src/title_bar_settings.rs b/crates/title_bar/src/title_bar_settings.rs index 155b7b7bc797567927a70b12c677372cb92c9453..61f951ca305d1a0bb53100b883a5e77409adb54f 100644 --- a/crates/title_bar/src/title_bar_settings.rs +++ b/crates/title_bar/src/title_bar_settings.rs @@ -1,3 +1,4 @@ +use gpui::WindowButtonLayout; use settings::{RegisterSetting, Settings, SettingsContent}; #[derive(Copy, Clone, Debug, RegisterSetting)] @@ -10,6 +11,7 @@ pub struct TitleBarSettings { pub show_sign_in: bool, pub show_user_menu: bool, pub show_menus: bool, + pub button_layout: Option, } impl Settings for TitleBarSettings { @@ -24,6 +26,7 @@ impl Settings for TitleBarSettings { show_sign_in: content.show_sign_in.unwrap(), show_user_menu: content.show_user_menu.unwrap(), show_menus: content.show_menus.unwrap(), + button_layout: content.button_layout.unwrap_or_default().into_layout(), } } } diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 5eb58bf1da1f25cc273a9fc5d7c08b920d3471e9..05433bf8eebf78eccbbedff7a4bfcfb39b0022a7 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -23,13 +23,12 @@ itertools.workspace = true menu.workspace = true schemars.workspace = true serde.workspace = true -settings.workspace = true smallvec.workspace = true story = { workspace = true, optional = true } strum.workspace = true theme.workspace = true ui_macros.workspace = true -util.workspace = true +gpui_util.workspace = true [target.'cfg(windows)'.dependencies] windows.workspace = true diff --git a/crates/ui/src/components/ai.rs b/crates/ui/src/components/ai.rs index a31db264e985b3adbca26b9e8d3fb2bdca306dcb..e3ad1db794902ae28b28274a60e3593efb3be392 100644 --- a/crates/ui/src/components/ai.rs +++ b/crates/ui/src/components/ai.rs @@ -1,5 +1,7 @@ +mod ai_setting_item; mod configured_api_card; mod thread_item; +pub use ai_setting_item::*; pub use configured_api_card::*; pub use thread_item::*; diff --git a/crates/ui/src/components/ai/ai_setting_item.rs b/crates/ui/src/components/ai/ai_setting_item.rs new file mode 100644 index 0000000000000000000000000000000000000000..bfb55e4c7da688b736b4ff5c64a5767f1e930120 --- /dev/null +++ b/crates/ui/src/components/ai/ai_setting_item.rs @@ -0,0 +1,406 @@ +use crate::{IconDecoration, IconDecorationKind, Tooltip, prelude::*}; +use gpui::{Animation, AnimationExt, SharedString, pulsating_between}; +use std::time::Duration; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum AiSettingItemStatus { + #[default] + Stopped, + Starting, + Running, + Error, + AuthRequired, + Authenticating, +} + +impl AiSettingItemStatus { + fn tooltip_text(&self) -> &'static str { + match self { + Self::Stopped => "Server is stopped.", + Self::Starting => "Server is starting.", + Self::Running => "Server is active.", + Self::Error => "Server has an error.", + Self::AuthRequired => "Authentication required.", + Self::Authenticating => "Waiting for authorization…", + } + } + + fn indicator_color(&self) -> Option { + match self { + Self::Stopped => None, + Self::Starting | Self::Authenticating => Some(Color::Muted), + Self::Running => Some(Color::Success), + Self::Error => Some(Color::Error), + Self::AuthRequired => Some(Color::Warning), + } + } + + fn is_animated(&self) -> bool { + matches!(self, Self::Starting | Self::Authenticating) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AiSettingItemSource { + Extension, + Custom, + Registry, +} + +impl AiSettingItemSource { + fn icon_name(&self) -> IconName { + match self { + Self::Extension => IconName::ZedSrcExtension, + Self::Custom => IconName::ZedSrcCustom, + Self::Registry => IconName::AcpRegistry, + } + } + + fn tooltip_text(&self, label: &str) -> String { + match self { + Self::Extension => format!("{label} was installed from an extension."), + Self::Registry => format!("{label} was installed from the ACP registry."), + Self::Custom => format!("{label} was configured manually."), + } + } +} + +/// A reusable setting item row for AI-related configuration lists. +#[derive(IntoElement, RegisterComponent)] +pub struct AiSettingItem { + id: ElementId, + status: AiSettingItemStatus, + source: AiSettingItemSource, + icon: Option, + label: SharedString, + detail_label: Option, + actions: Vec, + details: Option, +} + +impl AiSettingItem { + pub fn new( + id: impl Into, + label: impl Into, + status: AiSettingItemStatus, + source: AiSettingItemSource, + ) -> Self { + Self { + id: id.into(), + status, + source, + icon: None, + label: label.into(), + detail_label: None, + actions: Vec::new(), + details: None, + } + } + + pub fn icon(mut self, element: impl IntoElement) -> Self { + self.icon = Some(element.into_any_element()); + self + } + + pub fn detail_label(mut self, detail: impl Into) -> Self { + self.detail_label = Some(detail.into()); + self + } + + pub fn action(mut self, element: impl IntoElement) -> Self { + self.actions.push(element.into_any_element()); + self + } + + pub fn details(mut self, element: impl IntoElement) -> Self { + self.details = Some(element.into_any_element()); + self + } +} + +impl RenderOnce for AiSettingItem { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let Self { + id, + status, + source, + icon, + label, + detail_label, + actions, + details, + } = self; + + let source_id = format!("source-{}", id); + let icon_id = format!("icon-{}", id); + let status_tooltip = status.tooltip_text(); + let source_tooltip = source.tooltip_text(&label); + + let icon_element = icon.unwrap_or_else(|| { + let letter = label.chars().next().unwrap_or('?').to_ascii_uppercase(); + + h_flex() + .size_5() + .flex_none() + .justify_center() + .rounded_sm() + .border_1() + .border_color(cx.theme().colors().border_variant) + .bg(cx.theme().colors().element_active.opacity(0.2)) + .child( + Label::new(SharedString::from(letter.to_string())) + .size(LabelSize::Small) + .color(Color::Muted) + .buffer_font(cx), + ) + .into_any_element() + }); + + let icon_child = if status.is_animated() { + div() + .child(icon_element) + .with_animation( + format!("icon-pulse-{}", id), + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(pulsating_between(0.4, 0.8)), + |element, delta| element.opacity(delta), + ) + .into_any_element() + } else { + icon_element.into_any_element() + }; + + let icon_container = div() + .id(icon_id) + .relative() + .flex_none() + .tooltip(Tooltip::text(status_tooltip)) + .child(icon_child) + .when_some(status.indicator_color(), |this, color| { + this.child( + IconDecoration::new( + IconDecorationKind::Dot, + cx.theme().colors().panel_background, + cx, + ) + .size(px(12.)) + .color(color.color(cx)) + .position(gpui::Point { + x: px(-3.), + y: px(-3.), + }), + ) + }); + + v_flex() + .id(id) + .min_w_0() + .child( + h_flex() + .min_w_0() + .w_full() + .gap_1p5() + .justify_between() + .child( + h_flex() + .flex_1() + .min_w_0() + .gap_1p5() + .child(icon_container) + .child(Label::new(label).flex_shrink_0().truncate()) + .child( + div() + .id(source_id) + .min_w_0() + .flex_none() + .tooltip(Tooltip::text(source_tooltip)) + .child( + Icon::new(source.icon_name()) + .size(IconSize::Small) + .color(Color::Muted), + ), + ) + .when_some(detail_label, |this, detail| { + this.child( + Label::new(detail) + .color(Color::Muted) + .size(LabelSize::Small), + ) + }), + ) + .when(!actions.is_empty(), |this| { + this.child(h_flex().gap_0p5().flex_none().children(actions)) + }), + ) + .children(details) + } +} + +impl Component for AiSettingItem { + fn scope() -> ComponentScope { + ComponentScope::Agent + } + + fn preview(_window: &mut Window, cx: &mut App) -> Option { + let container = || { + v_flex() + .w_80() + .p_2() + .gap_2() + .border_1() + .border_color(cx.theme().colors().border_variant) + .bg(cx.theme().colors().panel_background) + }; + + let details_row = |icon_name: IconName, icon_color: Color, message: &str| { + h_flex() + .py_1() + .min_w_0() + .w_full() + .gap_2() + .justify_between() + .child( + h_flex() + .pr_4() + .min_w_0() + .w_full() + .gap_2() + .child( + Icon::new(icon_name) + .size(IconSize::XSmall) + .color(icon_color), + ) + .child( + div().min_w_0().flex_1().child( + Label::new(SharedString::from(message.to_string())) + .color(Color::Muted) + .size(LabelSize::Small), + ), + ), + ) + }; + + let examples = vec![ + single_example( + "MCP server with letter avatar (running)", + container() + .child( + AiSettingItem::new( + "ext-mcp", + "Postgres", + AiSettingItemStatus::Running, + AiSettingItemSource::Extension, + ) + .detail_label("3 tools") + .action( + IconButton::new("menu", IconName::Settings) + .icon_size(IconSize::Small) + .icon_color(Color::Muted), + ) + .action( + IconButton::new("toggle", IconName::Check) + .icon_size(IconSize::Small) + .icon_color(Color::Muted), + ), + ) + .into_any_element(), + ), + single_example( + "MCP server (stopped)", + container() + .child(AiSettingItem::new( + "custom-mcp", + "my-local-server", + AiSettingItemStatus::Stopped, + AiSettingItemSource::Custom, + )) + .into_any_element(), + ), + single_example( + "MCP server (starting, animated)", + container() + .child(AiSettingItem::new( + "starting-mcp", + "Context7", + AiSettingItemStatus::Starting, + AiSettingItemSource::Extension, + )) + .into_any_element(), + ), + single_example( + "Agent with icon (running)", + container() + .child( + AiSettingItem::new( + "ext-agent", + "Claude Agent", + AiSettingItemStatus::Running, + AiSettingItemSource::Extension, + ) + .icon( + Icon::new(IconName::AiClaude) + .size(IconSize::Small) + .color(Color::Muted), + ) + .action( + IconButton::new("restart", IconName::RotateCw) + .icon_size(IconSize::Small) + .icon_color(Color::Muted), + ) + .action( + IconButton::new("delete", IconName::Trash) + .icon_size(IconSize::Small) + .icon_color(Color::Muted), + ), + ) + .into_any_element(), + ), + single_example( + "Registry agent (starting, animated)", + container() + .child( + AiSettingItem::new( + "reg-agent", + "Devin Agent", + AiSettingItemStatus::Starting, + AiSettingItemSource::Registry, + ) + .icon( + Icon::new(IconName::ZedAssistant) + .size(IconSize::Small) + .color(Color::Muted), + ), + ) + .into_any_element(), + ), + single_example( + "Error with details", + container() + .child( + AiSettingItem::new( + "error-mcp", + "Amplitude", + AiSettingItemStatus::Error, + AiSettingItemSource::Extension, + ) + .details( + details_row( + IconName::XCircle, + Color::Error, + "Failed to connect: connection refused", + ) + .child( + Button::new("logout", "Log Out") + .style(ButtonStyle::Outlined) + .label_size(LabelSize::Small), + ), + ), + ) + .into_any_element(), + ), + ]; + + Some(example_group(examples).vertical().into_any_element()) + } +} diff --git a/crates/ui/src/components/ai/thread_item.rs b/crates/ui/src/components/ai/thread_item.rs index 875f73ed892fcce6a152ca21f5a661d262c02ad8..aebfbffce6926741c7ec1faa393750b3a1b35ebd 100644 --- a/crates/ui/src/components/ai/thread_item.rs +++ b/crates/ui/src/components/ai/thread_item.rs @@ -7,7 +7,8 @@ use gpui::{ Animation, AnimationExt, AnyView, ClickEvent, Hsla, MouseButton, SharedString, pulsating_between, }; -use std::time::Duration; +use itertools::Itertools as _; +use std::{path::PathBuf, sync::Arc, time::Duration}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum AgentThreadStatus { @@ -18,6 +19,13 @@ pub enum AgentThreadStatus { Error, } +#[derive(Clone)] +pub struct ThreadItemWorktreeInfo { + pub name: SharedString, + pub full_path: SharedString, + pub highlight_positions: Vec, +} + #[derive(IntoElement, RegisterComponent)] pub struct ThreadItem { id: ElementId, @@ -37,9 +45,8 @@ pub struct ThreadItem { hovered: bool, added: Option, removed: Option, - worktree: Option, - worktree_full_path: Option, - worktree_highlight_positions: Vec, + project_paths: Option>, + worktrees: Vec, on_click: Option>, on_hover: Box, action_slot: Option, @@ -66,9 +73,9 @@ impl ThreadItem { hovered: false, added: None, removed: None, - worktree: None, - worktree_full_path: None, - worktree_highlight_positions: Vec::new(), + + project_paths: None, + worktrees: Vec::new(), on_click: None, on_hover: Box::new(|_, _, _| {}), action_slot: None, @@ -146,18 +153,13 @@ impl ThreadItem { self } - pub fn worktree(mut self, worktree: impl Into) -> Self { - self.worktree = Some(worktree.into()); + pub fn project_paths(mut self, paths: Arc<[PathBuf]>) -> Self { + self.project_paths = Some(paths); self } - pub fn worktree_full_path(mut self, worktree_full_path: impl Into) -> Self { - self.worktree_full_path = Some(worktree_full_path.into()); - self - } - - pub fn worktree_highlight_positions(mut self, positions: Vec) -> Self { - self.worktree_highlight_positions = positions; + pub fn worktrees(mut self, worktrees: Vec) -> Self { + self.worktrees = worktrees; self } @@ -319,7 +321,22 @@ impl RenderOnce for ThreadItem { let added_count = self.added.unwrap_or(0); let removed_count = self.removed.unwrap_or(0); - let has_worktree = self.worktree.is_some(); + let project_paths = self.project_paths.as_ref().and_then(|paths| { + let paths_str = paths + .as_ref() + .iter() + .filter_map(|p| p.file_name()) + .filter_map(|name| name.to_str()) + .join(", "); + if paths_str.is_empty() { + None + } else { + Some(paths_str) + } + }); + + let has_project_paths = project_paths.is_some(); + let has_worktree = !self.worktrees.is_empty(); let has_timestamp = !self.timestamp.is_empty(); let timestamp = self.timestamp; @@ -375,70 +392,110 @@ impl RenderOnce for ThreadItem { }) }), ) - .when(has_worktree || has_diff_stats || has_timestamp, |this| { - let worktree_full_path = self.worktree_full_path.clone().unwrap_or_default(); - let worktree_label = self.worktree.map(|worktree| { - let positions = self.worktree_highlight_positions; - if positions.is_empty() { - Label::new(worktree) - .size(LabelSize::Small) - .color(Color::Muted) - .into_any_element() + .when( + has_project_paths || has_worktree || has_diff_stats || has_timestamp, + |this| { + // Collect all full paths for the shared tooltip. + let worktree_tooltip: SharedString = self + .worktrees + .iter() + .map(|wt| wt.full_path.as_ref()) + .collect::>() + .join("\n") + .into(); + let worktree_tooltip_title = if self.worktrees.len() > 1 { + "Thread Running in Local Git Worktrees" } else { - HighlightedLabel::new(worktree, positions) - .size(LabelSize::Small) - .color(Color::Muted) - .into_any_element() - } - }); - - this.child( - h_flex() - .min_w_0() - .gap_1p5() - .child(icon_container()) // Icon Spacing - .when_some(worktree_label, |this, label| { - this.child( - h_flex() - .id(format!("{}-worktree", self.id.clone())) - .gap_1() - .child( - Icon::new(IconName::GitWorktree) - .size(IconSize::XSmall) - .color(Color::Muted), + "Thread Running in a Local Git Worktree" + }; + + // Deduplicate chips by name — e.g. two paths both named + // "olivetti" produce a single chip. Highlight positions + // come from the first occurrence. + let mut seen_names: Vec = Vec::new(); + let mut worktree_chips: Vec = Vec::new(); + for wt in self.worktrees { + if seen_names.contains(&wt.name) { + continue; + } + let chip_index = seen_names.len(); + seen_names.push(wt.name.clone()); + let label = if wt.highlight_positions.is_empty() { + Label::new(wt.name) + .size(LabelSize::Small) + .color(Color::Muted) + .into_any_element() + } else { + HighlightedLabel::new(wt.name, wt.highlight_positions) + .size(LabelSize::Small) + .color(Color::Muted) + .into_any_element() + }; + let tooltip_title = worktree_tooltip_title; + let tooltip_meta = worktree_tooltip.clone(); + worktree_chips.push( + h_flex() + .id(format!("{}-worktree-{chip_index}", self.id.clone())) + .gap_0p5() + .child( + Icon::new(IconName::GitWorktree) + .size(IconSize::XSmall) + .color(Color::Muted), + ) + .child(label) + .tooltip(move |_, cx| { + Tooltip::with_meta( + tooltip_title, + None, + tooltip_meta.clone(), + cx, ) - .child(label) - .tooltip(move |_, cx| { - Tooltip::with_meta( - "Thread Running in a Local Git Worktree", - None, - worktree_full_path.clone(), - cx, - ) - }), - ) - }) - .when(has_worktree && (has_diff_stats || has_timestamp), |this| { - this.child(dot_separator()) - }) - .when(has_diff_stats, |this| { - this.child( - DiffStat::new(diff_stat_id, added_count, removed_count) - .tooltip("Unreviewed changes"), - ) - }) - .when(has_diff_stats && has_timestamp, |this| { - this.child(dot_separator()) - }) - .when(has_timestamp, |this| { - this.child( - Label::new(timestamp.clone()) - .size(LabelSize::Small) - .color(Color::Muted), + }) + .into_any_element(), + ); + } + + this.child( + h_flex() + .min_w_0() + .gap_1p5() + .child(icon_container()) // Icon Spacing + .when_some(project_paths, |this, paths| { + this.child( + Label::new(paths) + .size(LabelSize::Small) + .color(Color::Muted) + .into_any_element(), + ) + }) + .when(has_project_paths && has_worktree, |this| { + this.child(dot_separator()) + }) + .children(worktree_chips) + .when( + (has_project_paths || has_worktree) + && (has_diff_stats || has_timestamp), + |this| this.child(dot_separator()), ) - }), - ) - }) + .when(has_diff_stats, |this| { + this.child( + DiffStat::new(diff_stat_id, added_count, removed_count) + .tooltip("Unreviewed changes"), + ) + }) + .when(has_diff_stats && has_timestamp, |this| { + this.child(dot_separator()) + }) + .when(has_timestamp, |this| { + this.child( + Label::new(timestamp.clone()) + .size(LabelSize::Small) + .color(Color::Muted), + ) + }), + ) + }, + ) .when_some(self.on_click, |this, on_click| this.on_click(on_click)) } } @@ -526,7 +583,11 @@ impl Component for ThreadItem { ThreadItem::new("ti-4", "Add line numbers option to FileEditBlock") .icon(IconName::AiClaude) .timestamp("2w") - .worktree("link-agent-panel"), + .worktrees(vec![ThreadItemWorktreeInfo { + name: "link-agent-panel".into(), + full_path: "link-agent-panel".into(), + highlight_positions: Vec::new(), + }]), ) .into_any_element(), ), @@ -548,7 +609,11 @@ impl Component for ThreadItem { .child( ThreadItem::new("ti-5b", "Full metadata example") .icon(IconName::AiClaude) - .worktree("my-project") + .worktrees(vec![ThreadItemWorktreeInfo { + name: "my-project".into(), + full_path: "my-project".into(), + highlight_positions: Vec::new(), + }]) .added(42) .removed(17) .timestamp("3w"), @@ -623,8 +688,11 @@ impl Component for ThreadItem { ThreadItem::new("ti-11", "Search in worktree name") .icon(IconName::AiClaude) .timestamp("3mo") - .worktree("my-project-name") - .worktree_highlight_positions(vec![3, 4, 5, 6, 7, 8, 9, 10, 11]), + .worktrees(vec![ThreadItemWorktreeInfo { + name: "my-project-name".into(), + full_path: "my-project-name".into(), + highlight_positions: vec![3, 4, 5, 6, 7, 8, 9, 10, 11], + }]), ) .into_any_element(), ), diff --git a/crates/ui/src/components/context_menu.rs b/crates/ui/src/components/context_menu.rs index 064b67a433f0d053db9552e8def1064237db3980..2fcfd73b93d7c47018819fd9ec4426e9f1b38147 100644 --- a/crates/ui/src/components/context_menu.rs +++ b/crates/ui/src/components/context_menu.rs @@ -8,14 +8,12 @@ use gpui::{ Subscription, anchored, canvas, prelude::*, px, }; use menu::{SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious}; -use settings::Settings; use std::{ cell::{Cell, RefCell}, collections::HashMap, rc::Rc, time::{Duration, Instant}, }; -use theme::ThemeSettings; #[derive(Copy, Clone, Debug, PartialEq, Eq)] enum SubmenuOpenTrigger { @@ -2050,7 +2048,7 @@ impl ContextMenuItem { impl Render for ContextMenu { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx); + let ui_font_size = theme::theme_settings(cx).ui_font_size(cx); let window_size = window.viewport_size(); let rem_size = window.rem_size(); let is_wide_window = window_size.width / rem_size > rems_from_px(800.).0; diff --git a/crates/ui/src/components/disclosure.rs b/crates/ui/src/components/disclosure.rs index 84282db2e332dc5d39cde2b3aae8d8d181a1024c..320751890dab3a61d2a3ccfaa7917204b5d32c76 100644 --- a/crates/ui/src/components/disclosure.rs +++ b/crates/ui/src/components/disclosure.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use gpui::{ClickEvent, CursorStyle, SharedString}; -use crate::{Color, IconButton, IconButtonShape, IconName, IconSize, prelude::*}; +use crate::prelude::*; #[derive(IntoElement, RegisterComponent)] pub struct Disclosure { @@ -91,7 +91,6 @@ impl RenderOnce for Disclosure { false => self.closed_icon, }, ) - .shape(IconButtonShape::Square) .icon_color(Color::Muted) .icon_size(IconSize::Small) .disabled(self.disabled) diff --git a/crates/ui/src/components/icon/icon_decoration.rs b/crates/ui/src/components/icon/icon_decoration.rs index 9f84a8bcf4eb10672161ed2733d7ed5baa95f899..423f6d73a68e8ee3aea550129e2a6220a8a699a6 100644 --- a/crates/ui/src/components/icon/icon_decoration.rs +++ b/crates/ui/src/components/icon/icon_decoration.rs @@ -63,6 +63,7 @@ pub struct IconDecoration { color: Hsla, knockout_color: Hsla, knockout_hover_color: Hsla, + size: Pixels, position: Point, group_name: Option, } @@ -78,6 +79,7 @@ impl IconDecoration { color, knockout_color, knockout_hover_color: knockout_color, + size: ICON_DECORATION_SIZE, position, group_name: None, } @@ -116,6 +118,12 @@ impl IconDecoration { self } + /// Sets the size of the decoration. + pub fn size(mut self, size: Pixels) -> Self { + self.size = size; + self + } + /// Sets the name of the group the decoration belongs to pub fn group_name(mut self, name: Option) -> Self { self.group_name = name; @@ -125,11 +133,13 @@ impl IconDecoration { impl RenderOnce for IconDecoration { fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { + let size = self.size; + let foreground = svg() .absolute() .bottom_0() .right_0() - .size(ICON_DECORATION_SIZE) + .size(size) .path(self.kind.fg().path()) .text_color(self.color); @@ -137,7 +147,7 @@ impl RenderOnce for IconDecoration { .absolute() .bottom_0() .right_0() - .size(ICON_DECORATION_SIZE) + .size(size) .path(self.kind.bg().path()) .text_color(self.knockout_color) .map(|this| match self.group_name { @@ -148,7 +158,7 @@ impl RenderOnce for IconDecoration { }); div() - .size(ICON_DECORATION_SIZE) + .size(size) .flex_none() .absolute() .bottom(self.position.y) diff --git a/crates/ui/src/components/keybinding.rs b/crates/ui/src/components/keybinding.rs index e22669995db416a3ec6884a79860e76610dd7d03..016181ee9bd22aba1dd937220df03212aa390153 100644 --- a/crates/ui/src/components/keybinding.rs +++ b/crates/ui/src/components/keybinding.rs @@ -1,13 +1,13 @@ use std::rc::Rc; use crate::PlatformStyle; +use crate::utils::capitalize; use crate::{Icon, IconName, IconSize, h_flex, prelude::*}; use gpui::{ Action, AnyElement, App, FocusHandle, Global, IntoElement, KeybindingKeystroke, Keystroke, Modifiers, Window, relative, }; use itertools::Itertools; -use settings::KeybindSource; #[derive(Debug)] enum Source { @@ -102,11 +102,11 @@ impl KeyBinding { } } - pub fn from_keystrokes(keystrokes: Rc<[KeybindingKeystroke]>, source: KeybindSource) -> Self { + pub fn from_keystrokes(keystrokes: Rc<[KeybindingKeystroke]>, vim_mode: bool) -> Self { Self { source: Source::Keystrokes { keystrokes }, size: None, - vim_mode: source == KeybindSource::Vim, + vim_mode, platform_style: PlatformStyle::platform(), disabled: false, } @@ -142,7 +142,7 @@ fn render_key( match key_icon { Some(icon) => KeyIcon::new(icon, color).size(size).into_any_element(), None => { - let key = util::capitalize(key); + let key = capitalize(key); Key::new(&key, color).size(size).into_any_element() } } @@ -546,7 +546,7 @@ fn keystroke_text( let key = match key { "pageup" => "PageUp", "pagedown" => "PageDown", - key => &util::capitalize(key), + key => &capitalize(key), }; text.push_str(key); } diff --git a/crates/ui/src/components/keybinding_hint.rs b/crates/ui/src/components/keybinding_hint.rs index 7c19953ca43c907070829f7140f97a4fde495b57..9da470c4ee417321c61d1834c1256dd41316aedf 100644 --- a/crates/ui/src/components/keybinding_hint.rs +++ b/crates/ui/src/components/keybinding_hint.rs @@ -14,11 +14,10 @@ use theme::Appearance; /// use gpui::{App, Hsla, KeybindingKeystroke, Keystroke}; /// use ui::prelude::*; /// use ui::{KeyBinding, KeybindingHint}; -/// use settings::KeybindSource; /// /// # fn example(cx: &App) { /// let hint = KeybindingHint::new( -/// KeyBinding::from_keystrokes(vec![KeybindingKeystroke::from_keystroke(Keystroke::parse("ctrl-s").unwrap())].into(), KeybindSource::Base), +/// KeyBinding::from_keystrokes(vec![KeybindingKeystroke::from_keystroke(Keystroke::parse("ctrl-s").unwrap())].into(), false), /// Hsla::black() /// ) /// .prefix("Save:") @@ -46,11 +45,10 @@ impl KeybindingHint { /// use gpui::{App, Hsla, KeybindingKeystroke, Keystroke}; /// use ui::prelude::*; /// use ui::{KeyBinding, KeybindingHint}; - /// use settings::KeybindSource; /// /// # fn example(cx: &App) { /// let hint = KeybindingHint::new( - /// KeyBinding::from_keystrokes(vec![KeybindingKeystroke::from_keystroke(Keystroke::parse("ctrl-c").unwrap())].into(), KeybindSource::Base), + /// KeyBinding::from_keystrokes(vec![KeybindingKeystroke::from_keystroke(Keystroke::parse("ctrl-c").unwrap())].into(), false), /// Hsla::black() /// ); /// # } @@ -76,12 +74,11 @@ impl KeybindingHint { /// use gpui::{App, Hsla, KeybindingKeystroke, Keystroke}; /// use ui::prelude::*; /// use ui::{KeyBinding, KeybindingHint}; - /// use settings::KeybindSource; /// /// # fn example(cx: &App) { /// let hint = KeybindingHint::with_prefix( /// "Copy:", - /// KeyBinding::from_keystrokes(vec![KeybindingKeystroke::from_keystroke(Keystroke::parse("ctrl-c").unwrap())].into(), KeybindSource::Base), + /// KeyBinding::from_keystrokes(vec![KeybindingKeystroke::from_keystroke(Keystroke::parse("ctrl-c").unwrap())].into(), false), /// Hsla::black() /// ); /// # } @@ -111,11 +108,10 @@ impl KeybindingHint { /// use gpui::{App, Hsla, KeybindingKeystroke, Keystroke}; /// use ui::prelude::*; /// use ui::{KeyBinding, KeybindingHint}; - /// use settings::KeybindSource; /// /// # fn example(cx: &App) { /// let hint = KeybindingHint::with_suffix( - /// KeyBinding::from_keystrokes(vec![KeybindingKeystroke::from_keystroke(Keystroke::parse("ctrl-v").unwrap())].into(), KeybindSource::Base), + /// KeyBinding::from_keystrokes(vec![KeybindingKeystroke::from_keystroke(Keystroke::parse("ctrl-v").unwrap())].into(), false), /// "Paste", /// Hsla::black() /// ); @@ -145,11 +141,10 @@ impl KeybindingHint { /// use gpui::{App, Hsla, KeybindingKeystroke, Keystroke}; /// use ui::prelude::*; /// use ui::{KeyBinding, KeybindingHint}; - /// use settings::KeybindSource; /// /// # fn example(cx: &App) { /// let hint = KeybindingHint::new( - /// KeyBinding::from_keystrokes(vec![KeybindingKeystroke::from_keystroke(Keystroke::parse("ctrl-x").unwrap())].into(), KeybindSource::Base), + /// KeyBinding::from_keystrokes(vec![KeybindingKeystroke::from_keystroke(Keystroke::parse("ctrl-x").unwrap())].into(), false), /// Hsla::black() /// ) /// .prefix("Cut:"); @@ -170,11 +165,10 @@ impl KeybindingHint { /// use gpui::{App, Hsla, KeybindingKeystroke, Keystroke}; /// use ui::prelude::*; /// use ui::{KeyBinding, KeybindingHint}; - /// use settings::KeybindSource; /// /// # fn example(cx: &App) { /// let hint = KeybindingHint::new( - /// KeyBinding::from_keystrokes(vec![KeybindingKeystroke::from_keystroke(Keystroke::parse("ctrl-f").unwrap())].into(), KeybindSource::Base), + /// KeyBinding::from_keystrokes(vec![KeybindingKeystroke::from_keystroke(Keystroke::parse("ctrl-f").unwrap())].into(), false), /// Hsla::black() /// ) /// .suffix("Find"); @@ -195,11 +189,10 @@ impl KeybindingHint { /// use gpui::{App, Hsla, KeybindingKeystroke, Keystroke}; /// use ui::prelude::*; /// use ui::{KeyBinding, KeybindingHint}; - /// use settings::KeybindSource; /// /// # fn example(cx: &App) { /// let hint = KeybindingHint::new( - /// KeyBinding::from_keystrokes(vec![KeybindingKeystroke::from_keystroke(Keystroke::parse("ctrl-z").unwrap())].into(), KeybindSource::Base), + /// KeyBinding::from_keystrokes(vec![KeybindingKeystroke::from_keystroke(Keystroke::parse("ctrl-z").unwrap())].into(), false), /// Hsla::black() /// ) /// .size(Pixels::from(16.0)); diff --git a/crates/ui/src/components/label/highlighted_label.rs b/crates/ui/src/components/label/highlighted_label.rs index 1b10d910dd0ed1501188781622851e720c0ca102..73e03f82dfdef38f10c62b69be3b75da8a24dd08 100644 --- a/crates/ui/src/components/label/highlighted_label.rs +++ b/crates/ui/src/components/label/highlighted_label.rs @@ -29,6 +29,33 @@ impl HighlightedLabel { } } + /// Constructs a label with the given byte ranges highlighted. + /// Assumes that the highlight ranges are valid UTF-8 byte positions. + pub fn from_ranges( + label: impl Into, + highlight_ranges: Vec>, + ) -> Self { + let label = label.into(); + let highlight_indices = highlight_ranges + .iter() + .flat_map(|range| { + let mut indices = Vec::new(); + let mut index = range.start; + while index < range.end { + indices.push(index); + index += label[index..].chars().next().map_or(0, |c| c.len_utf8()); + } + indices + }) + .collect(); + + Self { + base: LabelLike::new(), + label, + highlight_indices, + } + } + pub fn text(&self) -> &str { self.label.as_str() } diff --git a/crates/ui/src/components/label/label_like.rs b/crates/ui/src/components/label/label_like.rs index d87bdf6c12323c4858881f36af62f1a91cdd2aa1..5cad04efcfabcc80648c005f8d18ec5805970a39 100644 --- a/crates/ui/src/components/label/label_like.rs +++ b/crates/ui/src/components/label/label_like.rs @@ -1,8 +1,6 @@ use crate::prelude::*; use gpui::{FontWeight, Rems, StyleRefinement, UnderlineStyle}; -use settings::Settings; use smallvec::SmallVec; -use theme::ThemeSettings; /// Sets the size of a label #[derive(Debug, PartialEq, Clone, Copy, Default)] @@ -191,7 +189,7 @@ impl LabelCommon for LabelLike { } fn buffer_font(mut self, cx: &App) -> Self { - let font = theme::ThemeSettings::get_global(cx).buffer_font.clone(); + let font = theme::theme_settings(cx).buffer_font(cx).clone(); self.weight = Some(font.weight); self.base = self.base.font(font); self @@ -200,7 +198,7 @@ impl LabelCommon for LabelLike { fn inline_code(mut self, cx: &App) -> Self { self.base = self .base - .font(theme::ThemeSettings::get_global(cx).buffer_font.clone()) + .font(theme::theme_settings(cx).buffer_font(cx).clone()) .bg(cx.theme().colors().element_background) .rounded_sm() .px_0p5(); @@ -258,7 +256,7 @@ impl RenderOnce for LabelLike { .text_color(color) .font_weight( self.weight - .unwrap_or(ThemeSettings::get_global(cx).ui_font.weight), + .unwrap_or(theme::theme_settings(cx).ui_font(cx).weight), ) .children(self.children) } diff --git a/crates/ui/src/components/list/list_header.rs b/crates/ui/src/components/list/list_header.rs index 8726dca50dada193b3051f14b6609a373fc60730..9d72366c3be4907c7d4e9e3dc0466903cbc58069 100644 --- a/crates/ui/src/components/list/list_header.rs +++ b/crates/ui/src/components/list/list_header.rs @@ -3,8 +3,7 @@ use std::sync::Arc; use crate::{Disclosure, prelude::*}; use component::{Component, ComponentScope, example_group_with_title, single_example}; use gpui::{AnyElement, ClickEvent}; -use settings::Settings; -use theme::ThemeSettings; +use theme::UiDensity; #[derive(IntoElement, RegisterComponent)] pub struct ListHeader { @@ -81,7 +80,7 @@ impl Toggleable for ListHeader { impl RenderOnce for ListHeader { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - let ui_density = ThemeSettings::get_global(cx).ui_density; + let ui_density = theme::theme_settings(cx).ui_density(cx); h_flex() .id(self.label.clone()) @@ -91,7 +90,7 @@ impl RenderOnce for ListHeader { .child( div() .map(|this| match ui_density { - theme::UiDensity::Comfortable => this.h_5(), + UiDensity::Comfortable => this.h_5(), _ => this.h_7(), }) .when(self.inset, |this| this.px_2()) diff --git a/crates/ui/src/components/scrollbar.rs b/crates/ui/src/components/scrollbar.rs index d0c720d5081d3ab7ad700df798b931933e03db28..86f5e3b4ccbe80dd340cbeafb52ed499bb79895a 100644 --- a/crates/ui/src/components/scrollbar.rs +++ b/crates/ui/src/components/scrollbar.rs @@ -15,10 +15,9 @@ use gpui::{ UniformListScrollHandle, Window, ease_in_out, prelude::FluentBuilder as _, px, quad, relative, size, }; -use settings::SettingsStore; +use gpui_util::ResultExt; use smallvec::SmallVec; use theme::ActiveTheme as _; -use util::ResultExt; use std::ops::Range; @@ -34,7 +33,6 @@ pub mod scrollbars { use gpui::{App, Global}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; - use settings::Settings; /// When to show the scrollbar in the editor. /// @@ -54,28 +52,7 @@ pub mod scrollbars { Never, } - impl From for ShowScrollbar { - fn from(value: settings::ShowScrollbar) -> Self { - match value { - settings::ShowScrollbar::Auto => ShowScrollbar::Auto, - settings::ShowScrollbar::System => ShowScrollbar::System, - settings::ShowScrollbar::Always => ShowScrollbar::Always, - settings::ShowScrollbar::Never => ShowScrollbar::Never, - } - } - } - - pub trait GlobalSetting { - fn get_value(cx: &App) -> &Self; - } - - impl GlobalSetting for T { - fn get_value(cx: &App) -> &T { - T::get_global(cx) - } - } - - pub trait ScrollbarVisibility: GlobalSetting + 'static { + pub trait ScrollbarVisibility: 'static { fn visibility(&self, cx: &App) -> ShowScrollbar; } @@ -103,11 +80,9 @@ where let element_id = config.id.take().unwrap_or_else(|| caller_location.into()); let track_color = config.track_color; - let state = window.use_keyed_state(element_id, cx, |window, cx| { + let state = window.use_keyed_state(element_id, cx, |_, cx| { let parent_id = cx.entity_id(); - ScrollbarStateWrapper( - cx.new(|cx| ScrollbarState::new_from_config(config, parent_id, window, cx)), - ) + ScrollbarStateWrapper(cx.new(|cx| ScrollbarState::new_from_config(config, parent_id, cx))) }); state.update(cx, |state, cx| { @@ -399,8 +374,8 @@ impl Scrollbars { Self::new_with_setting(show_along, |_| ShowScrollbar::Always) } - pub fn for_settings() -> Scrollbars { - Scrollbars::new_with_setting(ScrollAxes::Both, |cx| S::get_value(cx).visibility(cx)) + pub fn for_settings() -> Scrollbars { + Scrollbars::new_with_setting(ScrollAxes::Both, |cx| S::default().visibility(cx)) } } @@ -589,6 +564,16 @@ enum ParentHoverEvent { Outside, } +pub fn on_new_scrollbars(cx: &mut App) { + cx.observe_new::(|_, window, cx| { + if let Some(window) = window { + cx.observe_global_in::(window, ScrollbarState::settings_changed) + .detach(); + } + }) + .detach(); +} + /// This is used to ensure notifies within the state do not notify the parent /// unintentionally. struct ScrollbarStateWrapper(Entity>); @@ -611,15 +596,7 @@ struct ScrollbarState { } impl ScrollbarState { - fn new_from_config( - config: Scrollbars, - parent_id: EntityId, - window: &mut Window, - cx: &mut Context, - ) -> Self { - cx.observe_global_in::(window, Self::settings_changed) - .detach(); - + fn new_from_config(config: Scrollbars, parent_id: EntityId, cx: &mut Context) -> Self { let (manually_added, scroll_handle) = match config.scrollable_handle { Handle::Tracked(handle) => (true, handle), Handle::Untracked(func) => (false, func()), diff --git a/crates/ui/src/components/toggle.rs b/crates/ui/src/components/toggle.rs index 86ff1d8eff8691a2610a4a7e2268aaf47502e306..0b1d7884687b5a3f95c1d54d6a357c4425326f58 100644 --- a/crates/ui/src/components/toggle.rs +++ b/crates/ui/src/components/toggle.rs @@ -2,7 +2,6 @@ use gpui::{ AnyElement, AnyView, ClickEvent, ElementId, Hsla, IntoElement, KeybindingKeystroke, Keystroke, Styled, Window, div, hsla, prelude::*, }; -use settings::KeybindSource; use std::{rc::Rc, sync::Arc}; use crate::utils::is_light; @@ -1051,7 +1050,7 @@ impl Component for Switch { Keystroke::parse("cmd-s").unwrap(), )] .into(), - KeybindSource::Base, + false, ))) .into_any_element(), )], diff --git a/crates/ui/src/components/tooltip.rs b/crates/ui/src/components/tooltip.rs index 8b4ff3f73163f38e19da80462e687db3d88efc6f..8124b4ecbafdc6b096e91892741fe774e3ba032f 100644 --- a/crates/ui/src/components/tooltip.rs +++ b/crates/ui/src/components/tooltip.rs @@ -1,12 +1,9 @@ use std::borrow::Borrow; use std::rc::Rc; -use gpui::{Action, AnyElement, AnyView, AppContext, FocusHandle, IntoElement, Render}; -use settings::Settings; -use theme::ThemeSettings; - use crate::prelude::*; use crate::{Color, KeyBinding, Label, LabelSize, StyledExt, h_flex, v_flex}; +use gpui::{Action, AnyElement, AnyView, AppContext, FocusHandle, IntoElement, Render}; #[derive(RegisterComponent)] pub struct Tooltip { @@ -221,7 +218,7 @@ where C: AppContext + Borrow, { let app = (*cx).borrow(); - let ui_font = ThemeSettings::get_global(app).ui_font.clone(); + let ui_font = theme::theme_settings(app).ui_font(app).clone(); // padding to avoid tooltip appearing right below the mouse cursor div().pl_2().pt_2p5().child( diff --git a/crates/ui/src/styles/spacing.rs b/crates/ui/src/styles/spacing.rs index c6629f5d8829b2ebd59a80a2a22c033ab8c389f6..50d5446ebc25826e6c0665e906141d77ba78d584 100644 --- a/crates/ui/src/styles/spacing.rs +++ b/crates/ui/src/styles/spacing.rs @@ -1,6 +1,5 @@ use gpui::{App, Pixels, Rems, px, rems}; -use settings::Settings; -use theme::{ThemeSettings, UiDensity}; +use theme::UiDensity; use ui_macros::derive_dynamic_spacing; // Derives [DynamicSpacing]. See [ui_macros::derive_dynamic_spacing]. @@ -51,5 +50,5 @@ derive_dynamic_spacing![ /// /// Always use [DynamicSpacing] for spacing values. pub fn ui_density(cx: &mut App) -> UiDensity { - ThemeSettings::get_global(cx).ui_density + theme::theme_settings(cx).ui_density(cx) } diff --git a/crates/ui/src/styles/typography.rs b/crates/ui/src/styles/typography.rs index 2bb0b35720be715251bc7c11a139a1fccfaf6035..69790d3d3dae6bbc8728a63af806357a276ed67a 100644 --- a/crates/ui/src/styles/typography.rs +++ b/crates/ui/src/styles/typography.rs @@ -3,8 +3,7 @@ use gpui::{ AnyElement, App, IntoElement, ParentElement, Rems, RenderOnce, SharedString, Styled, Window, div, rems, }; -use settings::Settings; -use theme::{ActiveTheme, ThemeSettings}; +use theme::ActiveTheme; use crate::{Color, rems_from_px}; @@ -12,16 +11,16 @@ use crate::{Color, rems_from_px}; pub trait StyledTypography: Styled + Sized { /// Sets the font family to the buffer font. fn font_buffer(self, cx: &App) -> Self { - let settings = ThemeSettings::get_global(cx); - let buffer_font_family = settings.buffer_font.family.clone(); + let settings = theme::theme_settings(cx); + let buffer_font_family = settings.buffer_font(cx).family.clone(); self.font_family(buffer_font_family) } /// Sets the font family to the UI font. fn font_ui(self, cx: &App) -> Self { - let settings = ThemeSettings::get_global(cx); - let ui_font_family = settings.ui_font.family.clone(); + let settings = theme::theme_settings(cx); + let ui_font_family = settings.ui_font(cx).family.clone(); self.font_family(ui_font_family) } @@ -82,7 +81,7 @@ pub trait StyledTypography: Styled + Sized { /// This should only be used for text that is displayed in a buffer, /// or other places that text needs to match the user's buffer font size. fn text_buffer(self, cx: &App) -> Self { - let settings = ThemeSettings::get_global(cx); + let settings = theme::theme_settings(cx); self.text_size(settings.buffer_font_size(cx)) } } @@ -133,28 +132,28 @@ pub enum TextSize { impl TextSize { /// Returns the text size in rems. pub fn rems(self, cx: &App) -> Rems { - let theme_settings = ThemeSettings::get_global(cx); + let settings = theme::theme_settings(cx); match self { Self::Large => rems_from_px(16.), Self::Default => rems_from_px(14.), Self::Small => rems_from_px(12.), Self::XSmall => rems_from_px(10.), - Self::Ui => rems_from_px(theme_settings.ui_font_size(cx)), - Self::Editor => rems_from_px(theme_settings.buffer_font_size(cx)), + Self::Ui => rems_from_px(settings.ui_font_size(cx)), + Self::Editor => rems_from_px(settings.buffer_font_size(cx)), } } pub fn pixels(self, cx: &App) -> Pixels { - let theme_settings = ThemeSettings::get_global(cx); + let settings = theme::theme_settings(cx); match self { Self::Large => px(16.), Self::Default => px(14.), Self::Small => px(12.), Self::XSmall => px(10.), - Self::Ui => theme_settings.ui_font_size(cx), - Self::Editor => theme_settings.buffer_font_size(cx), + Self::Ui => settings.ui_font_size(cx), + Self::Editor => settings.buffer_font_size(cx), } } } @@ -212,7 +211,7 @@ pub struct Headline { impl RenderOnce for Headline { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - let ui_font = ThemeSettings::get_global(cx).ui_font.clone(); + let ui_font = theme::theme_settings(cx).ui_font(cx).clone(); div() .font(ui_font) diff --git a/crates/ui/src/utils.rs b/crates/ui/src/utils.rs index 2f2a148e1985d026371c96297eb92cc4ec079a3b..d88bf4a45e0b54536b6f5ca5ad4ae7c7fe936937 100644 --- a/crates/ui/src/utils.rs +++ b/crates/ui/src/utils.rs @@ -34,3 +34,25 @@ pub fn reveal_in_file_manager_label(is_remote: bool) -> &'static str { "Reveal in File Manager" } } + +/// Capitalizes the first character of a string. +/// +/// This function takes a string slice as input and returns a new `String` with the first character +/// capitalized. +/// +/// # Examples +/// +/// ``` +/// use ui::utils::capitalize; +/// +/// assert_eq!(capitalize("hello"), "Hello"); +/// assert_eq!(capitalize("WORLD"), "WORLD"); +/// assert_eq!(capitalize(""), ""); +/// ``` +pub fn capitalize(str: &str) -> String { + let mut chars = str.chars(); + match chars.next() { + None => String::new(), + Some(first_char) => first_char.to_uppercase().collect::() + chars.as_str(), + } +} diff --git a/crates/ui_macros/src/dynamic_spacing.rs b/crates/ui_macros/src/dynamic_spacing.rs index 15ba3e241ec43d02b83e4143eb620505a0a2f02e..f1207f5487a89f0afbd23e620da4c4cf4172be9a 100644 --- a/crates/ui_macros/src/dynamic_spacing.rs +++ b/crates/ui_macros/src/dynamic_spacing.rs @@ -65,7 +65,7 @@ pub fn derive_spacing(input: TokenStream) -> TokenStream { DynamicSpacingValue::Single(n) => { let n = n.base10_parse::().unwrap(); quote! { - DynamicSpacing::#variant => match ThemeSettings::get_global(cx).ui_density { + DynamicSpacing::#variant => match ::theme::theme_settings(cx).ui_density(cx) { ::theme::UiDensity::Compact => (#n - 4.0).max(0.0) / BASE_REM_SIZE_IN_PX, ::theme::UiDensity::Default => #n / BASE_REM_SIZE_IN_PX, ::theme::UiDensity::Comfortable => (#n + 4.0) / BASE_REM_SIZE_IN_PX, @@ -77,7 +77,7 @@ pub fn derive_spacing(input: TokenStream) -> TokenStream { let b = b.base10_parse::().unwrap(); let c = c.base10_parse::().unwrap(); quote! { - DynamicSpacing::#variant => match ThemeSettings::get_global(cx).ui_density { + DynamicSpacing::#variant => match ::theme::theme_settings(cx).ui_density(cx) { ::theme::UiDensity::Compact => #a / BASE_REM_SIZE_IN_PX, ::theme::UiDensity::Default => #b / BASE_REM_SIZE_IN_PX, ::theme::UiDensity::Comfortable => #c / BASE_REM_SIZE_IN_PX, @@ -157,7 +157,7 @@ pub fn derive_spacing(input: TokenStream) -> TokenStream { /// Returns the spacing value in pixels. pub fn px(&self, cx: &App) -> Pixels { - let ui_font_size_f32: f32 = ThemeSettings::get_global(cx).ui_font_size(cx).into(); + let ui_font_size_f32: f32 = ::theme::theme_settings(cx).ui_font_size(cx).into(); px(ui_font_size_f32 * self.spacing_ratio(cx)) } } diff --git a/crates/ui_prompt/Cargo.toml b/crates/ui_prompt/Cargo.toml index 55a98288433a7b31507310e20c4209a9d419e45f..9bcce107f3f7d6bd95ebddf6d33c4a9a29ec4493 100644 --- a/crates/ui_prompt/Cargo.toml +++ b/crates/ui_prompt/Cargo.toml @@ -19,6 +19,6 @@ gpui.workspace = true markdown.workspace = true menu.workspace = true settings.workspace = true -theme.workspace = true +theme_settings.workspace = true ui.workspace = true workspace.workspace = true diff --git a/crates/ui_prompt/src/ui_prompt.rs b/crates/ui_prompt/src/ui_prompt.rs index 3b2716fd92ea7889668767d66e47e5c43792f39e..92b1c9e74dcd2f7e227f5c325ea5defb0d9c8ed3 100644 --- a/crates/ui_prompt/src/ui_prompt.rs +++ b/crates/ui_prompt/src/ui_prompt.rs @@ -5,7 +5,7 @@ use gpui::{ }; use markdown::{Markdown, MarkdownElement, MarkdownStyle}; use settings::{Settings, SettingsStore}; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{FluentBuilder, TintColor, prelude::*}; use workspace::WorkspaceSettings; diff --git a/crates/util/src/path_list.rs b/crates/util/src/path_list.rs index bd012e43dd0c073d78822a5e831af1d78503e8ab..0ea8bce6face2c248239c92e43a14ed010fb0c6e 100644 --- a/crates/util/src/path_list.rs +++ b/crates/util/src/path_list.rs @@ -1,4 +1,5 @@ use std::{ + hash::{Hash, Hasher}, path::{Path, PathBuf}, sync::Arc, }; @@ -7,13 +8,13 @@ use crate::paths::SanitizedPath; use itertools::Itertools; use serde::{Deserialize, Serialize}; -/// A list of absolute paths, in a specific order. +/// A list of absolute paths, with an associated display order. /// -/// The paths are stored in lexicographic order, so that they can be compared to -/// other path lists without regard to the order of the paths. +/// Two `PathList` values are considered equal if they contain the same paths, +/// regardless of the order in which those paths were originally provided. /// /// The paths can be retrieved in the original order using `ordered_paths()`. -#[derive(Default, PartialEq, Eq, Hash, Debug, Clone)] +#[derive(Default, Debug, Clone)] pub struct PathList { /// The paths, in lexicographic order. paths: Arc<[PathBuf]>, @@ -23,6 +24,20 @@ pub struct PathList { order: Arc<[usize]>, } +impl PartialEq for PathList { + fn eq(&self, other: &Self) -> bool { + self.paths == other.paths + } +} + +impl Eq for PathList {} + +impl Hash for PathList { + fn hash(&self, state: &mut H) { + self.paths.hash(state); + } +} + #[derive(Debug, Serialize, Deserialize)] pub struct SerializedPathList { pub paths: String, @@ -55,6 +70,11 @@ impl PathList { self.paths.as_ref() } + /// Get the paths in the lexicographic order. + pub fn paths_owned(&self) -> Arc<[PathBuf]> { + self.paths.clone() + } + /// Get the order in which the paths were provided. pub fn order(&self) -> &[usize] { self.order.as_ref() @@ -132,6 +152,12 @@ mod tests { assert_eq!(list1.order(), &[1, 0], "list1 order incorrect"); assert_eq!(list2.order(), &[0, 1], "list2 order incorrect"); + // Same paths in different order are equal (order is display-only). + assert_eq!( + list1, list2, + "same paths with different order should be equal" + ); + let list1_deserialized = PathList::deserialize(&list1.serialize()); assert_eq!(list1_deserialized, list1, "list1 deserialization failed"); diff --git a/crates/util/src/util.rs b/crates/util/src/util.rs index 4f129ef6d529aff0991b86882e5e60b6ad837d5c..bd8ab4e2d4d99864c5e0dc228410904f3338d7c6 100644 --- a/crates/util/src/util.rs +++ b/crates/util/src/util.rs @@ -686,28 +686,6 @@ impl PartialOrd for NumericPrefixWithSuffix<'_> { } } -/// Capitalizes the first character of a string. -/// -/// This function takes a string slice as input and returns a new `String` with the first character -/// capitalized. -/// -/// # Examples -/// -/// ``` -/// use util::capitalize; -/// -/// assert_eq!(capitalize("hello"), "Hello"); -/// assert_eq!(capitalize("WORLD"), "WORLD"); -/// assert_eq!(capitalize(""), ""); -/// ``` -pub fn capitalize(str: &str) -> String { - let mut chars = str.chars(); - match chars.next() { - None => String::new(), - Some(first_char) => first_char.to_uppercase().collect::() + chars.as_str(), - } -} - fn emoji_regex() -> &'static Regex { static EMOJI_REGEX: LazyLock = LazyLock::new(|| Regex::new("(\\p{Emoji}|\u{200D})").unwrap()); diff --git a/crates/vim/Cargo.toml b/crates/vim/Cargo.toml index 7b4cff5ff9bdf37666076c403593c45131a63067..64282953a33312b85cc1e7cf21076b0cb61dccab 100644 --- a/crates/vim/Cargo.toml +++ b/crates/vim/Cargo.toml @@ -44,6 +44,7 @@ settings.workspace = true task.workspace = true text.workspace = true theme.workspace = true +theme_settings.workspace = true menu.workspace = true tokio = { version = "1.15", features = ["full"], optional = true } ui.workspace = true diff --git a/crates/vim/src/helix.rs b/crates/vim/src/helix.rs index 0db3b5a3fe533f9e21503c6904ee0f62764003fb..c1e766c03a897facb3c7acf76b3ef7811e6910a8 100644 --- a/crates/vim/src/helix.rs +++ b/crates/vim/src/helix.rs @@ -711,38 +711,28 @@ impl Vim { let display_map = editor.display_snapshot(cx); let selections = editor.selections.all_display(&display_map); - // Store selection info for positioning after edit - let selection_info: Vec<_> = selections - .iter() - .map(|selection| { - let range = selection.range(); - let start_offset = range.start.to_offset(&display_map, Bias::Left); - let end_offset = range.end.to_offset(&display_map, Bias::Left); - let was_empty = range.is_empty(); - let was_reversed = selection.reversed; - ( - display_map.buffer_snapshot().anchor_before(start_offset), - end_offset - start_offset, - was_empty, - was_reversed, - ) - }) - .collect(); - let mut edits = Vec::new(); + let mut selection_info = Vec::new(); for selection in &selections { let mut range = selection.range(); + let was_empty = range.is_empty(); + let was_reversed = selection.reversed; - // For empty selections, extend to replace one character - if range.is_empty() { + if was_empty { range.end = movement::saturating_right(&display_map, range.start); } let byte_range = range.start.to_offset(&display_map, Bias::Left) ..range.end.to_offset(&display_map, Bias::Left); + let snapshot = display_map.buffer_snapshot(); + let grapheme_count = snapshot.grapheme_count_for_range(&byte_range); + let anchor = snapshot.anchor_before(byte_range.start); + + selection_info.push((anchor, grapheme_count, was_empty, was_reversed)); + if !byte_range.is_empty() { - let replacement_text = text.repeat(byte_range.end - byte_range.start); + let replacement_text = text.repeat(grapheme_count); edits.push((byte_range, replacement_text)); } } @@ -753,14 +743,12 @@ impl Vim { let snapshot = editor.buffer().read(cx).snapshot(cx); let ranges: Vec<_> = selection_info .into_iter() - .map(|(start_anchor, original_len, was_empty, was_reversed)| { + .map(|(start_anchor, grapheme_count, was_empty, was_reversed)| { let start_point = start_anchor.to_point(&snapshot); if was_empty { - // For cursor-only, collapse to start start_point..start_point } else { - // For selections, span the replaced text - let replacement_len = text.len() * original_len; + let replacement_len = text.len() * grapheme_count; let end_offset = start_anchor.to_offset(&snapshot) + replacement_len; let end_point = snapshot.offset_to_point(end_offset); if was_reversed { @@ -1910,6 +1898,91 @@ mod test { ); } + #[gpui::test] + async fn test_helix_insert_before_after_select_lines(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.set_state( + "line one\nline ˇtwo\nline three\nline four", + Mode::HelixNormal, + ); + cx.simulate_keystrokes("2 x"); + cx.assert_state( + "line one\n«line two\nline three\nˇ»line four", + Mode::HelixNormal, + ); + cx.simulate_keystrokes("o"); + cx.assert_state("line one\nline two\nline three\nˇ\nline four", Mode::Insert); + + cx.set_state( + "line one\nline ˇtwo\nline three\nline four", + Mode::HelixNormal, + ); + cx.simulate_keystrokes("2 x"); + cx.assert_state( + "line one\n«line two\nline three\nˇ»line four", + Mode::HelixNormal, + ); + cx.simulate_keystrokes("shift-o"); + cx.assert_state("line one\nˇ\nline two\nline three\nline four", Mode::Insert); + } + + #[gpui::test] + async fn test_helix_insert_before_after_helix_select(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); + + // Test new line in selection direction + cx.set_state( + "ˇline one\nline two\nline three\nline four", + Mode::HelixNormal, + ); + cx.simulate_keystrokes("v j j"); + cx.assert_state( + "«line one\nline two\nlˇ»ine three\nline four", + Mode::HelixSelect, + ); + cx.simulate_keystrokes("o"); + cx.assert_state("line one\nline two\nline three\nˇ\nline four", Mode::Insert); + + cx.set_state( + "line one\nline two\nˇline three\nline four", + Mode::HelixNormal, + ); + cx.simulate_keystrokes("v k k"); + cx.assert_state( + "«ˇline one\nline two\nl»ine three\nline four", + Mode::HelixSelect, + ); + cx.simulate_keystrokes("shift-o"); + cx.assert_state("ˇ\nline one\nline two\nline three\nline four", Mode::Insert); + + // Test new line in opposite selection direction + cx.set_state( + "ˇline one\nline two\nline three\nline four", + Mode::HelixNormal, + ); + cx.simulate_keystrokes("v j j"); + cx.assert_state( + "«line one\nline two\nlˇ»ine three\nline four", + Mode::HelixSelect, + ); + cx.simulate_keystrokes("shift-o"); + cx.assert_state("ˇ\nline one\nline two\nline three\nline four", Mode::Insert); + + cx.set_state( + "line one\nline two\nˇline three\nline four", + Mode::HelixNormal, + ); + cx.simulate_keystrokes("v k k"); + cx.assert_state( + "«ˇline one\nline two\nl»ine three\nline four", + Mode::HelixSelect, + ); + cx.simulate_keystrokes("o"); + cx.assert_state("line one\nline two\nline three\nˇ\nline four", Mode::Insert); + } + #[gpui::test] async fn test_helix_select_mode_motion(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; @@ -2375,4 +2448,22 @@ mod test { Mode::Insert, ); } + + #[gpui::test] + async fn test_helix_replace_uses_graphemes(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); + + cx.set_state("«Hällöˇ» Wörld", Mode::HelixNormal); + cx.simulate_keystrokes("r 1"); + cx.assert_state("«11111ˇ» Wörld", Mode::HelixNormal); + + cx.set_state("«e\u{301}ˇ»", Mode::HelixNormal); + cx.simulate_keystrokes("r 1"); + cx.assert_state("«1ˇ»", Mode::HelixNormal); + + cx.set_state("«🙂ˇ»", Mode::HelixNormal); + cx.simulate_keystrokes("r 1"); + cx.assert_state("«1ˇ»", Mode::HelixNormal); + } } diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index 6763c5cddb8bf2cda6aa4fa0988ff6be67119d3c..118805586118e36269a1f0c1d1d619058133da30 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -731,10 +731,10 @@ impl Vim { .collect::>(); editor.edit_with_autoindent(edits, cx); editor.change_selections(Default::default(), window, cx, |s| { - s.move_cursors_with(&mut |map, cursor, _| { - let previous_line = map.start_of_relative_buffer_row(cursor, -1); + s.move_with(&mut |map, selection| { + let previous_line = map.start_of_relative_buffer_row(selection.start, -1); let insert_point = motion::end_of_line(map, false, previous_line, 1); - (insert_point, SelectionGoal::None) + selection.collapse_to(insert_point, SelectionGoal::None) }); }); }); @@ -750,14 +750,19 @@ impl Vim { self.start_recording(cx); self.switch_mode(Mode::Insert, false, window, cx); self.update_editor(cx, |_, editor, cx| { - let text_layout_details = editor.text_layout_details(window, cx); editor.transact(window, cx, |editor, window, cx| { let selections = editor.selections.all::(&editor.display_snapshot(cx)); let snapshot = editor.buffer().read(cx).snapshot(cx); let selection_end_rows: BTreeSet = selections .into_iter() - .map(|selection| selection.end.row) + .map(|selection| { + if !selection.is_empty() && selection.end.column == 0 { + selection.end.row.saturating_sub(1) + } else { + selection.end.row + } + }) .collect(); let edits = selection_end_rows .into_iter() @@ -772,14 +777,17 @@ impl Vim { }) .collect::>(); editor.change_selections(Default::default(), window, cx, |s| { - s.maybe_move_cursors_with(&mut |map, cursor, goal| { - Motion::CurrentLine.move_point( - map, - cursor, - goal, - None, - &text_layout_details, - ) + s.move_with(&mut |map, selection| { + let current_line = if !selection.is_empty() && selection.end.column() == 0 { + // If this is an insert after a selection to the end of the line, the + // cursor needs to be bumped back, because it'll be at the start of the + // *next* line. + map.start_of_relative_buffer_row(selection.end, -1) + } else { + selection.end + }; + let insert_point = motion::end_of_line(map, false, current_line, 1); + selection.collapse_to(insert_point, SelectionGoal::None) }); }); editor.edit_with_autoindent(edits, cx); diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index 85bc6991d3ece282b6dd549925c13c94b7919eef..2ae4abe33a0fbb4bc6f8a838e60dc0857949e0dc 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -18,6 +18,7 @@ use gpui::{ EntityId, Global, HighlightStyle, StyledText, Subscription, Task, TextStyle, WeakEntity, }; use language::{Buffer, BufferEvent, BufferId, Chunk, Point}; + use multi_buffer::MultiBufferRow; use picker::{Picker, PickerDelegate}; use project::{Project, ProjectItem, ProjectPath}; @@ -28,7 +29,7 @@ use std::collections::HashSet; use std::path::Path; use std::{fmt::Display, ops::Range, sync::Arc}; use text::{Bias, ToPoint}; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ ActiveTheme, Context, Div, FluentBuilder, KeyBinding, ParentElement, SharedString, Styled, StyledTypography, Window, h_flex, rems, @@ -1402,8 +1403,8 @@ impl MarksMatchInfo { let mut offset = 0; for chunk in chunks { line.push_str(chunk.text); - if let Some(highlight_style) = chunk.syntax_highlight_id - && let Some(highlight) = highlight_style.style(cx.theme().syntax()) + if let Some(highlight_id) = chunk.syntax_highlight_id + && let Some(highlight) = cx.theme().syntax().get(highlight_id).cloned() { highlights.push((offset..offset + chunk.text.len(), highlight)) } diff --git a/crates/vim/src/test/vim_test_context.rs b/crates/vim/src/test/vim_test_context.rs index d8574bb1b76b707fe9d36545ea054480cf097d64..510d218df050455d0df0f9c2b7b782a651694cd7 100644 --- a/crates/vim/src/test/vim_test_context.rs +++ b/crates/vim/src/test/vim_test_context.rs @@ -27,7 +27,7 @@ impl VimTestContext { git_ui::init(cx); crate::init(cx); search::init(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); settings_ui::init(cx); markdown_preview::init(cx); zed_actions::init(); diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index 9261abaf5d896944655018df5a70782759f7fcfa..eb2248ded0e574ca0d30206237694d50bc7f152e 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -51,7 +51,7 @@ pub use settings::{ use state::{Mode, Operator, RecordedSelection, SearchState, VimGlobals}; use std::{mem, ops::Range, sync::Arc}; use surrounds::SurroundsType; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{IntoElement, SharedString, px}; use vim_mode_setting::HelixModeSetting; use vim_mode_setting::VimModeSetting; @@ -601,9 +601,11 @@ impl Vim { } let mut was_enabled = Vim::enabled(cx); + let mut was_helix_enabled = HelixModeSetting::get_global(cx).0; let mut was_toggle = VimSettings::get_global(cx).toggle_relative_line_numbers; cx.observe_global_in::(window, move |editor, window, cx| { let enabled = Vim::enabled(cx); + let helix_enabled = HelixModeSetting::get_global(cx).0; let toggle = VimSettings::get_global(cx).toggle_relative_line_numbers; if enabled && was_enabled && (toggle != was_toggle) { if toggle { @@ -615,15 +617,20 @@ impl Vim { editor.set_relative_line_number(None, cx) } } - was_toggle = VimSettings::get_global(cx).toggle_relative_line_numbers; - if was_enabled == enabled { + let helix_changed = was_helix_enabled != helix_enabled; + was_toggle = toggle; + was_helix_enabled = helix_enabled; + + let state_changed = (was_enabled != enabled) || (was_enabled && helix_changed); + if !state_changed { return; } + if was_enabled { + Self::deactivate(editor, cx); + } was_enabled = enabled; if enabled { - Self::activate(editor, window, cx) - } else { - Self::deactivate(editor, cx) + Self::activate(editor, window, cx); } }) .detach(); @@ -1203,7 +1210,7 @@ impl Vim { return; } - if !mode.is_visual() && last_mode.is_visual() { + if !mode.is_visual() && last_mode.is_visual() && !last_mode.is_helix() { self.create_visual_marks(last_mode, window, cx); } @@ -1270,7 +1277,7 @@ impl Vim { } s.move_with(&mut |map, selection| { - if last_mode.is_visual() && !mode.is_visual() { + if last_mode.is_visual() && !last_mode.is_helix() && !mode.is_visual() { let mut point = selection.head(); if !selection.reversed && !selection.is_empty() { point = movement::left(map, selection.head()); diff --git a/crates/vim/src/visual.rs b/crates/vim/src/visual.rs index 502aa756b67889b1171464fde11be08ff0ccd508..bc53167b158d26717b1aa629b764a78dfe4c0ddc 100644 --- a/crates/vim/src/visual.rs +++ b/crates/vim/src/visual.rs @@ -788,7 +788,10 @@ impl Vim { { let range = row_range.start.to_offset(&display_map, Bias::Right) ..row_range.end.to_offset(&display_map, Bias::Right); - let text = text.repeat(range.end - range.start); + let grapheme_count = display_map + .buffer_snapshot() + .grapheme_count_for_range(&range); + let text = text.repeat(grapheme_count); edits.push((range, text)); } } @@ -2017,4 +2020,21 @@ mod test { // would depend on the key bindings configured, but the actions // are now available for use } + + #[gpui::test] + async fn test_visual_replace_uses_graphemes(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.set_state("«Hällöˇ» Wörld", Mode::Visual); + cx.simulate_keystrokes("r 1"); + cx.assert_state("ˇ11111 Wörld", Mode::Normal); + + cx.set_state("«e\u{301}ˇ»", Mode::Visual); + cx.simulate_keystrokes("r 1"); + cx.assert_state("ˇ1", Mode::Normal); + + cx.set_state("«🙂ˇ»", Mode::Visual); + cx.simulate_keystrokes("r 1"); + cx.assert_state("ˇ1", Mode::Normal); + } } diff --git a/crates/which_key/Cargo.toml b/crates/which_key/Cargo.toml index f53ba45dd71abc972ce23efb8871f485dfe47207..cafcc2306b89d805f3e02b70060e4bb23b3436ff 100644 --- a/crates/which_key/Cargo.toml +++ b/crates/which_key/Cargo.toml @@ -17,7 +17,7 @@ command_palette.workspace = true gpui.workspace = true serde.workspace = true settings.workspace = true -theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true diff --git a/crates/which_key/src/which_key_modal.rs b/crates/which_key/src/which_key_modal.rs index 238431b90a8eafdd0e085a3f109e8f812fbe709b..38b99207ea693b0cfc4113c4d4a4d70940090014 100644 --- a/crates/which_key/src/which_key_modal.rs +++ b/crates/which_key/src/which_key_modal.rs @@ -7,7 +7,7 @@ use gpui::{ }; use settings::Settings; use std::collections::HashMap; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ Divider, DividerColor, DynamicSpacing, LabelSize, WithScrollbar, prelude::*, text_for_keystrokes, diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index d5e9400353eee50b3c5734a31684abdb0149caa0..42e64504f348a727d17d2538d06556497fba54df 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -27,6 +27,7 @@ test-support = [ [dependencies] any_vec.workspace = true +agent_settings.workspace = true anyhow.workspace = true async-recursion.workspace = true client.workspace = true @@ -62,6 +63,7 @@ strum.workspace = true task.workspace = true telemetry.workspace = true theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true uuid.workspace = true diff --git a/crates/workspace/src/active_file_name.rs b/crates/workspace/src/active_file_name.rs new file mode 100644 index 0000000000000000000000000000000000000000..f35312d529423c4dc81bb71dc585c99169afdd39 --- /dev/null +++ b/crates/workspace/src/active_file_name.rs @@ -0,0 +1,69 @@ +use gpui::{ + Context, Empty, EventEmitter, IntoElement, ParentElement, Render, SharedString, Window, +}; +use settings::Settings; +use ui::{Button, Tooltip, prelude::*}; +use util::paths::PathStyle; + +use crate::{StatusItemView, item::ItemHandle, workspace_settings::StatusBarSettings}; + +pub struct ActiveFileName { + project_path: Option, + full_path: Option, +} + +impl ActiveFileName { + pub fn new() -> Self { + Self { + project_path: None, + full_path: None, + } + } +} + +impl Render for ActiveFileName { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + if !StatusBarSettings::get_global(cx).show_active_file { + return Empty.into_any_element(); + } + + let Some(project_path) = self.project_path.clone() else { + return Empty.into_any_element(); + }; + + let tooltip_text = self + .full_path + .clone() + .unwrap_or_else(|| project_path.clone()); + + div() + .child( + Button::new("active-file-name-button", project_path) + .label_size(LabelSize::Small) + .tooltip(Tooltip::text(tooltip_text)), + ) + .into_any_element() + } +} + +impl EventEmitter for ActiveFileName {} + +impl StatusItemView for ActiveFileName { + fn set_active_pane_item( + &mut self, + active_pane_item: Option<&dyn ItemHandle>, + _window: &mut Window, + cx: &mut Context, + ) { + if let Some(item) = active_pane_item { + self.project_path = item + .project_path(cx) + .map(|path| path.path.display(PathStyle::local()).into_owned().into()); + self.full_path = item.tab_tooltip_text(cx); + } else { + self.project_path = None; + self.full_path = None; + } + cx.notify(); + } +} diff --git a/crates/workspace/src/dock.rs b/crates/workspace/src/dock.rs index 44a24f687a49552f707d968e82d19387b74b0ac1..567d88228e317d837dbd5e5cc2b9235c63b500d9 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 db::kvp::KeyValueStore; use gpui::{ Action, AnyView, App, Axis, Context, Corner, Entity, EntityId, EventEmitter, FocusHandle, @@ -10,6 +11,7 @@ use gpui::{ Render, SharedString, StyleRefinement, Styled, Subscription, WeakEntity, Window, deferred, div, px, }; +use serde::{Deserialize, Serialize}; use settings::SettingsStore; use std::sync::Arc; use ui::{ @@ -35,8 +37,24 @@ pub trait Panel: Focusable + EventEmitter + Render + Sized { fn position(&self, window: &Window, cx: &App) -> DockPosition; fn position_is_valid(&self, position: DockPosition) -> bool; fn set_position(&mut self, position: DockPosition, window: &mut Window, cx: &mut Context); - fn size(&self, window: &Window, cx: &App) -> Pixels; - fn set_size(&mut self, size: Option, window: &mut Window, cx: &mut Context); + fn default_size(&self, window: &Window, cx: &App) -> Pixels; + fn initial_size_state(&self, _window: &Window, _cx: &App) -> PanelSizeState { + PanelSizeState::default() + } + fn size_state_changed(&mut self, _window: &mut Window, _cx: &mut Context) {} + fn supports_flexible_size(&self) -> bool { + false + } + fn has_flexible_size(&self, _window: &Window, _cx: &App) -> bool { + false + } + fn set_flexible_size( + &mut self, + _flexible: bool, + _window: &mut Window, + _cx: &mut Context, + ) { + } fn icon(&self, window: &Window, cx: &App) -> Option; fn icon_tooltip(&self, window: &Window, cx: &App) -> Option<&'static str>; fn toggle_action(&self) -> Box; @@ -61,6 +79,9 @@ pub trait Panel: Focusable + EventEmitter + Render + Sized { fn enabled(&self, _cx: &App) -> bool { true } + fn is_agent_panel(&self) -> bool { + false + } } pub trait PanelHandle: Send + Sync { @@ -75,8 +96,12 @@ pub trait PanelHandle: Send + Sync { fn set_active(&self, active: bool, window: &mut Window, cx: &mut App); fn remote_id(&self) -> Option; fn pane(&self, cx: &App) -> Option>; - fn size(&self, window: &Window, cx: &App) -> Pixels; - fn set_size(&self, size: Option, window: &mut Window, cx: &mut App); + fn default_size(&self, window: &Window, cx: &App) -> Pixels; + fn initial_size_state(&self, window: &Window, cx: &App) -> PanelSizeState; + fn size_state_changed(&self, window: &mut Window, cx: &mut App); + fn supports_flexible_size(&self, cx: &App) -> bool; + fn has_flexible_size(&self, window: &Window, cx: &App) -> bool; + fn set_flexible_size(&self, flexible: bool, window: &mut Window, cx: &mut App); fn icon(&self, window: &Window, cx: &App) -> Option; fn icon_tooltip(&self, window: &Window, cx: &App) -> Option<&'static str>; fn toggle_action(&self, window: &Window, cx: &App) -> Box; @@ -85,6 +110,7 @@ pub trait PanelHandle: Send + Sync { fn to_any(&self) -> AnyView; fn activation_priority(&self, cx: &App) -> u32; fn enabled(&self, cx: &App) -> bool; + fn is_agent_panel(&self, cx: &App) -> bool; fn move_to_next_position(&self, window: &mut Window, cx: &mut App) { let current_position = self.position(window, cx); let next_position = [ @@ -150,12 +176,28 @@ where T::remote_id() } - fn size(&self, window: &Window, cx: &App) -> Pixels { - self.read(cx).size(window, cx) + fn default_size(&self, window: &Window, cx: &App) -> Pixels { + self.read(cx).default_size(window, cx) } - fn set_size(&self, size: Option, window: &mut Window, cx: &mut App) { - self.update(cx, |this, cx| this.set_size(size, window, cx)) + fn initial_size_state(&self, window: &Window, cx: &App) -> PanelSizeState { + self.read(cx).initial_size_state(window, cx) + } + + fn size_state_changed(&self, window: &mut Window, cx: &mut App) { + self.update(cx, |this, cx| this.size_state_changed(window, cx)) + } + + fn supports_flexible_size(&self, cx: &App) -> bool { + self.read(cx).supports_flexible_size() + } + + fn has_flexible_size(&self, window: &Window, cx: &App) -> bool { + self.read(cx).has_flexible_size(window, cx) + } + + fn set_flexible_size(&self, flexible: bool, window: &mut Window, cx: &mut App) { + self.update(cx, |this, cx| this.set_flexible_size(flexible, window, cx)) } fn icon(&self, window: &Window, cx: &App) -> Option { @@ -189,6 +231,10 @@ where fn enabled(&self, cx: &App) -> bool { self.read(cx).enabled(cx) } + + fn is_agent_panel(&self, cx: &App) -> bool { + self.read(cx).is_agent_panel() + } } impl From<&dyn PanelHandle> for AnyView { @@ -262,8 +308,16 @@ impl DockPosition { } } +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct PanelSizeState { + pub size: Option, + #[serde(default)] + pub flex: Option, +} + struct PanelEntry { panel: Arc, + size_state: PanelSizeState, _subscriptions: [Subscription; 3], } @@ -272,6 +326,8 @@ pub struct PanelButtons { _settings_subscription: Subscription, } +pub(crate) const PANEL_SIZE_STATE_KEY: &str = "dock_panel_size"; + impl Dock { pub fn new( position: DockPosition, @@ -493,20 +549,37 @@ impl Dock { return; }; + let panel_id = Entity::entity_id(&panel); let was_visible = this.is_open() - && this.visible_panel().is_some_and(|active_panel| { - active_panel.panel_id() == Entity::entity_id(&panel) - }); - - this.remove_panel(&panel, window, cx); + && this + .visible_panel() + .is_some_and(|active_panel| active_panel.panel_id() == panel_id); + let size_state = this + .panel_entries + .iter() + .find(|entry| entry.panel.panel_id() == panel_id) + .map(|entry| entry.size_state) + .unwrap_or_default(); + + let previous_axis = this.position.axis(); + let next_axis = new_position.axis(); + let size_state = if previous_axis == next_axis { + size_state + } else { + PanelSizeState::default() + }; - new_dock.update(cx, |new_dock, cx| { - new_dock.remove_panel(&panel, window, cx); - }); + if !this.remove_panel(&panel, window, cx) { + // Panel was already moved from this dock + return; + } new_dock.update(cx, |new_dock, cx| { let index = new_dock.add_panel(panel.clone(), workspace.clone(), window, cx); + if let Some(added_panel) = new_dock.panel_for_id(panel_id).cloned() { + new_dock.set_panel_size_state(added_panel.as_ref(), size_state, cx); + } if was_visible { new_dock.set_open(true, window, cx); new_dock.activate_panel(index, window, cx); @@ -597,10 +670,13 @@ impl Dock { { *active_index += 1; } + let size_state = panel.read(cx).initial_size_state(window, cx); + self.panel_entries.insert( index, PanelEntry { panel: Arc::new(panel.clone()), + size_state, _subscriptions: subscriptions, }, ); @@ -672,6 +748,12 @@ impl Dock { self.panel_entries.len() } + pub fn has_agent_panel(&self, cx: &App) -> bool { + self.panel_entries + .iter() + .any(|entry| entry.panel.is_agent_panel(cx)) + } + pub fn activate_panel(&mut self, panel_ix: usize, window: &mut Window, cx: &mut Context) { if Some(panel_ix) != self.active_panel_index { if let Some(active_panel) = self.active_panel_entry() { @@ -714,32 +796,138 @@ impl Dock { } } - pub fn panel_size(&self, panel: &dyn PanelHandle, window: &Window, cx: &App) -> Option { + pub fn active_panel_size(&self) -> Option { + if self.is_open { + self.active_panel_entry().map(|entry| entry.size_state) + } else { + None + } + } + + pub fn stored_panel_size( + &self, + panel: &dyn PanelHandle, + window: &Window, + cx: &App, + ) -> Option { self.panel_entries .iter() .find(|entry| entry.panel.panel_id() == panel.panel_id()) - .map(|entry| entry.panel.size(window, cx)) + .map(|entry| { + entry + .size_state + .size + .unwrap_or_else(|| entry.panel.default_size(window, cx)) + }) } - pub fn active_panel_size(&self, window: &Window, cx: &App) -> Option { + pub fn stored_panel_size_state(&self, panel: &dyn PanelHandle) -> Option { + self.panel_entries + .iter() + .find(|entry| entry.panel.panel_id() == panel.panel_id()) + .map(|entry| entry.size_state) + } + + pub fn stored_active_panel_size(&self, window: &Window, cx: &App) -> Option { if self.is_open { - self.active_panel_entry() - .map(|entry| entry.panel.size(window, cx)) + self.active_panel_entry().map(|entry| { + entry + .size_state + .size + .unwrap_or_else(|| entry.panel.default_size(window, cx)) + }) } else { None } } + pub fn set_panel_size_state( + &mut self, + panel: &dyn PanelHandle, + size_state: PanelSizeState, + cx: &mut Context, + ) -> bool { + if let Some(entry) = self + .panel_entries + .iter_mut() + .find(|entry| entry.panel.panel_id() == panel.panel_id()) + { + entry.size_state = size_state; + cx.notify(); + true + } else { + false + } + } + + pub fn toggle_panel_flexible_size( + &mut self, + panel: &dyn PanelHandle, + current_size: Option, + current_flex: Option, + window: &mut Window, + cx: &mut Context, + ) { + let Some(entry) = self + .panel_entries + .iter_mut() + .find(|entry| entry.panel.panel_id() == panel.panel_id()) + else { + return; + }; + let currently_flexible = entry.panel.has_flexible_size(window, cx); + if currently_flexible { + entry.size_state.size = current_size; + } else { + entry.size_state.flex = current_flex; + } + let panel_key = entry.panel.panel_key(); + let size_state = entry.size_state; + let workspace = self.workspace.clone(); + entry + .panel + .set_flexible_size(!currently_flexible, window, cx); + entry.panel.size_state_changed(window, cx); + cx.defer(move |cx| { + if let Some(workspace) = workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + workspace.persist_panel_size_state(panel_key, size_state, cx); + }); + } + }); + cx.notify(); + } + pub fn resize_active_panel( &mut self, size: Option, + flex: Option, window: &mut Window, cx: &mut Context, ) { - if let Some(entry) = self.active_panel_entry() { + if let Some(index) = self.active_panel_index + && let Some(entry) = self.panel_entries.get_mut(index) + { let size = size.map(|size| size.max(RESIZE_HANDLE_SIZE).round()); - entry.panel.set_size(size, window, cx); + let use_flex = entry.panel.has_flexible_size(window, cx); + if use_flex { + entry.size_state.flex = flex; + } else { + entry.size_state.size = size; + } + + let panel_key = entry.panel.panel_key(); + let size_state = entry.size_state; + let workspace = self.workspace.clone(); + entry.panel.size_state_changed(window, cx); + cx.defer(move |cx| { + if let Some(workspace) = workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + workspace.persist_panel_size_state(panel_key, size_state, cx); + }); + } + }); cx.notify(); } } @@ -747,13 +935,35 @@ impl Dock { pub fn resize_all_panels( &mut self, size: Option, + flex: Option, window: &mut Window, cx: &mut Context, ) { + let mut size_states_to_persist = Vec::new(); + for entry in &mut self.panel_entries { let size = size.map(|size| size.max(RESIZE_HANDLE_SIZE).round()); - entry.panel.set_size(size, window, cx); + let use_flex = entry.panel.has_flexible_size(window, cx); + if use_flex { + entry.size_state.flex = flex; + } else { + entry.size_state.size = size; + } + entry.panel.size_state_changed(window, cx); + size_states_to_persist.push((entry.panel.panel_key(), entry.size_state)); } + + let workspace = self.workspace.clone(); + cx.defer(move |cx| { + if let Some(workspace) = workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + for (panel_key, size_state) in size_states_to_persist { + workspace.persist_panel_size_state(panel_key, size_state, cx); + } + }); + } + }); + cx.notify(); } @@ -772,22 +982,47 @@ impl Dock { dispatch_context } - pub fn clamp_panel_size(&mut self, max_size: Pixels, window: &mut Window, cx: &mut App) { + pub fn clamp_panel_size(&mut self, max_size: Pixels, window: &Window, cx: &mut App) { let max_size = (max_size - RESIZE_HANDLE_SIZE).abs(); - for panel in self.panel_entries.iter().map(|entry| &entry.panel) { - if panel.size(window, cx) > max_size { - panel.set_size(Some(max_size.max(RESIZE_HANDLE_SIZE)), window, cx); + for entry in &mut self.panel_entries { + let use_flexible = entry.panel.has_flexible_size(window, cx); + if use_flexible { + continue; + } + + let size = entry + .size_state + .size + .unwrap_or_else(|| entry.panel.default_size(window, cx)); + if size > max_size { + entry.size_state.size = Some(max_size.max(RESIZE_HANDLE_SIZE)); } } } + + pub(crate) fn load_persisted_size_state( + workspace: &Workspace, + panel_key: &'static str, + cx: &App, + ) -> Option { + let workspace_id = workspace + .database_id() + .map(|id| i64::from(id).to_string()) + .or(workspace.session_id())?; + let kvp = KeyValueStore::global(cx); + let scope = kvp.scoped(PANEL_SIZE_STATE_KEY); + scope + .read(&format!("{workspace_id}:{panel_key}")) + .log_err() + .flatten() + .and_then(|json| serde_json::from_str::(&json).log_err()) + } } impl Render for Dock { - 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 dispatch_context = Self::dispatch_context(); if let Some(entry) = self.visible_entry() { - let size = entry.panel.size(window, cx); - let position = self.position; let create_resize_handle = || { let handle = div() @@ -806,7 +1041,7 @@ impl Render for Dock { MouseButton::Left, cx.listener(|dock, e: &MouseUpEvent, window, cx| { if e.click_count == 2 { - dock.resize_active_panel(None, window, cx); + dock.resize_active_panel(None, None, window, cx); dock.workspace .update(cx, |workspace, cx| { workspace.serialize_workspace(window, cx); @@ -856,8 +1091,10 @@ impl Render for Dock { .border_color(cx.theme().colors().border) .overflow_hidden() .map(|this| match self.position().axis() { - Axis::Horizontal => this.w(size).h_full().flex_row(), - Axis::Vertical => this.h(size).w_full().flex_col(), + // Width and height are always set on the workspace wrapper in + // render_dock, so fill whatever space the wrapper provides. + Axis::Horizontal => this.w_full().h_full().flex_row(), + Axis::Vertical => this.h_full().w_full().flex_col(), }) .map(|this| match self.position() { DockPosition::Left => this.border_r_1(), @@ -867,8 +1104,8 @@ impl Render for Dock { .child( div() .map(|this| match self.position().axis() { - Axis::Horizontal => this.min_w(size).h_full(), - Axis::Vertical => this.min_h(size).w_full(), + Axis::Horizontal => this.w_full().h_full(), + Axis::Vertical => this.h_full().w_full(), }) .child( entry @@ -911,7 +1148,9 @@ impl Render for PanelButtons { DockPosition::Bottom | DockPosition::Right => (Corner::BottomRight, Corner::TopRight), }; - let buttons: Vec<_> = dock + let dock_entity = self.dock.clone(); + let workspace = dock.workspace.clone(); + let mut buttons: Vec<_> = dock .panel_entries .iter() .enumerate() @@ -926,6 +1165,10 @@ impl Render for PanelButtons { .log_err()?; let name = entry.panel.persistent_name(); let panel = entry.panel.clone(); + let supports_flexible = panel.supports_flexible_size(cx); + let currently_flexible = panel.has_flexible_size(window, cx); + let dock_for_menu = dock_entity.clone(); + let workspace_for_menu = workspace.clone(); let is_active_button = Some(i) == active_index && is_open; let (action, tooltip) = if is_active_button { @@ -954,20 +1197,76 @@ impl Render for PanelButtons { ]; ContextMenu::build(window, cx, |mut menu, _, cx| { + let mut has_position_entries = false; for position in POSITIONS { - if position != dock_position - && panel.position_is_valid(position, cx) - { + if panel.position_is_valid(position, cx) { + let is_current = position == dock_position; let panel = panel.clone(); - menu = menu.entry( + menu = menu.toggleable_entry( format!("Dock {}", position.label()), + is_current, + IconPosition::Start, None, move |window, cx| { - panel.set_position(position, window, cx); + if !is_current { + panel.set_position(position, window, cx); + } }, - ) + ); + has_position_entries = true; } } + if supports_flexible { + if has_position_entries { + menu = menu.separator(); + } + let panel_for_flex = panel.clone(); + let dock_for_flex = dock_for_menu.clone(); + let workspace_for_flex = workspace_for_menu.clone(); + menu = menu.toggleable_entry( + "Flex Width", + currently_flexible, + IconPosition::Start, + None, + move |window, cx| { + if !currently_flexible { + if let Some(ws) = workspace_for_flex.upgrade() { + ws.update(cx, |workspace, cx| { + workspace.toggle_dock_panel_flexible_size( + &dock_for_flex, + panel_for_flex.as_ref(), + window, + cx, + ); + }); + } + } + }, + ); + let panel_for_fixed = panel.clone(); + let dock_for_fixed = dock_for_menu.clone(); + let workspace_for_fixed = workspace_for_menu.clone(); + menu = menu.toggleable_entry( + "Fixed Width", + !currently_flexible, + IconPosition::Start, + None, + move |window, cx| { + if currently_flexible { + if let Some(ws) = workspace_for_fixed.upgrade() { + ws.update(cx, |workspace, cx| { + workspace.toggle_dock_panel_flexible_size( + &dock_for_fixed, + panel_for_fixed.as_ref(), + window, + cx, + ); + }); + } + } + }, + ); + } menu }) }) @@ -1004,12 +1303,18 @@ impl Render for PanelButtons { }) .collect(); + if dock_position == DockPosition::Right { + buttons.reverse(); + } + let has_buttons = !buttons.is_empty(); h_flex() .gap_1() .when( - has_buttons && dock.position == DockPosition::Bottom, + has_buttons + && (dock.position == DockPosition::Bottom + || dock.position == DockPosition::Right), |this| this.child(Divider::vertical().color(DividerColor::Border)), ) .children(buttons) @@ -1040,7 +1345,8 @@ pub mod test { pub zoomed: bool, pub active: bool, pub focus_handle: FocusHandle, - pub size: Pixels, + pub default_size: Pixels, + pub flexible: bool, pub activation_priority: u32, } actions!(test_only, [ToggleTestPanel]); @@ -1054,10 +1360,22 @@ pub mod test { zoomed: false, active: false, focus_handle: cx.focus_handle(), - size: px(300.), + default_size: px(300.), + flexible: false, activation_priority, } } + + pub fn new_flexible( + position: DockPosition, + activation_priority: u32, + cx: &mut App, + ) -> Self { + Self { + flexible: true, + ..Self::new(position, activation_priority, cx) + } + } } impl Render for TestPanel { @@ -1088,12 +1406,32 @@ pub mod test { cx.update_global::(|_, _| {}); } - fn size(&self, _window: &Window, _: &App) -> Pixels { - self.size + fn default_size(&self, _window: &Window, _: &App) -> Pixels { + self.default_size + } + + fn initial_size_state(&self, _window: &Window, _: &App) -> PanelSizeState { + PanelSizeState { + size: None, + flex: None, + } + } + + fn supports_flexible_size(&self) -> bool { + self.flexible + } + + fn has_flexible_size(&self, _window: &Window, _: &App) -> bool { + self.flexible } - fn set_size(&mut self, size: Option, _window: &mut Window, _: &mut Context) { - self.size = size.unwrap_or(px(300.)); + fn set_flexible_size( + &mut self, + flexible: bool, + _window: &mut Window, + _cx: &mut Context, + ) { + self.flexible = flexible; } fn icon(&self, _window: &Window, _: &App) -> Option { diff --git a/crates/workspace/src/item.rs b/crates/workspace/src/item.rs index 8e00753a86d67c819dae38613797ccbeff34edf9..ed104a534eba7707a04a60775ae08820c4f258b8 100644 --- a/crates/workspace/src/item.rs +++ b/crates/workspace/src/item.rs @@ -1583,7 +1583,7 @@ pub mod test { fn push_to_nav_history(&mut self, cx: &mut Context) { if let Some(history) = &mut self.nav_history { - history.push(Some(Box::new(self.state.clone())), cx); + history.push(Some(Box::new(self.state.clone())), None, cx); } } } diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index a65a77e0811af0ac2b14e9582651880ebb8b3af7..b3328c66654dd6e88e9f2ecd6c19b544de65e255 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/crates/workspace/src/multi_workspace.rs @@ -7,16 +7,23 @@ use gpui::{ }; use project::{DisableAiSettings, Project}; use settings::Settings; +pub use settings::SidebarSide; use std::future::Future; use std::path::PathBuf; +use std::sync::Arc; use ui::prelude::*; use util::ResultExt; +use zed_actions::agents_sidebar::{MoveWorkspaceToNewWindow, ToggleThreadSwitcher}; + +use agent_settings::AgentSettings; +use settings::SidebarDockPosition; +use ui::{ContextMenu, right_click_menu}; const SIDEBAR_RESIZE_HANDLE_SIZE: Pixels = px(6.0); use crate::{ - CloseIntent, CloseWindow, DockPosition, Event as WorkspaceEvent, Item, ModalView, Panel, - Workspace, WorkspaceId, client_side_decorations, + CloseIntent, CloseWindow, DockPosition, Event as WorkspaceEvent, Item, ModalView, OpenMode, + Panel, Workspace, WorkspaceId, client_side_decorations, }; actions!( @@ -35,6 +42,46 @@ actions!( ] ); +#[derive(Default)] +pub struct SidebarRenderState { + pub open: bool, + pub side: SidebarSide, +} + +pub fn sidebar_side_context_menu( + id: impl Into, + cx: &App, +) -> ui::RightClickMenu { + let current_position = AgentSettings::get_global(cx).sidebar_side; + right_click_menu(id).menu(move |window, cx| { + let fs = ::global(cx); + ContextMenu::build(window, cx, move |mut menu, _, _cx| { + let positions: [(SidebarDockPosition, &str); 2] = [ + (SidebarDockPosition::Left, "Left"), + (SidebarDockPosition::Right, "Right"), + ]; + for (position, label) in positions { + let fs = fs.clone(); + menu = menu.toggleable_entry( + label, + position == current_position, + IconPosition::Start, + None, + move |_window, cx| { + settings::update_settings_file(fs.clone(), cx, move |settings, _cx| { + settings + .agent + .get_or_insert_default() + .set_sidebar_side(position); + }); + }, + ); + } + menu + }) + }) +} + pub enum MultiWorkspaceEvent { ActiveWorkspaceChanged, WorkspaceAdded(Entity), @@ -45,12 +92,21 @@ pub trait Sidebar: Focusable + Render + Sized { fn width(&self, cx: &App) -> Pixels; fn set_width(&mut self, width: Option, cx: &mut Context); fn has_notifications(&self, cx: &App) -> bool; + fn side(&self, _cx: &App) -> SidebarSide; fn is_threads_list_view_active(&self) -> bool { true } - /// Makes focus reset bac to the search editor upon toggling the sidebar from outside + /// Makes focus reset back to the search editor upon toggling the sidebar from outside fn prepare_for_focus(&mut self, _window: &mut Window, _cx: &mut Context) {} + /// Opens or cycles the thread switcher popup. + fn toggle_thread_switcher( + &mut self, + _select_last: bool, + _window: &mut Window, + _cx: &mut Context, + ) { + } } pub trait SidebarHandle: 'static + Send + Sync { @@ -62,8 +118,11 @@ pub trait SidebarHandle: 'static + Send + Sync { fn has_notifications(&self, cx: &App) -> bool; fn to_any(&self) -> AnyView; fn entity_id(&self) -> EntityId; + fn toggle_thread_switcher(&self, select_last: bool, window: &mut Window, cx: &mut App); fn is_threads_list_view_active(&self, cx: &App) -> bool; + + fn side(&self, cx: &App) -> SidebarSide; } #[derive(Clone)] @@ -109,9 +168,22 @@ impl SidebarHandle for Entity { Entity::entity_id(self) } + fn toggle_thread_switcher(&self, select_last: bool, window: &mut Window, cx: &mut App) { + let entity = self.clone(); + window.defer(cx, move |window, cx| { + entity.update(cx, |this, cx| { + this.toggle_thread_switcher(select_last, window, cx); + }); + }); + } + fn is_threads_list_view_active(&self, cx: &App) -> bool { self.read(cx).is_threads_list_view_active() } + + fn side(&self, cx: &App) -> SidebarSide { + self.read(cx).side(cx) + } } pub struct MultiWorkspace { @@ -120,6 +192,7 @@ pub struct MultiWorkspace { active_workspace_index: usize, sidebar: Option>, sidebar_open: bool, + sidebar_overlay: Option, pending_removal_tasks: Vec>, _serialize_task: Option>, _subscriptions: Vec, @@ -128,6 +201,19 @@ pub struct MultiWorkspace { impl EventEmitter for MultiWorkspace {} impl MultiWorkspace { + pub fn sidebar_side(&self, cx: &App) -> SidebarSide { + self.sidebar + .as_ref() + .map_or(SidebarSide::Left, |s| s.side(cx)) + } + + pub fn sidebar_render_state(&self, cx: &App) -> SidebarRenderState { + SidebarRenderState { + open: self.sidebar_open() && self.multi_workspace_enabled(cx), + side: self.sidebar_side(cx), + } + } + pub fn new(workspace: Entity, window: &mut Window, cx: &mut Context) -> Self { let release_subscription = cx.on_release(|this: &mut MultiWorkspace, _cx| { if let Some(task) = this._serialize_task.take() { @@ -144,13 +230,18 @@ impl MultiWorkspace { this.close_sidebar(window, cx); } }); - Self::subscribe_to_workspace(&workspace, cx); + Self::subscribe_to_workspace(&workspace, window, cx); + let weak_self = cx.weak_entity(); + workspace.update(cx, |workspace, cx| { + workspace.set_multi_workspace(weak_self, cx); + }); Self { window_id: window.window_handle().window_id(), workspaces: vec![workspace], active_workspace_index: 0, sidebar: None, sidebar_open: false, + sidebar_overlay: None, pending_removal_tasks: Vec::new(), _serialize_task: None, _subscriptions: vec![ @@ -163,20 +254,8 @@ impl MultiWorkspace { pub fn register_sidebar(&mut self, sidebar: Entity, cx: &mut Context) { self._subscriptions - .push(cx.observe(&sidebar, |this, _, cx| { - let has_notifications = this.sidebar_has_notifications(cx); - let is_open = this.sidebar_open; - let show_toggle = this.multi_workspace_enabled(cx); - for workspace in &this.workspaces { - workspace.update(cx, |workspace, cx| { - workspace.set_workspace_sidebar_open( - is_open, - has_notifications, - show_toggle, - cx, - ); - }); - } + .push(cx.observe(&sidebar, |_this, _, cx| { + cx.notify(); })); self.sidebar = Some(Box::new(sidebar)); } @@ -185,6 +264,11 @@ impl MultiWorkspace { self.sidebar.as_deref() } + pub fn set_sidebar_overlay(&mut self, overlay: Option, cx: &mut Context) { + self.sidebar_overlay = overlay; + cx.notify(); + } + pub fn sidebar_open(&self) -> bool { self.sidebar_open } @@ -262,11 +346,8 @@ impl MultiWorkspace { pub fn open_sidebar(&mut self, cx: &mut Context) { self.sidebar_open = true; let sidebar_focus_handle = self.sidebar.as_ref().map(|s| s.focus_handle(cx)); - let has_notifications = self.sidebar_has_notifications(cx); - let show_toggle = self.multi_workspace_enabled(cx); for workspace in &self.workspaces { - workspace.update(cx, |workspace, cx| { - workspace.set_workspace_sidebar_open(true, has_notifications, show_toggle, cx); + workspace.update(cx, |workspace, _cx| { workspace.set_sidebar_focus_handle(sidebar_focus_handle.clone()); }); } @@ -274,13 +355,10 @@ impl MultiWorkspace { cx.notify(); } - fn close_sidebar(&mut self, window: &mut Window, cx: &mut Context) { + pub fn close_sidebar(&mut self, window: &mut Window, cx: &mut Context) { self.sidebar_open = false; - let has_notifications = self.sidebar_has_notifications(cx); - let show_toggle = self.multi_workspace_enabled(cx); for workspace in &self.workspaces { - workspace.update(cx, |workspace, cx| { - workspace.set_workspace_sidebar_open(false, has_notifications, show_toggle, cx); + workspace.update(cx, |workspace, _cx| { workspace.set_sidebar_focus_handle(None); }); } @@ -317,10 +395,14 @@ impl MultiWorkspace { .detach_and_log_err(cx); } - fn subscribe_to_workspace(workspace: &Entity, cx: &mut Context) { - cx.subscribe(workspace, |this, workspace, event, cx| { + fn subscribe_to_workspace( + workspace: &Entity, + window: &Window, + cx: &mut Context, + ) { + cx.subscribe_in(workspace, window, |this, workspace, event, window, cx| { if let WorkspaceEvent::Activate = event { - this.activate(workspace, cx); + this.activate(workspace.clone(), window, cx); } }) .detach(); @@ -338,53 +420,107 @@ impl MultiWorkspace { self.active_workspace_index } - pub fn activate(&mut self, workspace: Entity, cx: &mut Context) { + /// Adds a workspace to this window without changing which workspace is + /// active. + pub fn add(&mut self, workspace: Entity, window: &Window, cx: &mut Context) { if !self.multi_workspace_enabled(cx) { - self.workspaces[0] = workspace; - self.active_workspace_index = 0; - cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged); - cx.notify(); + self.set_single_workspace(workspace, cx); return; } - let old_index = self.active_workspace_index; - let new_index = self.set_active_workspace(workspace, cx); - if old_index != new_index { - self.serialize(cx); - } + self.insert_workspace(workspace, window, cx); } - fn set_active_workspace( + /// Ensures the workspace is in the multiworkspace and makes it the active one. + pub fn activate( &mut self, workspace: Entity, + window: &mut Window, cx: &mut Context, - ) -> usize { - let index = self.add_workspace(workspace, cx); + ) { + if !self.multi_workspace_enabled(cx) { + self.set_single_workspace(workspace, cx); + return; + } + + let index = self.insert_workspace(workspace, &*window, cx); let changed = self.active_workspace_index != index; self.active_workspace_index = index; if changed { cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged); + self.serialize(cx); } + self.focus_active_workspace(window, cx); + cx.notify(); + } + + /// Replaces the currently active workspace with a new one. If the + /// workspace is already in the list, this just switches to it. + pub fn replace( + &mut self, + workspace: Entity, + window: &Window, + cx: &mut Context, + ) { + if !self.multi_workspace_enabled(cx) { + self.set_single_workspace(workspace, cx); + return; + } + + if let Some(index) = self.workspaces.iter().position(|w| *w == workspace) { + let changed = self.active_workspace_index != index; + self.active_workspace_index = index; + if changed { + cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged); + self.serialize(cx); + } + cx.notify(); + return; + } + + let old_workspace = std::mem::replace( + &mut self.workspaces[self.active_workspace_index], + workspace.clone(), + ); + + let old_entity_id = old_workspace.entity_id(); + self.detach_workspace(&old_workspace, cx); + + Self::subscribe_to_workspace(&workspace, window, cx); + self.sync_sidebar_to_workspace(&workspace, cx); + + cx.emit(MultiWorkspaceEvent::WorkspaceRemoved(old_entity_id)); + cx.emit(MultiWorkspaceEvent::WorkspaceAdded(workspace)); + cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged); + self.serialize(cx); cx.notify(); - index } - /// Adds a workspace to this window without changing which workspace is active. - /// Returns the index of the workspace (existing or newly inserted). - pub fn add_workspace(&mut self, workspace: Entity, cx: &mut Context) -> usize { + fn set_single_workspace(&mut self, workspace: Entity, cx: &mut Context) { + self.workspaces[0] = workspace; + self.active_workspace_index = 0; + cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged); + cx.notify(); + } + + /// Inserts a workspace into the list if not already present. Returns the + /// index of the workspace (existing or newly inserted). Does not change + /// the active workspace index. + fn insert_workspace( + &mut self, + workspace: Entity, + window: &Window, + cx: &mut Context, + ) -> usize { if let Some(index) = self.workspaces.iter().position(|w| *w == workspace) { index } else { - if self.sidebar_open { - let sidebar_focus_handle = self.sidebar.as_ref().map(|s| s.focus_handle(cx)); - let has_notifications = self.sidebar_has_notifications(cx); - let show_toggle = self.multi_workspace_enabled(cx); - workspace.update(cx, |workspace, cx| { - workspace.set_workspace_sidebar_open(true, has_notifications, show_toggle, cx); - workspace.set_sidebar_focus_handle(sidebar_focus_handle); - }); - } - Self::subscribe_to_workspace(&workspace, cx); + Self::subscribe_to_workspace(&workspace, window, cx); + self.sync_sidebar_to_workspace(&workspace, cx); + let weak_self = cx.weak_entity(); + workspace.update(cx, |workspace, cx| { + workspace.set_multi_workspace(weak_self, cx); + }); self.workspaces.push(workspace.clone()); cx.emit(MultiWorkspaceEvent::WorkspaceAdded(workspace)); cx.notify(); @@ -392,19 +528,35 @@ impl MultiWorkspace { } } - pub fn activate_index(&mut self, index: usize, window: &mut Window, cx: &mut Context) { - debug_assert!( - index < self.workspaces.len(), - "workspace index out of bounds" - ); - let changed = self.active_workspace_index != index; - self.active_workspace_index = index; - self.serialize(cx); - self.focus_active_workspace(window, cx); - if changed { - cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged); + /// Clears session state and DB binding for a workspace that is being + /// removed or replaced. The DB row is preserved so the workspace still + /// appears in the recent-projects list. + fn detach_workspace(&mut self, workspace: &Entity, cx: &mut Context) { + workspace.update(cx, |workspace, _cx| { + workspace.session_id.take(); + workspace._schedule_serialize_workspace.take(); + workspace._serialize_workspace_task.take(); + }); + + if let Some(workspace_id) = workspace.read(cx).database_id() { + let db = crate::persistence::WorkspaceDb::global(cx); + self.pending_removal_tasks.retain(|task| !task.is_ready()); + self.pending_removal_tasks + .push(cx.background_spawn(async move { + db.set_session_binding(workspace_id, None, None) + .await + .log_err(); + })); + } + } + + fn sync_sidebar_to_workspace(&self, workspace: &Entity, cx: &mut Context) { + if self.sidebar_open { + let sidebar_focus_handle = self.sidebar.as_ref().map(|s| s.focus_handle(cx)); + workspace.update(cx, |workspace, _| { + workspace.set_sidebar_focus_handle(sidebar_focus_handle); + }); } - cx.notify(); } fn cycle_workspace(&mut self, delta: isize, window: &mut Window, cx: &mut Context) { @@ -414,7 +566,8 @@ impl MultiWorkspace { } let current = self.active_workspace_index as isize; let next = ((current + delta).rem_euclid(count)) as usize; - self.activate_index(next, window, cx); + let workspace = self.workspaces[next].clone(); + self.activate(workspace, window, cx); } fn next_workspace(&mut self, _: &NextWorkspace, window: &mut Window, cx: &mut Context) { @@ -584,7 +737,7 @@ impl MultiWorkspace { cx: &mut Context, ) -> Entity { let workspace = cx.new(|cx| Workspace::test_new(project, window, cx)); - self.activate(workspace.clone(), cx); + self.activate(workspace.clone(), window, cx); workspace } @@ -605,8 +758,7 @@ impl MultiWorkspace { cx, ); let new_workspace = cx.new(|cx| Workspace::new(None, project, app_state, window, cx)); - self.set_active_workspace(new_workspace.clone(), cx); - self.focus_active_workspace(window, cx); + self.activate(new_workspace.clone(), window, cx); let weak_workspace = new_workspace.downgrade(); let db = crate::persistence::WorkspaceDb::global(cx); @@ -642,9 +794,17 @@ impl MultiWorkspace { self.create_empty_workspace(window, cx) } - pub fn remove_workspace(&mut self, index: usize, window: &mut Window, cx: &mut Context) { - if self.workspaces.len() <= 1 || index >= self.workspaces.len() { - return; + pub fn remove( + &mut self, + workspace: &Entity, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let Some(index) = self.workspaces.iter().position(|w| w == workspace) else { + return false; + }; + if self.workspaces.len() <= 1 { + return false; } let removed_workspace = self.workspaces.remove(index); @@ -655,18 +815,7 @@ impl MultiWorkspace { self.active_workspace_index -= 1; } - if let Some(workspace_id) = removed_workspace.read(cx).database_id() { - let db = crate::persistence::WorkspaceDb::global(cx); - self.pending_removal_tasks.retain(|task| !task.is_ready()); - self.pending_removal_tasks - .push(cx.background_spawn(async move { - // Clear the session binding instead of deleting the row so - // the workspace still appears in the recent-projects list. - db.set_session_binding(workspace_id, None, None) - .await - .log_err(); - })); - } + self.detach_workspace(&removed_workspace, cx); self.serialize(cx); self.focus_active_workspace(window, cx); @@ -675,21 +824,66 @@ impl MultiWorkspace { )); cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged); cx.notify(); + + true + } + + pub fn move_workspace_to_new_window( + &mut self, + workspace: &Entity, + window: &mut Window, + cx: &mut Context, + ) { + let workspace = workspace.clone(); + if !self.remove(&workspace, window, cx) { + return; + } + + let app_state: Arc = workspace.read(cx).app_state().clone(); + + cx.defer(move |cx| { + let options = (app_state.build_window_options)(None, cx); + + let Ok(window) = cx.open_window(options, |window, cx| { + cx.new(|cx| MultiWorkspace::new(workspace, window, cx)) + }) else { + return; + }; + + let _ = window.update(cx, |_, window, _| { + window.activate_window(); + }); + }); + } + + fn move_active_workspace_to_new_window( + &mut self, + _: &MoveWorkspaceToNewWindow, + window: &mut Window, + cx: &mut Context, + ) { + let workspace = self.workspace().clone(); + self.move_workspace_to_new_window(&workspace, window, cx); } pub fn open_project( &mut self, paths: Vec, + open_mode: OpenMode, window: &mut Window, cx: &mut Context, ) -> Task>> { let workspace = self.workspace().clone(); - if self.multi_workspace_enabled(cx) { - workspace.update(cx, |workspace, cx| { - workspace.open_workspace_for_paths(true, paths, window, cx) - }) + let needs_close_prompt = + open_mode == OpenMode::Replace || !self.multi_workspace_enabled(cx); + let open_mode = if self.multi_workspace_enabled(cx) { + open_mode } else { + OpenMode::Replace + }; + + if needs_close_prompt { cx.spawn_in(window, async move |_this, cx| { let should_continue = workspace .update_in(cx, |workspace, window, cx| { @@ -699,13 +893,17 @@ impl MultiWorkspace { if should_continue { workspace .update_in(cx, |workspace, window, cx| { - workspace.open_workspace_for_paths(true, paths, window, cx) + workspace.open_workspace_for_paths(open_mode, paths, window, cx) })? .await } else { Ok(workspace) } }) + } else { + workspace.update(cx, |workspace, cx| { + workspace.open_workspace_for_paths(open_mode, paths, window, cx) + }) } } } @@ -713,6 +911,8 @@ impl MultiWorkspace { impl Render for MultiWorkspace { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let multi_workspace_enabled = self.multi_workspace_enabled(cx); + let sidebar_side = self.sidebar_side(cx); + let sidebar_on_right = sidebar_side == SidebarSide::Right; let sidebar: Option = if multi_workspace_enabled && self.sidebar_open() { self.sidebar.as_ref().map(|sidebar_handle| { @@ -723,7 +923,12 @@ impl Render for MultiWorkspace { div() .id("sidebar-resize-handle") .absolute() - .right(-SIDEBAR_RESIZE_HANDLE_SIZE / 2.) + .when(!sidebar_on_right, |el| { + el.right(-SIDEBAR_RESIZE_HANDLE_SIZE / 2.) + }) + .when(sidebar_on_right, |el| { + el.left(-SIDEBAR_RESIZE_HANDLE_SIZE / 2.) + }) .top(px(0.)) .h_full() .w(SIDEBAR_RESIZE_HANDLE_SIZE) @@ -763,7 +968,13 @@ impl Render for MultiWorkspace { None }; - let ui_font = theme::setup_ui_font(window, cx); + let (left_sidebar, right_sidebar) = if sidebar_on_right { + (None, sidebar) + } else { + (sidebar, None) + }; + + let ui_font = theme_settings::setup_ui_font(window, cx); let text_color = cx.theme().colors().text; let workspace = self.workspace().clone(); @@ -795,21 +1006,36 @@ impl Render for MultiWorkspace { )) .on_action(cx.listener(Self::next_workspace)) .on_action(cx.listener(Self::previous_workspace)) + .on_action(cx.listener(Self::move_active_workspace_to_new_window)) + .on_action(cx.listener( + |this: &mut Self, action: &ToggleThreadSwitcher, window, cx| { + if let Some(sidebar) = &this.sidebar { + sidebar.toggle_thread_switcher(action.select_last, window, cx); + } + }, + )) }) .when( self.sidebar_open() && self.multi_workspace_enabled(cx), |this| { this.on_drag_move(cx.listener( - |this: &mut Self, e: &DragMoveEvent, _window, cx| { + move |this: &mut Self, + e: &DragMoveEvent, + window, + cx| { if let Some(sidebar) = &this.sidebar { - let new_width = e.event.position.x; + let new_width = if sidebar_on_right { + window.bounds().size.width - e.event.position.x + } else { + e.event.position.x + }; sidebar.set_width(Some(new_width), cx); } }, )) - .children(sidebar) }, ) + .children(left_sidebar) .child( div() .flex() @@ -818,11 +1044,26 @@ impl Render for MultiWorkspace { .overflow_hidden() .child(self.workspace().clone()), ) - .child(self.workspace().read(cx).modal_layer.clone()), + .children(right_sidebar) + .child(self.workspace().read(cx).modal_layer.clone()) + .children(self.sidebar_overlay.as_ref().map(|view| { + deferred(div().absolute().size_full().inset_0().occlude().child( + v_flex().h(px(0.0)).top_20().items_center().child( + h_flex().occlude().child(view.clone()).on_mouse_down( + MouseButton::Left, + |_, _, cx| { + cx.stop_propagation(); + }, + ), + ), + )) + .with_priority(2) + })), window, cx, Tiling { - left: multi_workspace_enabled && self.sidebar_open(), + left: !sidebar_on_right && multi_workspace_enabled && self.sidebar_open(), + right: sidebar_on_right && multi_workspace_enabled && self.sidebar_open(), ..Tiling::default() }, ) @@ -840,7 +1081,7 @@ mod tests { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); DisableAiSettings::register(cx); cx.update_flags(false, vec!["agent-v2".into()]); }); @@ -916,4 +1157,90 @@ mod tests { ); }); } + + #[gpui::test] + async fn test_replace(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + let project_a = Project::test(fs.clone(), [], cx).await; + let project_b = Project::test(fs.clone(), [], cx).await; + let project_c = Project::test(fs.clone(), [], cx).await; + let project_d = Project::test(fs.clone(), [], cx).await; + + let (multi_workspace, cx) = cx + .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); + + let workspace_a_id = + multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].entity_id()); + + // Replace the only workspace (single-workspace case). + let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| { + let workspace = cx.new(|cx| Workspace::test_new(project_b.clone(), window, cx)); + mw.replace(workspace.clone(), &*window, cx); + workspace + }); + + multi_workspace.read_with(cx, |mw, _cx| { + assert_eq!(mw.workspaces().len(), 1); + assert_eq!( + mw.workspaces()[0].entity_id(), + workspace_b.entity_id(), + "slot should now be project_b" + ); + assert_ne!( + mw.workspaces()[0].entity_id(), + workspace_a_id, + "project_a should be gone" + ); + }); + + // Add project_c as a second workspace, then replace it with project_d. + let workspace_c = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_c.clone(), window, cx) + }); + + multi_workspace.read_with(cx, |mw, _cx| { + assert_eq!(mw.workspaces().len(), 2); + assert_eq!(mw.active_workspace_index(), 1); + }); + + let workspace_d = multi_workspace.update_in(cx, |mw, window, cx| { + let workspace = cx.new(|cx| Workspace::test_new(project_d.clone(), window, cx)); + mw.replace(workspace.clone(), &*window, cx); + workspace + }); + + multi_workspace.read_with(cx, |mw, _cx| { + assert_eq!(mw.workspaces().len(), 2, "should still have 2 workspaces"); + assert_eq!(mw.active_workspace_index(), 1); + assert_eq!( + mw.workspaces()[1].entity_id(), + workspace_d.entity_id(), + "active slot should now be project_d" + ); + assert_ne!( + mw.workspaces()[1].entity_id(), + workspace_c.entity_id(), + "project_c should be gone" + ); + }); + + // Replace with workspace_b which is already in the list — should just switch. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.replace(workspace_b.clone(), &*window, cx); + }); + + multi_workspace.read_with(cx, |mw, _cx| { + assert_eq!( + mw.workspaces().len(), + 2, + "no workspace should be added or removed" + ); + assert_eq!( + mw.active_workspace_index(), + 0, + "should have switched to workspace_b" + ); + }); + } } diff --git a/crates/workspace/src/notifications.rs b/crates/workspace/src/notifications.rs index 85b1fe4e707acbc7107df14d23caa3bda24519e5..dbf2accf3dd9910426ca3557daf9cee0e5b0a82b 100644 --- a/crates/workspace/src/notifications.rs +++ b/crates/workspace/src/notifications.rs @@ -9,7 +9,7 @@ use markdown::{Markdown, MarkdownElement, MarkdownStyle}; use parking_lot::Mutex; use project::project_settings::ProjectSettings; use settings::Settings; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use std::ops::Deref; use std::sync::{Arc, LazyLock}; diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 54f83bbbe64309a8a00f74d68508a403bc7003f9..c270ab63c38dabaf802a0ef668fb2fd6ec841721 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -42,7 +42,7 @@ use std::{ }, time::Duration, }; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ ContextMenu, ContextMenuEntry, ContextMenuItem, DecoratedIcon, IconButtonShape, IconDecoration, IconDecorationKind, Indicator, PopoverMenu, PopoverMenuHandle, Tab, TabBar, TabPosition, @@ -473,6 +473,9 @@ pub struct NavigationEntry { pub data: Option>, pub timestamp: usize, pub is_preview: bool, + /// Row position for Neovim-style deduplication. When set, entries with the + /// same item and row are considered duplicates and deduplicated. + pub row: Option, } #[derive(Clone)] @@ -2846,12 +2849,13 @@ impl Pane { })) .on_aux_click( cx.listener(move |pane: &mut Self, event: &ClickEvent, window, cx| { - if !event.is_middle_click() { + if !event.is_middle_click() || is_pinned { return; } pane.close_item_by_id(item_id, SaveIntent::Close, window, cx) .detach_and_log_err(cx); + cx.stop_propagation(); }), ) .on_drag( @@ -4510,7 +4514,12 @@ impl Render for Pane { } impl ItemNavHistory { - pub fn push(&mut self, data: Option, cx: &mut App) { + pub fn push( + &mut self, + data: Option, + row: Option, + cx: &mut App, + ) { if self .item .upgrade() @@ -4518,7 +4527,7 @@ impl ItemNavHistory { { let is_preview_item = self.history.0.lock().preview_item_id == Some(self.item.id()); self.history - .push(data, self.item.clone(), is_preview_item, cx); + .push(data, self.item.clone(), is_preview_item, row, cx); } } @@ -4526,9 +4535,10 @@ impl ItemNavHistory { let is_preview_item = self.history.0.lock().preview_item_id == Some(self.item.id()); NavigationEntry { item: self.item.clone(), - data: data, - timestamp: 0, // not used + data, + timestamp: 0, is_preview: is_preview_item, + row: None, } } @@ -4632,12 +4642,22 @@ impl NavHistory { data: Option, item: Arc, is_preview: bool, + row: Option, cx: &mut App, ) { let state = &mut *self.0.lock(); + let new_item_id = item.id(); + + let is_same_location = + |entry: &NavigationEntry| entry.item.id() == new_item_id && entry.row == row; + match state.mode { NavigationMode::Disabled => {} NavigationMode::Normal | NavigationMode::ReopeningClosedItem => { + state + .backward_stack + .retain(|entry| !is_same_location(entry)); + if state.backward_stack.len() >= MAX_NAVIGATION_HISTORY_LEN { state.backward_stack.pop_front(); } @@ -4646,10 +4666,13 @@ impl NavHistory { data: data.map(|data| Arc::new(data) as Arc), timestamp: state.next_timestamp.fetch_add(1, Ordering::SeqCst), is_preview, + row, }); state.forward_stack.clear(); } NavigationMode::GoingBack => { + state.forward_stack.retain(|entry| !is_same_location(entry)); + if state.forward_stack.len() >= MAX_NAVIGATION_HISTORY_LEN { state.forward_stack.pop_front(); } @@ -4658,9 +4681,14 @@ impl NavHistory { data: data.map(|data| Arc::new(data) as Arc), timestamp: state.next_timestamp.fetch_add(1, Ordering::SeqCst), is_preview, + row, }); } NavigationMode::GoingForward => { + state + .backward_stack + .retain(|entry| !is_same_location(entry)); + if state.backward_stack.len() >= MAX_NAVIGATION_HISTORY_LEN { state.backward_stack.pop_front(); } @@ -4669,6 +4697,7 @@ impl NavHistory { data: data.map(|data| Arc::new(data) as Arc), timestamp: state.next_timestamp.fetch_add(1, Ordering::SeqCst), is_preview, + row, }); } NavigationMode::ClosingItem if is_preview => return, @@ -4681,6 +4710,7 @@ impl NavHistory { data: data.map(|data| Arc::new(data) as Arc), timestamp: state.next_timestamp.fetch_add(1, Ordering::SeqCst), is_preview, + row, }); } } @@ -6829,6 +6859,79 @@ mod tests { assert_item_labels(&pane, ["A!", "B!", "D", "E", "C*", "F"], cx); } + #[gpui::test] + async fn test_middle_click_pinned_tab_does_not_close(cx: &mut TestAppContext) { + use gpui::{Modifiers, MouseButton, MouseDownEvent, MouseUpEvent}; + + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + let project = Project::test(fs, None, cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + + let item_a = add_labeled_item(&pane, "A", false, cx); + add_labeled_item(&pane, "B", false, cx); + + pane.update_in(cx, |pane, window, cx| { + pane.pin_tab_at( + pane.index_for_item_id(item_a.item_id()).unwrap(), + window, + cx, + ); + }); + assert_item_labels(&pane, ["A!", "B*"], cx); + cx.run_until_parked(); + + let tab_a_bounds = cx + .debug_bounds("TAB-0") + .expect("Tab A (index 1) should have debug bounds"); + let tab_b_bounds = cx + .debug_bounds("TAB-1") + .expect("Tab B (index 2) should have debug bounds"); + + cx.simulate_event(MouseDownEvent { + position: tab_a_bounds.center(), + button: MouseButton::Middle, + modifiers: Modifiers::default(), + click_count: 1, + first_mouse: false, + }); + + cx.run_until_parked(); + + cx.simulate_event(MouseUpEvent { + position: tab_a_bounds.center(), + button: MouseButton::Middle, + modifiers: Modifiers::default(), + click_count: 1, + }); + + cx.run_until_parked(); + + cx.simulate_event(MouseDownEvent { + position: tab_b_bounds.center(), + button: MouseButton::Middle, + modifiers: Modifiers::default(), + click_count: 1, + first_mouse: false, + }); + + cx.run_until_parked(); + + cx.simulate_event(MouseUpEvent { + position: tab_b_bounds.center(), + button: MouseButton::Middle, + modifiers: Modifiers::default(), + click_count: 1, + }); + + cx.run_until_parked(); + + assert_item_labels(&pane, ["A*!"], cx); + } + #[gpui::test] async fn test_add_item_with_new_item(cx: &mut TestAppContext) { init_test(cx); @@ -8486,7 +8589,7 @@ mod tests { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(LoadThemes::JustBase, cx); + theme_settings::init(LoadThemes::JustBase, cx); }); } diff --git a/crates/workspace/src/pane_group.rs b/crates/workspace/src/pane_group.rs index 0921a19486718c5375ed17ebbb3d7e314546f8d7..3fa4800afb6088e0d106c8b60a835073978e598c 100644 --- a/crates/workspace/src/pane_group.rs +++ b/crates/workspace/src/pane_group.rs @@ -97,6 +97,10 @@ impl PaneGroup { } } + pub fn width_fraction_for_pane(&self, pane: &Entity) -> Option { + self.root.width_fraction_for_pane(pane) + } + pub fn pane_at_pixel_position(&self, coordinate: Point) -> Option<&Entity> { match &self.root { Member::Pane(pane) => Some(pane), @@ -301,6 +305,13 @@ impl Member { }), } } + + fn width_fraction_for_pane(&self, pane: &Entity) -> Option { + match self { + Member::Pane(found) => (found == pane).then_some(1.0), + Member::Axis(axis) => axis.width_fraction_for_pane(pane), + } + } } #[derive(Clone, Copy)] @@ -884,6 +895,40 @@ impl PaneAxis { None } + fn width_fraction_for_pane(&self, pane: &Entity) -> Option { + let flexes = self.flexes.lock(); + let total_flex = flexes.iter().copied().sum::(); + + for (index, member) in self.members.iter().enumerate() { + let child_fraction = if total_flex > 0.0 { + flexes[index] / total_flex + } else { + 1.0 / self.members.len() as f32 + }; + + match member { + Member::Pane(found) => { + if found == pane { + return Some(match self.axis { + Axis::Horizontal => child_fraction, + Axis::Vertical => 1.0, + }); + } + } + Member::Axis(axis) => { + if let Some(descendant_fraction) = axis.width_fraction_for_pane(pane) { + return Some(match self.axis { + Axis::Horizontal => child_fraction * descendant_fraction, + Axis::Vertical => descendant_fraction, + }); + } + } + } + } + + None + } + fn render( &self, basis: usize, diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index 08f4fa84613436e6c5c34b3410df324e666b5fb8..c8952dfecb137cce02998225a9346556a0fc2776 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -2523,7 +2523,7 @@ mod tests { let workspace2 = multi_workspace.update_in(cx, |mw, window, cx| { let workspace = cx.new(|cx| crate::Workspace::test_new(project2.clone(), window, cx)); workspace.update(cx, |ws, _cx| ws.set_random_database_id()); - mw.activate(workspace.clone(), cx); + mw.activate(workspace.clone(), window, cx); workspace }); @@ -2541,7 +2541,8 @@ mod tests { // --- Remove the second workspace (index 1) --- multi_workspace.update_in(cx, |mw, window, cx| { - mw.remove_workspace(1, window, cx); + let ws = mw.workspaces()[1].clone(); + mw.remove(&ws, window, cx); }); cx.run_until_parked(); @@ -4193,7 +4194,7 @@ mod tests { workspace.update(cx, |ws: &mut crate::Workspace, _cx| { ws.set_database_id(workspace2_db_id) }); - mw.activate(workspace.clone(), cx); + mw.activate(workspace.clone(), window, cx); }); // Save a full workspace row to the DB directly. @@ -4221,7 +4222,8 @@ mod tests { // Remove workspace at index 1 (the second workspace). multi_workspace.update_in(cx, |mw, window, cx| { - mw.remove_workspace(1, window, cx); + let ws = mw.workspaces()[1].clone(); + mw.remove(&ws, window, cx); }); cx.run_until_parked(); @@ -4291,7 +4293,7 @@ mod tests { workspace.update(cx, |ws: &mut crate::Workspace, _cx| { ws.set_database_id(ws2_id) }); - mw.activate(workspace.clone(), cx); + mw.activate(workspace.clone(), window, cx); }); let session_id = "test-zombie-session"; @@ -4331,7 +4333,8 @@ mod tests { // Remove workspace2 (index 1). multi_workspace.update_in(cx, |mw, window, cx| { - mw.remove_workspace(1, window, cx); + let ws = mw.workspaces()[1].clone(); + mw.remove(&ws, window, cx); }); cx.run_until_parked(); @@ -4390,7 +4393,7 @@ mod tests { workspace.update(cx, |ws: &mut crate::Workspace, _cx| { ws.set_database_id(workspace2_db_id) }); - mw.activate(workspace.clone(), cx); + mw.activate(workspace.clone(), window, cx); }); // Save a full workspace row to the DB directly and let it settle. @@ -4414,7 +4417,8 @@ mod tests { // Remove workspace2 — this pushes a task to pending_removal_tasks. multi_workspace.update_in(cx, |mw, window, cx| { - mw.remove_workspace(1, window, cx); + let ws = mw.workspaces()[1].clone(); + mw.remove(&ws, window, cx); }); // Simulate the quit handler pattern: collect flush tasks + pending diff --git a/crates/workspace/src/shared_screen.rs b/crates/workspace/src/shared_screen.rs index 136f552fee23231b45fcb867d2ce8bab02dca7e8..41e8f41f2ad4e10b85ceb68e5f4690b1faf6c04a 100644 --- a/crates/workspace/src/shared_screen.rs +++ b/crates/workspace/src/shared_screen.rs @@ -69,7 +69,7 @@ impl Item for SharedScreen { fn deactivated(&mut self, _window: &mut Window, cx: &mut Context) { if let Some(nav_history) = self.nav_history.as_mut() { - nav_history.push::<()>(None, cx); + nav_history.push::<()>(None, None, cx); } } diff --git a/crates/workspace/src/status_bar.rs b/crates/workspace/src/status_bar.rs index 304c6417baab6c6a9b4b6e26e8f685992c1f80db..dad5389f2f5574c773af740fd61c6c1501c2fea0 100644 --- a/crates/workspace/src/status_bar.rs +++ b/crates/workspace/src/status_bar.rs @@ -1,7 +1,10 @@ -use crate::{ItemHandle, MultiWorkspace, Pane, ToggleWorkspaceSidebar}; +use crate::{ + ItemHandle, MultiWorkspace, Pane, SidebarSide, ToggleWorkspaceSidebar, + sidebar_side_context_menu, +}; use gpui::{ - AnyView, App, Context, Decorations, Entity, IntoElement, ParentElement, Render, Styled, - Subscription, Window, + AnyView, App, Context, Corner, Decorations, Entity, IntoElement, ParentElement, Render, Styled, + Subscription, WeakEntity, Window, }; use std::any::TypeId; use theme::CLIENT_SIDE_DECORATION_ROUNDING; @@ -29,18 +32,45 @@ trait StatusItemViewHandle: Send { fn item_type(&self) -> TypeId; } +#[derive(Default)] +struct SidebarStatus { + open: bool, + side: SidebarSide, + has_notifications: bool, + show_toggle: bool, +} + +impl SidebarStatus { + fn query(multi_workspace: &Option>, cx: &App) -> Self { + multi_workspace + .as_ref() + .and_then(|mw| mw.upgrade()) + .map(|mw| { + let mw = mw.read(cx); + let enabled = mw.multi_workspace_enabled(cx); + Self { + open: mw.sidebar_open() && enabled, + side: mw.sidebar_side(cx), + has_notifications: mw.sidebar_has_notifications(cx), + show_toggle: enabled, + } + }) + .unwrap_or_default() + } +} + pub struct StatusBar { left_items: Vec>, right_items: Vec>, active_pane: Entity, + multi_workspace: Option>, _observe_active_pane: Subscription, - workspace_sidebar_open: bool, - sidebar_has_notifications: bool, - show_sidebar_toggle: bool, } impl Render for StatusBar { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let sidebar = SidebarStatus::query(&self.multi_workspace, cx); + h_flex() .w_full() .justify_between() @@ -50,11 +80,14 @@ impl Render for StatusBar { .map(|el| match window.window_decorations() { Decorations::Server => el, Decorations::Client { tiling, .. } => el - .when(!(tiling.bottom || tiling.right), |el| { - el.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING) - }) .when( - !(tiling.bottom || tiling.left) && !self.workspace_sidebar_open, + !(tiling.bottom || tiling.right) + && !(sidebar.open && sidebar.side == SidebarSide::Right), + |el| el.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING), + ) + .when( + !(tiling.bottom || tiling.left) + && !(sidebar.open && sidebar.side == SidebarSide::Left), |el| el.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING), ) // This border is to avoid a transparent gap in the rounded corners @@ -62,44 +95,77 @@ impl Render for StatusBar { .border_b(px(1.0)) .border_color(cx.theme().colors().status_bar_background), }) - .child(self.render_left_tools(cx)) - .child(self.render_right_tools()) + .child(self.render_left_tools(&sidebar, cx)) + .child(self.render_right_tools(&sidebar, cx)) } } impl StatusBar { - fn render_left_tools(&self, cx: &mut Context) -> impl IntoElement { + fn render_left_tools( + &self, + sidebar: &SidebarStatus, + cx: &mut Context, + ) -> impl IntoElement { h_flex() .gap_1() .min_w_0() .overflow_x_hidden() .when( - self.show_sidebar_toggle && !self.workspace_sidebar_open, - |this| this.child(self.render_sidebar_toggle(cx)), + sidebar.show_toggle && !sidebar.open && sidebar.side == SidebarSide::Left, + |this| this.child(self.render_sidebar_toggle(sidebar, cx)), ) .children(self.left_items.iter().map(|item| item.to_any())) } - fn render_right_tools(&self) -> impl IntoElement { + fn render_right_tools( + &self, + sidebar: &SidebarStatus, + cx: &mut Context, + ) -> impl IntoElement { h_flex() .flex_shrink_0() .gap_1() .overflow_x_hidden() .children(self.right_items.iter().rev().map(|item| item.to_any())) + .when( + sidebar.show_toggle && !sidebar.open && sidebar.side == SidebarSide::Right, + |this| this.child(self.render_sidebar_toggle(sidebar, cx)), + ) } - fn render_sidebar_toggle(&self, cx: &mut Context) -> impl IntoElement { - h_flex() - .gap_0p5() - .child( + fn render_sidebar_toggle( + &self, + sidebar: &SidebarStatus, + cx: &mut Context, + ) -> impl IntoElement { + let on_right = sidebar.side == SidebarSide::Right; + let has_notifications = sidebar.has_notifications; + let indicator_border = cx.theme().colors().status_bar_background; + + let toggle = sidebar_side_context_menu("sidebar-status-toggle-menu", cx) + .anchor(if on_right { + Corner::BottomRight + } else { + Corner::BottomLeft + }) + .attach(if on_right { + Corner::TopRight + } else { + Corner::TopLeft + }) + .trigger(move |_is_active, _window, _cx| { IconButton::new( "toggle-workspace-sidebar", - IconName::ThreadsSidebarLeftClosed, + if on_right { + IconName::ThreadsSidebarRightClosed + } else { + IconName::ThreadsSidebarLeftClosed + }, ) .icon_size(IconSize::Small) - .when(self.sidebar_has_notifications, |this| { + .when(has_notifications, |this| { this.indicator(Indicator::dot().color(Color::Accent)) - .indicator_border_color(Some(cx.theme().colors().status_bar_background)) + .indicator_border_color(Some(indicator_border)) }) .tooltip(move |_, cx| { Tooltip::for_action("Open Threads Sidebar", &ToggleWorkspaceSidebar, cx) @@ -110,41 +176,47 @@ impl StatusBar { multi_workspace.toggle_sidebar(window, cx); }); } - }), - ) - .child(Divider::vertical().color(ui::DividerColor::Border)) + }) + }); + + h_flex() + .gap_0p5() + .when(on_right, |this| { + this.child(Divider::vertical().color(ui::DividerColor::Border)) + }) + .child(toggle) + .when(!on_right, |this| { + this.child(Divider::vertical().color(ui::DividerColor::Border)) + }) } } impl StatusBar { - pub fn new(active_pane: &Entity, window: &mut Window, cx: &mut Context) -> Self { + pub fn new( + active_pane: &Entity, + multi_workspace: Option>, + window: &mut Window, + cx: &mut Context, + ) -> Self { let mut this = Self { left_items: Default::default(), right_items: Default::default(), active_pane: active_pane.clone(), + multi_workspace, _observe_active_pane: cx.observe_in(active_pane, window, |this, _, window, cx| { this.update_active_pane_item(window, cx) }), - workspace_sidebar_open: false, - sidebar_has_notifications: false, - show_sidebar_toggle: false, }; this.update_active_pane_item(window, cx); this } - pub fn set_workspace_sidebar_open(&mut self, open: bool, cx: &mut Context) { - self.workspace_sidebar_open = open; - cx.notify(); - } - - pub fn set_sidebar_has_notifications(&mut self, has: bool, cx: &mut Context) { - self.sidebar_has_notifications = has; - cx.notify(); - } - - pub fn set_show_sidebar_toggle(&mut self, show: bool, cx: &mut Context) { - self.show_sidebar_toggle = show; + pub fn set_multi_workspace( + &mut self, + multi_workspace: WeakEntity, + cx: &mut Context, + ) { + self.multi_workspace = Some(multi_workspace); cx.notify(); } diff --git a/crates/workspace/src/tasks.rs b/crates/workspace/src/tasks.rs index 8a2ae6a40ab6328c2a2328fbdbe0e5be5972cf22..0ebb97b9d75543986bb6727546aad872a11a4f87 100644 --- a/crates/workspace/src/tasks.rs +++ b/crates/workspace/src/tasks.rs @@ -254,7 +254,7 @@ mod tests { cx.update(|cx| { let settings_store = settings::SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); register_serializable_item::(cx); }); let fs = FakeFs::new(cx.executor()); diff --git a/crates/workspace/src/welcome.rs b/crates/workspace/src/welcome.rs index 1b0566bf561b80137bf222a9d7c3348012cfce27..efd9b75a6802f888f43654e21006f202cc36c5a4 100644 --- a/crates/workspace/src/welcome.rs +++ b/crates/workspace/src/welcome.rs @@ -1,5 +1,5 @@ use crate::{ - NewFile, Open, PathList, SerializedWorkspaceLocation, Workspace, WorkspaceId, + NewFile, Open, OpenMode, PathList, SerializedWorkspaceLocation, Workspace, WorkspaceId, item::{Item, ItemEvent}, persistence::WorkspaceDb, }; @@ -326,7 +326,7 @@ impl WelcomePage { self.workspace .update(cx, |workspace, cx| { workspace - .open_workspace_for_paths(true, paths, window, cx) + .open_workspace_for_paths(OpenMode::Replace, paths, window, cx) .detach_and_log_err(cx); }) .log_err(); diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 0acc15697008d427efbe0371040a88945b8694c1..d4b1cebca6b6b71b9efd3394f639a5cb32384682 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -1,3 +1,4 @@ +pub mod active_file_name; pub mod dock; pub mod history_manager; pub mod invalid_item_view; @@ -29,7 +30,7 @@ pub use dock::Panel; pub use multi_workspace::{ CloseWorkspaceSidebar, DraggedSidebar, FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent, NextWorkspace, PreviousWorkspace, Sidebar, SidebarHandle, - ToggleWorkspaceSidebar, + SidebarRenderState, SidebarSide, ToggleWorkspaceSidebar, sidebar_side_context_menu, }; pub use path_list::{PathList, SerializedPathList}; pub use toast_layer::{ToastAction, ToastLayer, ToastView}; @@ -51,8 +52,8 @@ use futures::{ future::{Shared, try_join_all}, }; use gpui::{ - Action, AnyEntity, AnyView, AnyWeakView, App, AsyncApp, AsyncWindowContext, Bounds, Context, - CursorStyle, Decorations, DragMoveEvent, Entity, EntityId, EventEmitter, FocusHandle, + Action, AnyEntity, AnyView, AnyWeakView, App, AsyncApp, AsyncWindowContext, Axis, Bounds, + Context, CursorStyle, Decorations, DragMoveEvent, Entity, EntityId, EventEmitter, FocusHandle, Focusable, Global, HitboxBehavior, Hsla, KeyContext, Keystroke, ManagedView, MouseButton, PathPromptOptions, Point, PromptLevel, Render, ResizeEdge, Size, Stateful, Subscription, SystemWindowTabController, Task, Tiling, WeakEntity, WindowBounds, WindowHandle, WindowId, @@ -123,13 +124,14 @@ use std::{ process::ExitStatus, rc::Rc, sync::{ - Arc, LazyLock, Weak, + Arc, LazyLock, atomic::{AtomicBool, AtomicUsize}, }, time::Duration, }; use task::{DebugScenario, SharedTaskContext, SpawnInTerminal}; -use theme::{ActiveTheme, GlobalTheme, SystemAppearance, ThemeSettings}; +use theme::{ActiveTheme, SystemAppearance}; +use theme_settings::ThemeSettings; pub use toolbar::{ PaneSearchBarCallbacks, Toolbar, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, }; @@ -148,7 +150,7 @@ pub use workspace_settings::{ }; use zed_actions::{Spawn, feedback::FileBugReport, theme::ToggleMode}; -use crate::{item::ItemBufferKind, notifications::NotificationId}; +use crate::{dock::PanelSizeState, item::ItemBufferKind, notifications::NotificationId}; use crate::{ persistence::{ SerializedAxis, @@ -662,7 +664,15 @@ fn prompt_and_open_paths(app_state: Arc, options: PathPromptOptions, c }) .ok(); } else { - let task = Workspace::new_local(Vec::new(), app_state.clone(), None, None, None, true, cx); + let task = Workspace::new_local( + Vec::new(), + app_state.clone(), + None, + None, + None, + OpenMode::Replace, + cx, + ); cx.spawn(async move |cx| { let OpenResult { window, .. } = task.await?; window.update(cx, |multi_workspace, window, cx| { @@ -701,7 +711,7 @@ pub fn prompt_for_open_path_and_open( if let Some(handle) = multi_workspace_handle { if let Some(task) = handle .update(cx, |multi_workspace, window, cx| { - multi_workspace.open_project(paths, window, cx) + multi_workspace.open_project(paths, OpenMode::Replace, window, cx) }) .log_err() { @@ -712,7 +722,7 @@ pub fn prompt_for_open_path_and_open( } if let Some(task) = this .update_in(cx, |this, window, cx| { - this.open_workspace_for_paths(false, paths, window, cx) + this.open_workspace_for_paths(OpenMode::NewWindow, paths, window, cx) }) .log_err() { @@ -730,40 +740,32 @@ pub fn init(app_state: Arc, cx: &mut App) { 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, - ); - } - } + .on_action(|_: &Open, cx: &mut App| { + let app_state = AppState::global(cx); + prompt_and_open_paths( + app_state, + PathPromptOptions { + files: true, + directories: true, + 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, - ); - } - } + .on_action(|_: &OpenFiles, cx: &mut App| { + let directories = cx.can_select_mixed_files_and_dirs(); + let app_state = AppState::global(cx); + prompt_and_open_paths( + app_state, + PathPromptOptions { + files: true, + directories, + multiple: true, + prompt: None, + }, + cx, + ); }); } @@ -1072,7 +1074,7 @@ pub struct AppState { pub session: Entity, } -struct GlobalAppState(Weak); +struct GlobalAppState(Arc); impl Global for GlobalAppState {} @@ -1108,14 +1110,14 @@ struct Follower { impl AppState { #[track_caller] - pub fn global(cx: &App) -> Weak { + pub fn global(cx: &App) -> Arc { cx.global::().0.clone() } - pub fn try_global(cx: &App) -> Option> { + pub fn try_global(cx: &App) -> Option> { cx.try_global::() .map(|state| state.0.clone()) } - pub fn set_global(state: Weak, cx: &mut App) { + pub fn set_global(state: Arc, cx: &mut App) { cx.set_global(GlobalAppState(state)); } @@ -1141,7 +1143,7 @@ impl AppState { let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); let workspace_store = cx.new(|cx| WorkspaceStore::new(client.clone(), cx)); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); client::init(&client, cx); Arc::new(Self { @@ -1342,6 +1344,7 @@ pub struct Workspace { removing: bool, _panels_task: Option>>, sidebar_focus_handle: Option, + multi_workspace: Option>, } impl EventEmitter for Workspace {} @@ -1364,6 +1367,19 @@ struct FollowerView { location: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum OpenMode { + /// Open the workspace in a new window. + NewWindow, + /// Add to the window's multi workspace without activating it (used during deserialization). + Add, + /// Add to the window's multi workspace and activate it. + #[default] + Activate, + /// Replace the currently active workspace, and any of it's linked workspaces + Replace, +} + impl Workspace { pub fn new( workspace_id: Option, @@ -1626,8 +1642,13 @@ impl Workspace { let left_dock_buttons = cx.new(|cx| PanelButtons::new(left_dock.clone(), cx)); let bottom_dock_buttons = cx.new(|cx| PanelButtons::new(bottom_dock.clone(), cx)); let right_dock_buttons = cx.new(|cx| PanelButtons::new(right_dock.clone(), cx)); + let multi_workspace = window + .root::() + .flatten() + .map(|mw| mw.downgrade()); let status_bar = cx.new(|cx| { - let mut status_bar = StatusBar::new(¢er_pane.clone(), window, cx); + let mut status_bar = + StatusBar::new(¢er_pane.clone(), multi_workspace.clone(), window, cx); status_bar.add_left_item(left_dock_buttons, window, cx); status_bar.add_right_item(right_dock_buttons, window, cx); status_bar.add_right_item(bottom_dock_buttons, window, cx); @@ -1675,8 +1696,8 @@ impl Workspace { *SystemAppearance::global_mut(cx) = SystemAppearance(window_appearance.into()); - GlobalTheme::reload_theme(cx); - GlobalTheme::reload_icon_theme(cx); + theme_settings::reload_theme(cx); + theme_settings::reload_icon_theme(cx); }), cx.on_release({ let weak_handle = weak_handle.clone(); @@ -1754,6 +1775,7 @@ impl Workspace { last_open_dock_positions: Vec::new(), removing: false, sidebar_focus_handle: None, + multi_workspace, } } @@ -1763,7 +1785,7 @@ impl Workspace { requesting_window: Option>, env: Option>, init: Option) + Send>>, - activate: bool, + open_mode: OpenMode, cx: &mut App, ) -> Task> { let project_handle = Project::local( @@ -1861,8 +1883,13 @@ impl Workspace { }); } + let window_to_replace = match open_mode { + OpenMode::NewWindow => None, + _ => requesting_window, + }; + let (window, workspace): (WindowHandle, Entity) = - if let Some(window) = requesting_window { + if let Some(window) = window_to_replace { let centered_layout = serialized_workspace .as_ref() .map(|w| w.centered_layout) @@ -1887,10 +1914,19 @@ impl Workspace { workspace }); - if activate { - multi_workspace.activate(workspace.clone(), cx); - } else { - multi_workspace.add_workspace(workspace.clone(), cx); + match open_mode { + OpenMode::Replace => { + multi_workspace.replace(workspace.clone(), &*window, cx); + } + OpenMode::Activate => { + multi_workspace.activate(workspace.clone(), window, cx); + } + OpenMode::Add => { + multi_workspace.add(workspace.clone(), &*window, cx); + } + OpenMode::NewWindow => { + unreachable!() + } } workspace })?; @@ -2127,6 +2163,197 @@ impl Workspace { } } + pub fn agent_panel_position(&self, cx: &App) -> Option { + self.all_docks().into_iter().find_map(|dock| { + let dock = dock.read(cx); + dock.has_agent_panel(cx).then_some(dock.position()) + }) + } + + pub fn panel_size_state(&self, cx: &App) -> Option { + self.all_docks().into_iter().find_map(|dock| { + let dock = dock.read(cx); + let panel = dock.panel::()?; + dock.stored_panel_size_state(&panel) + }) + } + + pub fn persisted_panel_size_state( + &self, + panel_key: &'static str, + cx: &App, + ) -> Option { + dock::Dock::load_persisted_size_state(self, panel_key, cx) + } + + pub fn persist_panel_size_state( + &self, + panel_key: &str, + size_state: dock::PanelSizeState, + cx: &mut App, + ) { + let Some(workspace_id) = self + .database_id() + .map(|id| i64::from(id).to_string()) + .or(self.session_id()) + else { + return; + }; + + let kvp = db::kvp::KeyValueStore::global(cx); + let panel_key = panel_key.to_string(); + cx.background_spawn(async move { + let scope = kvp.scoped(dock::PANEL_SIZE_STATE_KEY); + scope + .write( + format!("{workspace_id}:{panel_key}"), + serde_json::to_string(&size_state)?, + ) + .await + }) + .detach_and_log_err(cx); + } + + pub fn set_panel_size_state( + &mut self, + size_state: dock::PanelSizeState, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let Some(panel) = self.panel::(cx) else { + return false; + }; + + let dock = self.dock_at_position(panel.position(window, cx)); + let did_set = dock.update(cx, |dock, cx| { + dock.set_panel_size_state(&panel, size_state, cx) + }); + + if did_set { + self.persist_panel_size_state(T::panel_key(), size_state, cx); + } + + did_set + } + + pub fn toggle_dock_panel_flexible_size( + &self, + dock: &Entity, + panel: &dyn PanelHandle, + window: &mut Window, + cx: &mut App, + ) { + let position = dock.read(cx).position(); + let current_size = self.dock_size(&dock.read(cx), window, cx); + let current_flex = + current_size.and_then(|size| self.dock_flex_for_size(position, size, window, cx)); + dock.update(cx, |dock, cx| { + dock.toggle_panel_flexible_size(panel, current_size, current_flex, window, cx); + }); + } + + fn dock_size(&self, dock: &Dock, window: &Window, cx: &App) -> Option { + let panel = dock.active_panel()?; + let size_state = dock + .stored_panel_size_state(panel.as_ref()) + .unwrap_or_default(); + let position = dock.position(); + + let use_flex = panel.has_flexible_size(window, cx); + + if position.axis() == Axis::Horizontal + && use_flex + && let Some(flex) = size_state.flex.or_else(|| self.default_dock_flex(position)) + { + let workspace_width = self.bounds.size.width; + if workspace_width <= Pixels::ZERO { + return None; + } + let flex = flex.max(0.001); + let opposite = self.opposite_dock_panel_and_size_state(position, window, cx); + if let Some(opposite_flex) = opposite.as_ref().and_then(|(_, s)| s.flex) { + // Both docks are flex items sharing the full workspace width. + let total_flex = flex + 1.0 + opposite_flex; + return Some((flex / total_flex * workspace_width).max(RESIZE_HANDLE_SIZE)); + } else { + // Opposite dock is fixed-width; flex items share (W - fixed). + let opposite_fixed = opposite + .map(|(panel, s)| s.size.unwrap_or_else(|| panel.default_size(window, cx))) + .unwrap_or_default(); + let available = (workspace_width - opposite_fixed).max(RESIZE_HANDLE_SIZE); + return Some((flex / (flex + 1.0) * available).max(RESIZE_HANDLE_SIZE)); + } + } + + Some( + size_state + .size + .unwrap_or_else(|| panel.default_size(window, cx)), + ) + } + + pub fn dock_flex_for_size( + &self, + position: DockPosition, + size: Pixels, + window: &Window, + cx: &App, + ) -> Option { + if position.axis() != Axis::Horizontal { + return None; + } + + let workspace_width = self.bounds.size.width; + if workspace_width <= Pixels::ZERO { + return None; + } + + let opposite = self.opposite_dock_panel_and_size_state(position, window, cx); + if let Some(opposite_flex) = opposite.as_ref().and_then(|(_, s)| s.flex) { + let size = size.clamp(px(0.), workspace_width - px(1.)); + Some((size * (1.0 + opposite_flex) / (workspace_width - size)).max(0.0)) + } else { + let opposite_width = opposite + .map(|(panel, s)| s.size.unwrap_or_else(|| panel.default_size(window, cx))) + .unwrap_or_default(); + let available = (workspace_width - opposite_width).max(RESIZE_HANDLE_SIZE); + let remaining = (available - size).max(px(1.)); + Some((size / remaining).max(0.0)) + } + } + + fn opposite_dock_panel_and_size_state( + &self, + position: DockPosition, + window: &Window, + cx: &App, + ) -> Option<(Arc, PanelSizeState)> { + let opposite_position = match position { + DockPosition::Left => DockPosition::Right, + DockPosition::Right => DockPosition::Left, + DockPosition::Bottom => return None, + }; + + let opposite_dock = self.dock_at_position(opposite_position).read(cx); + let panel = opposite_dock.visible_panel()?; + let mut size_state = opposite_dock + .stored_panel_size_state(panel.as_ref()) + .unwrap_or_default(); + if size_state.flex.is_none() && panel.has_flexible_size(window, cx) { + size_state.flex = self.default_dock_flex(opposite_position); + } + Some((panel.clone(), size_state)) + } + + pub fn default_dock_flex(&self, position: DockPosition) -> Option { + if position.axis() != Axis::Horizontal { + return None; + } + + let pane = self.last_active_center_pane.clone()?.upgrade()?; + Some(self.center.width_fraction_for_pane(&pane).unwrap_or(1.0)) + } + pub fn is_edited(&self) -> bool { self.window_edited } @@ -2144,9 +2371,25 @@ impl Workspace { let dock_position = panel.position(window, cx); let dock = self.dock_at_position(dock_position); let any_panel = panel.to_any(); + let persisted_size_state = + self.persisted_panel_size_state(T::panel_key(), cx) + .or_else(|| { + load_legacy_panel_size(T::panel_key(), dock_position, self, cx).map(|size| { + let state = dock::PanelSizeState { + size: Some(size), + flex: None, + }; + self.persist_panel_size_state(T::panel_key(), state, cx); + state + }) + }); dock.update(cx, |dock, cx| { - dock.add_panel(panel, self.weak_self.clone(), window, cx) + let index = dock.add_panel(panel.clone(), self.weak_self.clone(), window, cx); + if let Some(size_state) = persisted_size_state { + dock.set_panel_size_state(&panel, size_state, cx); + } + index }); cx.emit(Event::PanelAdded(any_panel)); @@ -2167,20 +2410,6 @@ impl Workspace { &self.status_bar } - pub fn set_workspace_sidebar_open( - &self, - open: bool, - has_notifications: bool, - show_toggle: bool, - cx: &mut App, - ) { - self.status_bar.update(cx, |status_bar, cx| { - status_bar.set_workspace_sidebar_open(open, cx); - status_bar.set_sidebar_has_notifications(has_notifications, cx); - status_bar.set_show_sidebar_toggle(show_toggle, cx); - }); - } - pub fn set_sidebar_focus_handle(&mut self, handle: Option) { self.sidebar_focus_handle = handle; } @@ -2189,6 +2418,21 @@ impl Workspace { StatusBarSettings::get_global(cx).show } + pub fn multi_workspace(&self) -> Option<&WeakEntity> { + self.multi_workspace.as_ref() + } + + pub fn set_multi_workspace( + &mut self, + multi_workspace: WeakEntity, + cx: &mut App, + ) { + self.status_bar.update(cx, |status_bar, cx| { + status_bar.set_multi_workspace(multi_workspace.clone(), cx); + }); + self.multi_workspace = Some(multi_workspace); + } + pub fn app_state(&self) -> &Arc { &self.app_state } @@ -2712,7 +2956,7 @@ impl Workspace { None, env, None, - true, + OpenMode::Activate, cx, ); cx.spawn_in(window, async move |_vh, cx| { @@ -2753,7 +2997,7 @@ impl Workspace { None, env, None, - true, + OpenMode::Activate, cx, ); cx.spawn_in(window, async move |_vh, cx| { @@ -3135,23 +3379,22 @@ impl Workspace { pub fn open_workspace_for_paths( &mut self, - replace_current_window: bool, + // replace_current_window: bool, + mut open_mode: OpenMode, paths: Vec, window: &mut Window, cx: &mut Context, ) -> Task>> { - let window_handle = window.window_handle().downcast::(); + let requesting_window = window.window_handle().downcast::(); let is_remote = self.project.read(cx).is_via_collab(); let has_worktree = self.project.read(cx).worktrees(cx).next().is_some(); let has_dirty_items = self.items(cx).any(|item| item.is_dirty(cx)); - let window_to_replace = if replace_current_window { - window_handle - } else if is_remote || has_worktree || has_dirty_items { - None - } else { - window_handle - }; + let workspace_is_empty = !is_remote && !has_worktree && !has_dirty_items; + if workspace_is_empty { + open_mode = OpenMode::Replace; + } + let app_state = self.app_state.clone(); cx.spawn(async move |_, cx| { @@ -3161,7 +3404,8 @@ impl Workspace { &paths, app_state, OpenOptions { - replace_window: window_to_replace, + requesting_window, + open_mode, ..Default::default() }, cx, @@ -4730,11 +4974,12 @@ impl Workspace { .into_iter() .find(|dock| dock.focus_handle(cx).contains_focused(window, cx)); - if let Some(dock) = active_dock { - let Some(panel_size) = dock.read(cx).active_panel_size(window, cx) else { + if let Some(dock_entity) = active_dock { + let dock = dock_entity.read(cx); + let Some(panel_size) = self.dock_size(&dock, window, cx) else { return; }; - match dock.read(cx).position() { + match dock.position() { DockPosition::Left => self.resize_left_dock(panel_size + amount, window, cx), DockPosition::Bottom => self.resize_bottom_dock(panel_size + amount, window, cx), DockPosition::Right => self.resize_right_dock(panel_size + amount, window, cx), @@ -6754,24 +6999,33 @@ impl Workspace { |workspace: &mut Workspace, _: &ResetActiveDockSize, window, cx| { for dock in workspace.all_docks() { if dock.focus_handle(cx).contains_focused(window, cx) { - let Some(panel) = dock.read(cx).active_panel() else { - return; - }; - - // Set to `None`, then the size will fall back to the default. - panel.clone().set_size(None, window, cx); - + let panel = dock.read(cx).active_panel().cloned(); + if let Some(panel) = panel { + dock.update(cx, |dock, cx| { + dock.set_panel_size_state( + panel.as_ref(), + dock::PanelSizeState::default(), + cx, + ); + }); + } return; } } }, )) .on_action(cx.listener( - |workspace: &mut Workspace, _: &ResetOpenDocksSize, window, cx| { + |workspace: &mut Workspace, _: &ResetOpenDocksSize, _window, cx| { for dock in workspace.all_docks() { - if let Some(panel) = dock.read(cx).visible_panel() { - // Set to `None`, then the size will fall back to the default. - panel.clone().set_size(None, window, cx); + let panel = dock.read(cx).visible_panel().cloned(); + if let Some(panel) = panel { + dock.update(cx, |dock, cx| { + dock.set_panel_size_state( + panel.as_ref(), + dock::PanelSizeState::default(), + cx, + ); + }); } } }, @@ -7085,14 +7339,49 @@ impl Workspace { leader_border_for_pane(follower_states, &pane, window, cx) }); - Some( - div() - .flex() - .flex_none() - .overflow_hidden() - .child(dock.clone()) - .children(leader_border), - ) + let mut container = div() + .flex() + .overflow_hidden() + .flex_none() + .child(dock.clone()) + .children(leader_border); + + // Apply sizing only when the dock is open. When closed the dock is still + // included in the element tree so its focus handle remains mounted — without + // this, toggle_panel_focus cannot focus the panel when the dock is closed. + let dock = dock.read(cx); + if let Some(panel) = dock.visible_panel() { + let size_state = dock.stored_panel_size_state(panel.as_ref()); + if position.axis() == Axis::Horizontal { + let use_flexible = panel.has_flexible_size(window, cx); + let flex_grow = if use_flexible { + size_state + .and_then(|state| state.flex) + .or_else(|| self.default_dock_flex(position)) + } else { + None + }; + if let Some(grow) = flex_grow { + let grow = grow.max(0.001); + let style = container.style(); + style.flex_grow = Some(grow); + style.flex_shrink = Some(1.0); + style.flex_basis = Some(relative(0.).into()); + } else { + let size = size_state + .and_then(|state| state.size) + .unwrap_or_else(|| panel.default_size(window, cx)); + container = container.w(size); + } + } else { + let size = size_state + .and_then(|state| state.size) + .unwrap_or_else(|| panel.default_size(window, cx)); + container = container.h(size); + } + } + + Some(container) } pub fn for_window(window: &Window, cx: &App) -> Option> { @@ -7162,18 +7451,17 @@ impl Workspace { } } - fn adjust_dock_size_by_px( + fn resize_dock( &mut self, - panel_size: Pixels, dock_pos: DockPosition, - px: Pixels, + new_size: Pixels, window: &mut Window, cx: &mut Context, ) { match dock_pos { - DockPosition::Left => self.resize_left_dock(panel_size + px, window, cx), - DockPosition::Right => self.resize_right_dock(panel_size + px, window, cx), - DockPosition::Bottom => self.resize_bottom_dock(panel_size + px, window, cx), + DockPosition::Left => self.resize_left_dock(new_size, window, cx), + DockPosition::Right => self.resize_right_dock(new_size, window, cx), + DockPosition::Bottom => self.resize_bottom_dock(new_size, window, cx), } } @@ -7183,21 +7471,22 @@ impl Workspace { self.right_dock.read_with(cx, |right_dock, cx| { let right_dock_size = right_dock - .active_panel_size(window, cx) + .stored_active_panel_size(window, cx) .unwrap_or(Pixels::ZERO); if right_dock_size + size > workspace_width { size = workspace_width - right_dock_size } }); + let flex_grow = self.dock_flex_for_size(DockPosition::Left, size, window, cx); self.left_dock.update(cx, |left_dock, cx| { if WorkspaceSettings::get_global(cx) .resize_all_panels_in_dock .contains(&DockPosition::Left) { - left_dock.resize_all_panels(Some(size), window, cx); + left_dock.resize_all_panels(Some(size), flex_grow, window, cx); } else { - left_dock.resize_active_panel(Some(size), window, cx); + left_dock.resize_active_panel(Some(size), flex_grow, window, cx); } }); } @@ -7207,20 +7496,21 @@ impl Workspace { let mut size = new_size.min(workspace_width - RESIZE_HANDLE_SIZE); self.left_dock.read_with(cx, |left_dock, cx| { let left_dock_size = left_dock - .active_panel_size(window, cx) + .stored_active_panel_size(window, cx) .unwrap_or(Pixels::ZERO); if left_dock_size + size > workspace_width { size = workspace_width - left_dock_size } }); + let flex_grow = self.dock_flex_for_size(DockPosition::Right, size, window, cx); self.right_dock.update(cx, |right_dock, cx| { if WorkspaceSettings::get_global(cx) .resize_all_panels_in_dock .contains(&DockPosition::Right) { - right_dock.resize_all_panels(Some(size), window, cx); + right_dock.resize_all_panels(Some(size), flex_grow, window, cx); } else { - right_dock.resize_active_panel(Some(size), window, cx); + right_dock.resize_active_panel(Some(size), flex_grow, window, cx); } }); } @@ -7232,9 +7522,9 @@ impl Workspace { .resize_all_panels_in_dock .contains(&DockPosition::Bottom) { - bottom_dock.resize_all_panels(Some(size), window, cx); + bottom_dock.resize_all_panels(Some(size), None, window, cx); } else { - bottom_dock.resize_active_panel(Some(size), window, cx); + bottom_dock.resize_active_panel(Some(size), None, window, cx); } }); } @@ -7255,17 +7545,23 @@ impl Workspace { fn toggle_theme_mode(&mut self, _: &ToggleMode, _window: &mut Window, cx: &mut Context) { let current_mode = ThemeSettings::get_global(cx).theme.mode(); let next_mode = match current_mode { - Some(theme::ThemeAppearanceMode::Light) => theme::ThemeAppearanceMode::Dark, - Some(theme::ThemeAppearanceMode::Dark) => theme::ThemeAppearanceMode::Light, - Some(theme::ThemeAppearanceMode::System) | None => match cx.theme().appearance() { - theme::Appearance::Light => theme::ThemeAppearanceMode::Dark, - theme::Appearance::Dark => theme::ThemeAppearanceMode::Light, - }, + Some(theme_settings::ThemeAppearanceMode::Light) => { + theme_settings::ThemeAppearanceMode::Dark + } + Some(theme_settings::ThemeAppearanceMode::Dark) => { + theme_settings::ThemeAppearanceMode::Light + } + Some(theme_settings::ThemeAppearanceMode::System) | None => { + match cx.theme().appearance() { + theme::Appearance::Light => theme_settings::ThemeAppearanceMode::Dark, + theme::Appearance::Dark => theme_settings::ThemeAppearanceMode::Light, + } + } }; let fs = self.project().read(cx).fs().clone(); settings::update_settings_file(fs, cx, move |settings, _cx| { - theme::set_mode(settings, next_mode); + theme_settings::set_mode(settings, next_mode); }); } @@ -7615,11 +7911,10 @@ fn adjust_active_dock_size_by_px( return; }; let dock = active_dock.read(cx); - let Some(panel_size) = dock.active_panel_size(window, cx) else { + let Some(panel_size) = workspace.dock_size(&dock, window, cx) else { return; }; - let dock_pos = dock.position(); - workspace.adjust_dock_size_by_px(panel_size, dock_pos, px, window, cx); + workspace.resize_dock(dock.position(), panel_size + px, window, cx); } fn adjust_open_docks_size_by_px( @@ -7631,23 +7926,21 @@ fn adjust_open_docks_size_by_px( let docks = workspace .all_docks() .into_iter() - .filter_map(|dock| { - if dock.read(cx).is_open() { - let dock = dock.read(cx); - let panel_size = dock.active_panel_size(window, cx)?; + .filter_map(|dock_entity| { + let dock = dock_entity.read(cx); + if dock.is_open() { let dock_pos = dock.position(); - Some((panel_size, dock_pos, px)) + let panel_size = workspace.dock_size(&dock, window, cx)?; + Some((dock_pos, panel_size + px)) } else { None } }) .collect::>(); - docks - .into_iter() - .for_each(|(panel_size, dock_pos, offset)| { - workspace.adjust_dock_size_by_px(panel_size, dock_pos, offset, window, cx); - }); + for (position, new_size) in docks { + workspace.resize_dock(position, new_size, window, cx); + } } impl Focusable for Workspace { @@ -7697,7 +7990,7 @@ impl Render for Workspace { } else { (None, None) }; - let ui_font = theme::setup_ui_font(window, cx); + let ui_font = theme_settings::setup_ui_font(window, cx); let theme = cx.theme().clone(); let colors = theme.colors(); @@ -8320,7 +8613,7 @@ pub async fn restore_multiworkspace( None, None, None, - true, + OpenMode::Activate, cx, ) }) @@ -8350,7 +8643,7 @@ pub async fn restore_multiworkspace( Some(window_handle), None, None, - false, + OpenMode::Add, cx, ) }) @@ -8370,18 +8663,17 @@ pub async fn restore_multiworkspace( .workspaces() .iter() .position(|ws| ws.read(cx).database_id() == Some(target_id)); - if let Some(index) = target_index { - multi_workspace.activate_index(index, window, cx); - } else if !multi_workspace.workspaces().is_empty() { - multi_workspace.activate_index(0, window, cx); + let index = target_index.unwrap_or(0); + if let Some(workspace) = multi_workspace.workspaces().get(index).cloned() { + multi_workspace.activate(workspace, window, cx); } }) .ok(); } else { window_handle .update(cx, |multi_workspace, window, cx| { - if !multi_workspace.workspaces().is_empty() { - multi_workspace.activate_index(0, window, cx); + if let Some(workspace) = multi_workspace.workspaces().first().cloned() { + multi_workspace.activate(workspace, window, cx); } }) .ok(); @@ -8632,7 +8924,7 @@ pub fn join_channel( requesting_window, None, None, - true, + OpenMode::Activate, cx, ) }) @@ -8705,8 +8997,18 @@ pub async fn get_any_active_multi_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, None, true, cx)) - .await?; + cx.update(|cx| { + Workspace::new_local( + vec![], + app_state.clone(), + None, + None, + None, + OpenMode::Activate, + cx, + ) + }) + .await?; } activate_any_workspace_window(&mut cx).context("could not open zed") } @@ -8876,7 +9178,8 @@ pub struct OpenOptions { pub focus: Option, pub open_new_workspace: Option, pub wait: bool, - pub replace_window: Option>, + pub requesting_window: Option>, + pub open_mode: OpenMode, pub env: Option>, } @@ -8931,7 +9234,7 @@ pub fn open_workspace_by_id( workspace.centered_layout = centered_layout; workspace }); - multi_workspace.add_workspace(workspace.clone(), cx); + multi_workspace.add(workspace.clone(), &*window, cx); workspace })?; (window, workspace) @@ -9061,7 +9364,7 @@ pub fn open_paths( let open_task = existing .update(cx, |multi_workspace, window, cx| { window.activate_window(); - multi_workspace.activate(target_workspace.clone(), cx); + multi_workspace.activate(target_workspace.clone(), window, cx); target_workspace.update(cx, |workspace, cx| { workspace.open_paths( abs_paths, @@ -9095,10 +9398,10 @@ pub fn open_paths( Workspace::new_local( abs_paths, app_state.clone(), - open_options.replace_window, + open_options.requesting_window, open_options.env, None, - true, + open_options.open_mode, cx, ) }) @@ -9156,13 +9459,14 @@ pub fn open_new( cx: &mut App, init: impl FnOnce(&mut Workspace, &mut Window, &mut Context) + 'static + Send, ) -> Task> { + let addition = open_options.open_mode; let task = Workspace::new_local( Vec::new(), app_state, - open_options.replace_window, + open_options.requesting_window, open_options.env, Some(Box::new(init)), - true, + addition, cx, ); cx.spawn(async move |cx| { @@ -9198,6 +9502,9 @@ pub fn create_and_open_local_file( .read_with(cx, |project, cx| project.try_windows_path_to_wsl(path, cx)); cx.spawn_in(window, async move |workspace, cx| { let path = path.await?; + + let path = fs.canonicalize(&path).await.unwrap_or(path); + let mut items = workspace .update_in(cx, |workspace, window, cx| { workspace.open_paths( @@ -9370,7 +9677,7 @@ async fn open_remote_project_inner( workspace }); - multi_workspace.activate(new_workspace.clone(), cx); + multi_workspace.activate(new_workspace.clone(), window, cx); new_workspace })?; @@ -9457,8 +9764,8 @@ pub fn join_in_room_project( existing_window_and_workspace { existing_window - .update(cx, |multi_workspace, _, cx| { - multi_workspace.activate(target_workspace, cx); + .update(cx, |multi_workspace, window, cx| { + multi_workspace.activate(target_workspace, window, cx); }) .ok(); existing_window @@ -10095,19 +10402,68 @@ pub fn with_active_or_new_workspace( } None => { let app_state = AppState::global(cx); - if let Some(app_state) = app_state.upgrade() { - open_new( - OpenOptions::default(), - app_state, - cx, - move |workspace, window, cx| f(workspace, window, cx), - ) - .detach_and_log_err(cx); - } + open_new( + OpenOptions::default(), + app_state, + cx, + move |workspace, window, cx| f(workspace, window, cx), + ) + .detach_and_log_err(cx); } } } +/// Reads a panel's pixel size from its legacy KVP format and deletes the legacy +/// key. This migration path only runs once per panel per workspace. +fn load_legacy_panel_size( + panel_key: &str, + dock_position: DockPosition, + workspace: &Workspace, + cx: &mut App, +) -> Option { + #[derive(Deserialize)] + struct LegacyPanelState { + #[serde(default)] + width: Option, + #[serde(default)] + height: Option, + } + + let workspace_id = workspace + .database_id() + .map(|id| i64::from(id).to_string()) + .or_else(|| workspace.session_id())?; + + let legacy_key = match panel_key { + "ProjectPanel" => { + format!("{}-{:?}", "ProjectPanel", workspace_id) + } + "OutlinePanel" => { + format!("{}-{:?}", "OutlinePanel", workspace_id) + } + "GitPanel" => { + format!("{}-{:?}", "GitPanel", workspace_id) + } + "TerminalPanel" => { + format!("{:?}-{:?}", "TerminalPanel", workspace_id) + } + _ => return None, + }; + + let kvp = db::kvp::KeyValueStore::global(cx); + let json = kvp.read_kvp(&legacy_key).log_err().flatten()?; + let state = serde_json::from_str::(&json).log_err()?; + let size = match dock_position { + DockPosition::Bottom => state.height, + DockPosition::Left | DockPosition::Right => state.width, + }?; + + cx.background_spawn(async move { kvp.delete_kvp(legacy_key).await }) + .detach_and_log_err(cx); + + Some(size) +} + #[cfg(test)] mod tests { use std::{cell::RefCell, rc::Rc, sync::Arc, time::Duration}; @@ -10343,7 +10699,8 @@ mod tests { // Activate workspace A multi_workspace_handle .update(cx, |mw, window, cx| { - mw.activate_index(0, window, cx); + let workspace = mw.workspaces()[0].clone(); + mw.activate(workspace, window, cx); }) .unwrap(); @@ -10982,6 +11339,128 @@ mod tests { }); } + /// Tests that the navigation history deduplicates entries for the same item. + /// + /// When navigating back and forth between items (e.g., A -> B -> A -> B -> A -> B -> C), + /// the navigation history deduplicates by keeping only the most recent visit to each item, + /// resulting in [A, B, C] instead of [A, B, A, B, A, B, C]. This ensures that Go Back (Ctrl-O) + /// navigates through unique items efficiently: C -> B -> A, rather than bouncing between + /// repeated entries: C -> B -> A -> B -> A -> B -> A. + /// + /// This behavior prevents the navigation history from growing unnecessarily large and provides + /// a better user experience by eliminating redundant navigation steps when jumping between files. + #[gpui::test] + async fn test_navigation_history_deduplication(cx: &mut gpui::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 item_a = cx.new(|cx| { + TestItem::new(cx).with_project_items(&[TestProjectItem::new(1, "a.txt", cx)]) + }); + let item_b = cx.new(|cx| { + TestItem::new(cx).with_project_items(&[TestProjectItem::new(2, "b.txt", cx)]) + }); + let item_c = cx.new(|cx| { + TestItem::new(cx).with_project_items(&[TestProjectItem::new(3, "c.txt", cx)]) + }); + + let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane(Box::new(item_a.clone()), None, true, window, cx); + workspace.add_item_to_active_pane(Box::new(item_b.clone()), None, true, window, cx); + workspace.add_item_to_active_pane(Box::new(item_c.clone()), None, true, window, cx); + }); + + workspace.update_in(cx, |workspace, window, cx| { + workspace.activate_item(&item_a, false, false, window, cx); + }); + cx.run_until_parked(); + + workspace.update_in(cx, |workspace, window, cx| { + workspace.activate_item(&item_b, false, false, window, cx); + }); + cx.run_until_parked(); + + workspace.update_in(cx, |workspace, window, cx| { + workspace.activate_item(&item_a, false, false, window, cx); + }); + cx.run_until_parked(); + + workspace.update_in(cx, |workspace, window, cx| { + workspace.activate_item(&item_b, false, false, window, cx); + }); + cx.run_until_parked(); + + workspace.update_in(cx, |workspace, window, cx| { + workspace.activate_item(&item_a, false, false, window, cx); + }); + cx.run_until_parked(); + + workspace.update_in(cx, |workspace, window, cx| { + workspace.activate_item(&item_b, false, false, window, cx); + }); + cx.run_until_parked(); + + workspace.update_in(cx, |workspace, window, cx| { + workspace.activate_item(&item_c, false, false, window, cx); + }); + cx.run_until_parked(); + + let backward_count = pane.read_with(cx, |pane, cx| { + let mut count = 0; + pane.nav_history().for_each_entry(cx, &mut |_, _| { + count += 1; + }); + count + }); + assert!( + backward_count <= 4, + "Should have at most 4 entries, got {}", + backward_count + ); + + workspace + .update_in(cx, |workspace, window, cx| { + workspace.go_back(pane.downgrade(), window, cx) + }) + .await + .unwrap(); + + let active_item = workspace.read_with(cx, |workspace, cx| { + workspace.active_item(cx).unwrap().item_id() + }); + assert_eq!( + active_item, + item_b.entity_id(), + "After first go_back, should be at item B" + ); + + workspace + .update_in(cx, |workspace, window, cx| { + workspace.go_back(pane.downgrade(), window, cx) + }) + .await + .unwrap(); + + let active_item = workspace.read_with(cx, |workspace, cx| { + workspace.active_item(cx).unwrap().item_id() + }); + assert_eq!( + active_item, + item_a.entity_id(), + "After second go_back, should be at item A" + ); + + pane.read_with(cx, |pane, _| { + assert!(pane.can_navigate_forward(), "Should be able to go forward"); + }); + } + #[gpui::test] async fn test_activate_last_pane(cx: &mut gpui::TestAppContext) { init_test(cx); @@ -11884,6 +12363,394 @@ mod tests { assert_eq!(active_item.item_id(), last_item.item_id()); }); } + + #[gpui::test] + async fn test_flexible_dock_sizing(cx: &mut gpui::TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + let project = Project::test(fs, [], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + + workspace.update(cx, |workspace, _cx| { + workspace.bounds.size.width = px(800.); + }); + + workspace.update_in(cx, |workspace, window, cx| { + let panel = cx.new(|cx| TestPanel::new_flexible(DockPosition::Right, 100, cx)); + workspace.add_panel(panel, window, cx); + workspace.toggle_dock(DockPosition::Right, window, cx); + }); + + let (panel, resized_width, ratio_basis_width) = + workspace.update_in(cx, |workspace, window, cx| { + let item = cx.new(|cx| { + TestItem::new(cx).with_project_items(&[TestProjectItem::new(1, "one.txt", cx)]) + }); + workspace.add_item_to_active_pane(Box::new(item), None, true, window, cx); + + let dock = workspace.right_dock().read(cx); + let workspace_width = workspace.bounds.size.width; + let initial_width = workspace + .dock_size(&dock, window, cx) + .expect("flexible dock should have an initial width"); + + assert_eq!(initial_width, workspace_width / 2.); + + workspace.resize_right_dock(px(300.), window, cx); + + let dock = workspace.right_dock().read(cx); + let resized_width = workspace + .dock_size(&dock, window, cx) + .expect("flexible dock should keep its resized width"); + + assert_eq!(resized_width, px(300.)); + + let panel = workspace + .right_dock() + .read(cx) + .visible_panel() + .expect("flexible dock should have a visible panel") + .panel_id(); + + (panel, resized_width, workspace_width) + }); + + workspace.update_in(cx, |workspace, window, cx| { + workspace.toggle_dock(DockPosition::Right, window, cx); + workspace.toggle_dock(DockPosition::Right, window, cx); + + let dock = workspace.right_dock().read(cx); + let reopened_width = workspace + .dock_size(&dock, window, cx) + .expect("flexible dock should restore when reopened"); + + assert_eq!(reopened_width, resized_width); + + let right_dock = workspace.right_dock().read(cx); + let flexible_panel = right_dock + .visible_panel() + .expect("flexible dock should still have a visible panel"); + assert_eq!(flexible_panel.panel_id(), panel); + assert_eq!( + right_dock + .stored_panel_size_state(flexible_panel.as_ref()) + .and_then(|size_state| size_state.flex), + Some( + resized_width.to_f64() as f32 + / (workspace.bounds.size.width - resized_width).to_f64() as f32 + ) + ); + }); + + workspace.update_in(cx, |workspace, window, cx| { + workspace.split_pane( + workspace.active_pane().clone(), + SplitDirection::Right, + window, + cx, + ); + + let dock = workspace.right_dock().read(cx); + let split_width = workspace + .dock_size(&dock, window, cx) + .expect("flexible dock should keep its user-resized proportion"); + + assert_eq!(split_width, px(300.)); + + workspace.bounds.size.width = px(1600.); + + let dock = workspace.right_dock().read(cx); + let resized_window_width = workspace + .dock_size(&dock, window, cx) + .expect("flexible dock should preserve proportional size on window resize"); + + assert_eq!( + resized_window_width, + workspace.bounds.size.width + * (resized_width.to_f64() as f32 / ratio_basis_width.to_f64() as f32) + ); + }); + } + + #[gpui::test] + async fn test_panel_size_state_persistence(cx: &mut gpui::TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + // Fixed-width panel: pixel size is persisted to KVP and restored on re-add. + { + let project = Project::test(fs.clone(), [], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + + workspace.update(cx, |workspace, _cx| { + workspace.set_random_database_id(); + workspace.bounds.size.width = px(800.); + }); + + let panel = workspace.update_in(cx, |workspace, window, cx| { + let panel = cx.new(|cx| TestPanel::new(DockPosition::Left, 100, cx)); + workspace.add_panel(panel.clone(), window, cx); + workspace.toggle_dock(DockPosition::Left, window, cx); + panel + }); + + workspace.update_in(cx, |workspace, window, cx| { + workspace.resize_left_dock(px(350.), window, cx); + }); + + cx.run_until_parked(); + + let persisted = workspace.read_with(cx, |workspace, cx| { + workspace.persisted_panel_size_state(TestPanel::panel_key(), cx) + }); + assert_eq!( + persisted.and_then(|s| s.size), + Some(px(350.)), + "fixed-width panel size should be persisted to KVP" + ); + + // Remove the panel and re-add a fresh instance with the same key. + // The new instance should have its size state restored from KVP. + workspace.update_in(cx, |workspace, window, cx| { + workspace.remove_panel(&panel, window, cx); + }); + + workspace.update_in(cx, |workspace, window, cx| { + let new_panel = cx.new(|cx| TestPanel::new(DockPosition::Left, 100, cx)); + workspace.add_panel(new_panel, window, cx); + + let left_dock = workspace.left_dock().read(cx); + let size_state = left_dock + .panel::() + .and_then(|p| left_dock.stored_panel_size_state(&p)); + assert_eq!( + size_state.and_then(|s| s.size), + Some(px(350.)), + "re-added fixed-width panel should restore persisted size from KVP" + ); + }); + } + + // Flexible panel: both pixel size and ratio are persisted and restored. + { + let project = Project::test(fs.clone(), [], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + + workspace.update(cx, |workspace, _cx| { + workspace.set_random_database_id(); + workspace.bounds.size.width = px(800.); + }); + + let panel = workspace.update_in(cx, |workspace, window, cx| { + let item = cx.new(|cx| { + TestItem::new(cx).with_project_items(&[TestProjectItem::new(1, "one.txt", cx)]) + }); + workspace.add_item_to_active_pane(Box::new(item), None, true, window, cx); + + let panel = cx.new(|cx| TestPanel::new_flexible(DockPosition::Right, 100, cx)); + workspace.add_panel(panel.clone(), window, cx); + workspace.toggle_dock(DockPosition::Right, window, cx); + panel + }); + + workspace.update_in(cx, |workspace, window, cx| { + workspace.resize_right_dock(px(300.), window, cx); + }); + + cx.run_until_parked(); + + let persisted = workspace + .read_with(cx, |workspace, cx| { + workspace.persisted_panel_size_state(TestPanel::panel_key(), cx) + }) + .expect("flexible panel state should be persisted to KVP"); + assert_eq!( + persisted.size, None, + "flexible panel should not persist a redundant pixel size" + ); + let original_ratio = persisted.flex.expect("panel's flex should be persisted"); + + // Remove the panel and re-add: both size and ratio should be restored. + workspace.update_in(cx, |workspace, window, cx| { + workspace.remove_panel(&panel, window, cx); + }); + + workspace.update_in(cx, |workspace, window, cx| { + let new_panel = cx.new(|cx| TestPanel::new_flexible(DockPosition::Right, 100, cx)); + workspace.add_panel(new_panel, window, cx); + + let right_dock = workspace.right_dock().read(cx); + let size_state = right_dock + .panel::() + .and_then(|p| right_dock.stored_panel_size_state(&p)) + .expect("re-added flexible panel should have restored size state from KVP"); + assert_eq!( + size_state.size, None, + "re-added flexible panel should not have a persisted pixel size" + ); + assert_eq!( + size_state.flex, + Some(original_ratio), + "re-added flexible panel should restore persisted flex" + ); + }); + } + } + + #[gpui::test] + async fn test_flexible_panel_left_dock_sizing(cx: &mut gpui::TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + let project = Project::test(fs, [], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + + workspace.update(cx, |workspace, _cx| { + workspace.bounds.size.width = px(900.); + }); + + // Step 1: Add a tab to the center pane then open a flexible panel in the left + // dock. With one full-width center pane the default ratio is 0.5, so the panel + // and the center pane each take half the workspace width. + workspace.update_in(cx, |workspace, window, cx| { + let item = cx.new(|cx| { + TestItem::new(cx).with_project_items(&[TestProjectItem::new(1, "one.txt", cx)]) + }); + workspace.add_item_to_active_pane(Box::new(item), None, true, window, cx); + + let panel = cx.new(|cx| TestPanel::new_flexible(DockPosition::Left, 100, cx)); + workspace.add_panel(panel, window, cx); + workspace.toggle_dock(DockPosition::Left, window, cx); + + let left_dock = workspace.left_dock().read(cx); + let left_width = workspace + .dock_size(&left_dock, window, cx) + .expect("left dock should have an active panel"); + + assert_eq!( + left_width, + workspace.bounds.size.width / 2., + "flexible left panel should split evenly with the center pane" + ); + }); + + // Step 2: Split the center pane vertically (top/bottom). Vertical splits do not + // change horizontal width fractions, so the flexible panel stays at the same + // width as each half of the split. + workspace.update_in(cx, |workspace, window, cx| { + workspace.split_pane( + workspace.active_pane().clone(), + SplitDirection::Down, + window, + cx, + ); + + let left_dock = workspace.left_dock().read(cx); + let left_width = workspace + .dock_size(&left_dock, window, cx) + .expect("left dock should still have an active panel after vertical split"); + + assert_eq!( + left_width, + workspace.bounds.size.width / 2., + "flexible left panel width should match each vertically-split pane" + ); + }); + + // Step 3: Open a fixed-width panel in the right dock. The right dock's default + // size reduces the available width, so the flexible left panel and the center + // panes all shrink proportionally to accommodate it. + workspace.update_in(cx, |workspace, window, cx| { + let panel = cx.new(|cx| TestPanel::new(DockPosition::Right, 200, cx)); + workspace.add_panel(panel, window, cx); + workspace.toggle_dock(DockPosition::Right, window, cx); + + let right_dock = workspace.right_dock().read(cx); + let right_width = workspace + .dock_size(&right_dock, window, cx) + .expect("right dock should have an active panel"); + + let left_dock = workspace.left_dock().read(cx); + let left_width = workspace + .dock_size(&left_dock, window, cx) + .expect("left dock should still have an active panel"); + + let available_width = workspace.bounds.size.width - right_width; + assert_eq!( + left_width, + available_width / 2., + "flexible left panel should shrink proportionally as the right dock takes space" + ); + }); + + // Step 4: Toggle the right dock's panel to flexible. Now both docks use + // flex sizing and the workspace width is divided among left-flex, center + // (implicit flex 1.0), and right-flex. + workspace.update_in(cx, |workspace, window, cx| { + let right_dock = workspace.right_dock().clone(); + let right_panel = right_dock + .read(cx) + .visible_panel() + .expect("right dock should have a visible panel") + .clone(); + workspace.toggle_dock_panel_flexible_size( + &right_dock, + right_panel.as_ref(), + window, + cx, + ); + + let right_dock = right_dock.read(cx); + let right_panel = right_dock + .visible_panel() + .expect("right dock should still have a visible panel"); + assert!( + right_panel.has_flexible_size(window, cx), + "right panel should now be flexible" + ); + + let right_size_state = right_dock + .stored_panel_size_state(right_panel.as_ref()) + .expect("right panel should have a stored size state after toggling"); + let right_flex = right_size_state + .flex + .expect("right panel should have a flex value after toggling"); + + let left_dock = workspace.left_dock().read(cx); + let left_width = workspace + .dock_size(&left_dock, window, cx) + .expect("left dock should still have an active panel"); + let right_width = workspace + .dock_size(&right_dock, window, cx) + .expect("right dock should still have an active panel"); + + let left_flex = workspace + .default_dock_flex(DockPosition::Left) + .expect("left dock should have a default flex"); + + let total_flex = left_flex + 1.0 + right_flex; + let expected_left = left_flex / total_flex * workspace.bounds.size.width; + let expected_right = right_flex / total_flex * workspace.bounds.size.width; + assert_eq!( + left_width, expected_left, + "flexible left panel should share workspace width via flex ratios" + ); + assert_eq!( + right_width, expected_right, + "flexible right panel should share workspace width via flex ratios" + ); + }); + } + struct TestModal(FocusHandle); impl TestModal { @@ -11936,13 +12803,11 @@ mod tests { panel_1.panel_id() ); assert_eq!( - left_dock.read(cx).active_panel_size(window, cx).unwrap(), - panel_1.size(window, cx) + workspace.dock_size(&left_dock.read(cx), window, cx), + Some(px(300.)) ); - left_dock.update(cx, |left_dock, cx| { - left_dock.resize_active_panel(Some(px(1337.)), window, cx) - }); + workspace.resize_left_dock(px(1337.), window, cx); assert_eq!( workspace .right_dock() @@ -11972,7 +12837,12 @@ mod tests { panel_1.panel_id() ); assert_eq!( - right_dock.read(cx).active_panel_size(window, cx).unwrap(), + right_dock + .read(cx) + .active_panel_size() + .unwrap() + .size + .unwrap(), px(1337.) ); @@ -12010,8 +12880,8 @@ mod tests { panel_1.panel_id() ); assert_eq!( - left_dock.read(cx).active_panel_size(window, cx).unwrap(), - px(1337.) + workspace.dock_size(&left_dock.read(cx), window, cx), + Some(px(1337.)) ); // And the right dock should be closed as it no longer has any panels. assert!(!workspace.right_dock().read(cx).is_open()); @@ -12027,8 +12897,8 @@ mod tests { // since the panel orientation changed from vertical to horizontal. let bottom_dock = workspace.bottom_dock(); assert_eq!( - bottom_dock.read(cx).active_panel_size(window, cx).unwrap(), - panel_1.size(window, cx), + workspace.dock_size(&bottom_dock.read(cx), window, cx), + Some(px(300.)) ); // Close bottom dock and move panel_1 back to the left. bottom_dock.update(cx, |bottom_dock, cx| { @@ -13599,7 +14469,8 @@ mod tests { // Switch to workspace A multi_workspace_handle .update(cx, |mw, window, cx| { - mw.activate_index(0, window, cx); + let workspace = mw.workspaces()[0].clone(); + mw.activate(workspace, window, cx); }) .unwrap(); @@ -13644,7 +14515,8 @@ mod tests { // Switch to workspace B multi_workspace_handle .update(cx, |mw, window, cx| { - mw.activate_index(1, window, cx); + let workspace = mw.workspaces()[1].clone(); + mw.activate(workspace, window, cx); }) .unwrap(); cx.run_until_parked(); @@ -13652,7 +14524,8 @@ mod tests { // Switch back to workspace A multi_workspace_handle .update(cx, |mw, window, cx| { - mw.activate_index(0, window, cx); + let workspace = mw.workspaces()[0].clone(); + mw.activate(workspace, window, cx); }) .unwrap(); cx.run_until_parked(); @@ -13686,7 +14559,7 @@ mod tests { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); cx.set_global(db::AppDatabase::test_new()); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); }); } @@ -13828,4 +14701,72 @@ mod tests { assert!(panel.is_zoomed(window, cx)); }); } + + #[gpui::test] + async fn test_panels_stay_open_after_position_change_and_settings_update( + cx: &mut gpui::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)); + + // Add two panels to the left dock and open it. + let (panel_a, panel_b) = workspace.update_in(cx, |workspace, window, cx| { + let panel_a = cx.new(|cx| TestPanel::new(DockPosition::Left, 100, cx)); + let panel_b = cx.new(|cx| TestPanel::new(DockPosition::Left, 101, cx)); + workspace.add_panel(panel_a.clone(), window, cx); + workspace.add_panel(panel_b.clone(), window, cx); + workspace.left_dock().update(cx, |dock, cx| { + dock.set_open(true, window, cx); + dock.activate_panel(0, window, cx); + }); + (panel_a, panel_b) + }); + + workspace.update_in(cx, |workspace, _, cx| { + assert!(workspace.left_dock().read(cx).is_open()); + }); + + // Simulate a feature flag changing default dock positions: both panels + // move from Left to Right. + workspace.update_in(cx, |_workspace, _window, cx| { + panel_a.update(cx, |p, _cx| p.position = DockPosition::Right); + panel_b.update(cx, |p, _cx| p.position = DockPosition::Right); + cx.update_global::(|_, _| {}); + }); + + // Both panels should now be in the right dock. + workspace.update_in(cx, |workspace, _, cx| { + let right_dock = workspace.right_dock().read(cx); + assert_eq!(right_dock.panels_len(), 2); + }); + + // Open the right dock and activate panel_b (simulating the user + // opening the panel after it moved). + workspace.update_in(cx, |workspace, window, cx| { + workspace.right_dock().update(cx, |dock, cx| { + dock.set_open(true, window, cx); + dock.activate_panel(1, window, cx); + }); + }); + + // Now trigger another SettingsStore change + workspace.update_in(cx, |_workspace, _window, cx| { + cx.update_global::(|_, _| {}); + }); + + workspace.update_in(cx, |workspace, _, cx| { + assert!( + workspace.right_dock().read(cx).is_open(), + "Right dock should still be open after a settings change" + ); + assert_eq!( + workspace.right_dock().read(cx).panels_len(), + 2, + "Both panels should still be in the right dock" + ); + }); + } } diff --git a/crates/workspace/src/workspace_settings.rs b/crates/workspace/src/workspace_settings.rs index 5575af3d7cf07fd7afd22ddbb78a620bab775714..d78b233229800b571ccc37f87719d09125f1c4c3 100644 --- a/crates/workspace/src/workspace_settings.rs +++ b/crates/workspace/src/workspace_settings.rs @@ -132,6 +132,7 @@ impl Settings for TabBarSettings { #[derive(Deserialize, RegisterSetting)] pub struct StatusBarSettings { pub show: bool, + pub show_active_file: bool, pub active_language_button: bool, pub cursor_position_button: bool, pub line_endings_button: bool, @@ -143,6 +144,7 @@ impl Settings for StatusBarSettings { let status_bar = content.status_bar.clone().unwrap(); StatusBarSettings { show: status_bar.show.unwrap(), + show_active_file: status_bar.show_active_file.unwrap(), active_language_button: status_bar.active_language_button.unwrap(), cursor_position_button: status_bar.cursor_position_button.unwrap(), line_endings_button: status_bar.line_endings_button.unwrap(), diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 5d726cc9e712e75056c84ca19c09cf8081b53ea9..07f01e21758aa79509e7d6466e2f3b798eb7b8d3 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -7,7 +7,9 @@ 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 fs::{ + Fs, MTime, PathEvent, PathEventKind, RemoveOptions, Watcher, copy_recursive, read_dir_items, +}; use futures::{ FutureExt as _, Stream, StreamExt, channel::{ @@ -128,6 +130,7 @@ pub struct LocalWorktree { scan_requests_tx: channel::Sender, path_prefixes_to_scan_tx: channel::Sender, is_scanning: (watch::Sender, watch::Receiver), + snapshot_subscriptions: VecDeque<(usize, oneshot::Sender<()>)>, _background_scanner_tasks: Vec>, update_observer: Option, fs: Arc, @@ -470,6 +473,7 @@ impl Worktree { next_entry_id, snapshot, is_scanning: watch::channel_with(true), + snapshot_subscriptions: Default::default(), update_observer: None, scan_requests_tx, path_prefixes_to_scan_tx, @@ -714,6 +718,16 @@ impl Worktree { } } + pub fn wait_for_snapshot( + &mut self, + scan_id: usize, + ) -> impl Future> + use<> { + match self { + Worktree::Local(this) => this.wait_for_snapshot(scan_id).boxed(), + Worktree::Remote(this) => this.wait_for_snapshot(scan_id).boxed(), + } + } + #[cfg(feature = "test-support")] pub fn has_update_observer(&self) -> bool { match self { @@ -1170,6 +1184,15 @@ impl LocalWorktree { if !repo_changes.is_empty() { cx.emit(Event::UpdatedGitRepositories(repo_changes)); } + + while let Some((scan_id, _)) = self.snapshot_subscriptions.front() { + if self.snapshot.completed_scan_id >= *scan_id { + let (_, tx) = self.snapshot_subscriptions.pop_front().unwrap(); + tx.send(()).ok(); + } else { + break; + } + } } fn changed_repos( @@ -1286,6 +1309,28 @@ impl LocalWorktree { } } + pub fn wait_for_snapshot( + &mut self, + scan_id: usize, + ) -> impl Future> + use<> { + let (tx, rx) = oneshot::channel(); + if self.snapshot.completed_scan_id >= scan_id { + tx.send(()).ok(); + } else { + match self + .snapshot_subscriptions + .binary_search_by_key(&scan_id, |probe| probe.0) + { + Ok(ix) | Err(ix) => self.snapshot_subscriptions.insert(ix, (scan_id, tx)), + } + } + + async move { + rx.await?; + Ok(()) + } + } + pub fn snapshot(&self) -> LocalSnapshot { self.snapshot.clone() } @@ -4094,7 +4139,7 @@ impl BackgroundScanner { } for (ix, event) in events.iter().enumerate() { - let abs_path = &SanitizedPath::new(&event.path); + let abs_path = SanitizedPath::new(&event.path); let mut is_git_related = false; let mut dot_git_paths = None; @@ -4111,13 +4156,33 @@ impl BackgroundScanner { } if let Some((dot_git_abs_path, path_in_git_dir)) = dot_git_paths { - if skipped_files_in_dot_git + // We ignore `""` as well, as that is going to be the + // `.git` folder itself. WE do not care about it, if + // there are changes within we will see them, we need + // this ignore to prevent us from accidentally observing + // the ignored created file due to the events not being + // empty after filtering. + + let is_dot_git_changed = { + path_in_git_dir == Path::new("") + && event.kind == Some(PathEventKind::Changed) + && abs_path + .strip_prefix(root_canonical_path) + .ok() + .and_then(|it| RelPath::new(it, PathStyle::local()).ok()) + .is_some_and(|it| { + snapshot + .entry_for_path(&it) + .is_some_and(|entry| entry.kind == EntryKind::Dir) + }) + }; + let condition = skipped_files_in_dot_git.iter().any(|skipped| { + OsStr::new(skipped) == path_in_git_dir.as_path().as_os_str() + }) || skipped_dirs_in_dot_git .iter() - .any(|skipped| OsStr::new(skipped) == path_in_git_dir.as_path().as_os_str()) - || skipped_dirs_in_dot_git.iter().any(|skipped_git_subdir| { - path_in_git_dir.starts_with(skipped_git_subdir) - }) - { + .any(|skipped_git_subdir| path_in_git_dir.starts_with(skipped_git_subdir)) + || is_dot_git_changed; + if condition { log::debug!( "ignoring event {abs_path:?} as it's in the .git directory among skipped files or directories" ); diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index fef75ea29f762df60f6d60892b78e28ac7aad503..c24371667e7d3f984f0960f6b3f18d5d0f1e5f4c 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.230.0" +version = "0.231.0" publish.workspace = true license = "GPL-3.0-or-later" authors = ["Zed Team "] @@ -13,6 +13,8 @@ workspace = true [features] tracy = ["ztracing/tracy"] +# LEAK_BACKTRACE=1 cargo run --features zed/track-project-leak --profile release-fast +track-project-leak = ["gpui/leak-detection"] test-support = [ "gpui/test-support", "gpui_platform/screen-capture", @@ -66,7 +68,7 @@ activity_indicator.workspace = true agent.workspace = true agent-client-protocol.workspace = true agent_settings.workspace = true -agent_ui.workspace = true +agent_ui = { workspace = true, features = ["audio"] } anyhow.workspace = true askpass.workspace = true assets.workspace = true @@ -195,6 +197,7 @@ telemetry.workspace = true telemetry_events.workspace = true terminal_view.workspace = true theme.workspace = true +theme_settings.workspace = true theme_extension.workspace = true theme_selector.workspace = true time.workspace = true diff --git a/crates/zed/build.rs b/crates/zed/build.rs index 690444705c9ed52cf96901a7cda81e04eabeeb4e..4b27d939aee833058d03771907b53324a6ce50d0 100644 --- a/crates/zed/build.rs +++ b/crates/zed/build.rs @@ -82,10 +82,8 @@ fn main() { } } - #[cfg(target_os = "windows")] - { - #[cfg(target_env = "msvc")] - { + if cfg!(windows) { + if cfg!(target_env = "msvc") { // todo(windows): This is to avoid stack overflow. Remove it when solved. println!("cargo:rustc-link-arg=/stack:{}", 8 * 1024 * 1024); } @@ -102,7 +100,7 @@ fn main() { let conpty_dll_target = target_dir.join("conpty.dll"); let open_console_target = target_dir.join("OpenConsole.exe"); - let conpty_url = "https://github.com/microsoft/terminal/releases/download/v1.23.13503.0/Microsoft.Windows.Console.ConPTY.1.23.251216003.nupkg"; + let conpty_url = "https://github.com/microsoft/terminal/releases/download/v1.24.10621.0/Microsoft.Windows.Console.ConPTY.1.24.260303001.nupkg"; let nupkg_path = out_dir.join("conpty.nupkg.zip"); let extract_dir = out_dir.join("conpty"); @@ -217,21 +215,24 @@ fn main() { println!("cargo:rerun-if-env-changed=RELEASE_CHANNEL"); println!("cargo:rerun-if-changed={}", icon.display()); - let mut res = winresource::WindowsResource::new(); + #[cfg(windows)] + { + let mut res = winresource::WindowsResource::new(); - // Depending on the security applied to the computer, winresource might fail - // fetching the RC path. Therefore, we add a way to explicitly specify the - // toolkit path, allowing winresource to use a valid RC path. - if let Some(explicit_rc_toolkit_path) = std::env::var("ZED_RC_TOOLKIT_PATH").ok() { - res.set_toolkit_path(explicit_rc_toolkit_path.as_str()); - } - res.set_icon(icon.to_str().unwrap()); - res.set("FileDescription", "Zed"); - res.set("ProductName", "Zed"); + // Depending on the security applied to the computer, winresource might fail + // fetching the RC path. Therefore, we add a way to explicitly specify the + // toolkit path, allowing winresource to use a valid RC path. + if let Some(explicit_rc_toolkit_path) = std::env::var("ZED_RC_TOOLKIT_PATH").ok() { + res.set_toolkit_path(explicit_rc_toolkit_path.as_str()); + } + res.set_icon(icon.to_str().unwrap()); + res.set("FileDescription", "Zed"); + res.set("ProductName", "Zed"); - if let Err(e) = res.compile() { - eprintln!("{}", e); - std::process::exit(1); + if let Err(e) = res.compile() { + eprintln!("{}", e); + std::process::exit(1); + } } } } diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 0a55953931ff4527851f9c9e7d6ac5f451eea0fd..764a89d507c590f9d5a1f4b7ce40b30795fa450b 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -52,6 +52,7 @@ use std::{ time::Instant, }; use theme::{ActiveTheme, GlobalTheme, ThemeRegistry}; +use theme_settings::load_user_theme; use util::{ResultExt, TryFutureExt, maybe}; use uuid::Uuid; use workspace::{ @@ -440,10 +441,8 @@ fn main() { } }); app.on_reopen(move |cx| { - if let Some(app_state) = AppState::try_global(cx).and_then(|app_state| app_state.upgrade()) - { + if let Some(app_state) = AppState::try_global(cx) { cx.spawn({ - let app_state = app_state; async move |cx| { if let Err(e) = restore_or_create_workspace(app_state, cx).await { fail_to_open_window_async(e, cx) @@ -538,6 +537,7 @@ fn main() { tx.send(Some(options)).log_err(); }) .detach(); + ui::on_new_scrollbars::(cx); let node_runtime = NodeRuntime::new(client.http_client(), Some(shell_env_loaded_rx), rx); @@ -598,6 +598,8 @@ fn main() { }) .detach(); + let is_new_install = matches!(&installation_id, Some(IdType::New(_))); + // We should rename these in the future to `first app open`, `first app open for release channel`, and `app open` if let (Some(system_id), Some(installation_id)) = (&system_id, &installation_id) { match (&system_id, &installation_id) { @@ -625,7 +627,7 @@ fn main() { node_runtime, session: app_session, }); - AppState::set_global(Arc::downgrade(&app_state), cx); + AppState::set_global(app_state.clone(), cx); auto_update::init(client.clone(), cx); dap_adapters::init(cx); @@ -639,7 +641,7 @@ fn main() { cx, ); - theme::init(theme::LoadThemes::All(Box::new(Assets)), cx); + theme_settings::init(theme::LoadThemes::All(Box::new(Assets)), cx); eager_load_active_theme_and_icon_theme(fs.clone(), cx); theme_extension::init( extension_host_proxy, @@ -683,6 +685,7 @@ fn main() { app_state.client.clone(), prompt_builder.clone(), app_state.languages.clone(), + is_new_install, false, cx, ); @@ -811,6 +814,7 @@ fn main() { let fs = app_state.fs.clone(); load_user_themes_in_background(fs.clone(), cx); watch_themes(fs.clone(), cx); + #[cfg(debug_assertions)] watch_languages(fs.clone(), app_state.languages.clone(), cx); let menus = app_menus(cx); @@ -1781,8 +1785,24 @@ fn load_user_themes_in_background(fs: Arc, cx: &mut App) { })?; } } - theme_registry.load_user_themes(themes_dir, fs).await?; - cx.update(GlobalTheme::reload_theme); + + let mut theme_paths = fs + .read_dir(themes_dir) + .await + .with_context(|| format!("reading themes from {themes_dir:?}"))?; + + while let Some(theme_path) = theme_paths.next().await { + let Some(theme_path) = theme_path.log_err() else { + continue; + }; + let Some(bytes) = fs.load_bytes(&theme_path).await.log_err() else { + continue; + }; + + load_user_theme(&theme_registry, &bytes).log_err(); + } + + cx.update(theme_settings::reload_theme); anyhow::Ok(()) } }) @@ -1801,13 +1821,10 @@ fn watch_themes(fs: Arc, cx: &mut App) { for event in paths { if fs.metadata(&event.path).await.ok().flatten().is_some() { let theme_registry = cx.update(|cx| ThemeRegistry::global(cx)); - if theme_registry - .load_user_theme(&event.path, fs.clone()) - .await - .log_err() - .is_some() + if let Some(bytes) = fs.load_bytes(&event.path).await.log_err() + && load_user_theme(&theme_registry, &bytes).log_err().is_some() { - cx.update(GlobalTheme::reload_theme); + cx.update(theme_settings::reload_theme); } } } @@ -1821,7 +1838,7 @@ fn watch_languages(fs: Arc, languages: Arc, cx: &m use std::time::Duration; cx.background_spawn(async move { - let languages_src = Path::new("crates/languages/src"); + let languages_src = Path::new("crates/grammars/src"); let Some(languages_src) = fs.canonicalize(languages_src).await.log_err() else { return; }; @@ -1851,9 +1868,6 @@ fn watch_languages(fs: Arc, languages: Arc, cx: &m .detach(); } -#[cfg(not(debug_assertions))] -fn watch_languages(_fs: Arc, _languages: Arc, _cx: &mut App) {} - fn dump_all_gpui_actions() { #[derive(Debug, serde::Serialize)] struct ActionDef { diff --git a/crates/zed/src/reliability.rs b/crates/zed/src/reliability.rs index 2f284027929b19e5b0d5ac084267cf5548cda667..e6c3821507cffb0d6e46f9634a646a009b73ddc3 100644 --- a/crates/zed/src/reliability.rs +++ b/crates/zed/src/reliability.rs @@ -22,7 +22,11 @@ use crate::STARTUP_TIME; const MAX_HANG_TRACES: usize = 3; pub fn init(client: Arc, cx: &mut App) { - monitor_hangs(cx); + if cfg!(debug_assertions) { + log::info!("Debug assertions enabled, skipping hang monitoring"); + } else { + monitor_hangs(cx); + } cx.on_flags_ready({ let client = client.clone(); diff --git a/crates/zed/src/visual_test_runner.rs b/crates/zed/src/visual_test_runner.rs index b2e88c1d0f9fb861522bce869478c7303aae54eb..57fbeeb9a991705ca1f9ae6cf00b9a17a41d822f 100644 --- a/crates/zed/src/visual_test_runner.rs +++ b/crates/zed/src/visual_test_runner.rs @@ -119,7 +119,7 @@ use { time::Duration, }, util::ResultExt as _, - workspace::{AppState, MultiWorkspace, Panel as _, Workspace}, + workspace::{AppState, MultiWorkspace, Workspace}, zed_actions::OpenSettingsAt, }; @@ -170,13 +170,13 @@ fn run_visual_tests(project_path: PathBuf, update_baseline: bool) -> Result<()> // Set the global app state so settings_ui and other subsystems can find it cx.update(|cx| { - AppState::set_global(Arc::downgrade(&app_state), cx); + AppState::set_global(app_state.clone(), cx); }); // Initialize all Zed subsystems cx.update(|cx| { gpui_tokio::init(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); client::init(&app_state.client, cx); audio::init(cx); workspace::init(app_state.clone(), cx); @@ -214,6 +214,7 @@ fn run_visual_tests(project_path: PathBuf, update_baseline: bool) -> Result<()> app_state.client.clone(), prompt_builder, app_state.languages.clone(), + true, false, cx, ); @@ -965,7 +966,7 @@ fn init_app_state(cx: &mut App) -> Arc { let user_store = cx.new(|cx| client::UserStore::new(client.clone(), cx)); let workspace_store = cx.new(|cx| workspace::WorkspaceStore::new(client.clone(), cx)); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); client::init(&client, cx); let app_state = Arc::new(AppState { @@ -978,7 +979,7 @@ fn init_app_state(cx: &mut App) -> Arc { build_window_options: |_, _| Default::default(), session, }); - AppState::set_global(Arc::downgrade(&app_state), cx); + AppState::set_global(app_state.clone(), cx); app_state } @@ -2594,7 +2595,7 @@ fn run_multi_workspace_sidebar_visual_tests( }); cx.new(|cx| { let mut multi_workspace = MultiWorkspace::new(workspace1, window, cx); - multi_workspace.activate(workspace2, cx); + multi_workspace.activate(workspace2, window, cx); multi_workspace }) }, @@ -2645,7 +2646,8 @@ fn run_multi_workspace_sidebar_visual_tests( // Switch to workspace 1 so it's highlighted as active (index 0) multi_workspace_window .update(cx, |multi_workspace, window, cx| { - multi_workspace.activate_index(0, window, cx); + let workspace = multi_workspace.workspaces()[0].clone(); + multi_workspace.activate(workspace, window, cx); }) .context("Failed to activate workspace 1")?; @@ -3545,7 +3547,6 @@ edition = "2021" new_workspace.update(cx, |workspace, cx| { if let Some(new_panel) = workspace.panel::(cx) { new_panel.update(cx, |panel, cx| { - panel.set_size(Some(px(480.0)), window, cx); panel.open_external_thread_with_server(stub_agent.clone(), window, cx); }); } diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 10d8c0d5974fc1c3a097a3d09c34f107f7840877..992ce084e83aaa36c087d1273ab878b77c9beea3 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -17,7 +17,7 @@ use agent_ui::{AgentDiffToolbar, AgentPanelDelegate}; use anyhow::Context as _; pub use app_menus::*; use assets::Assets; -use audio::{AudioSettings, REPLAY_DURATION}; + use breadcrumbs::Breadcrumbs; use client::zed_urls; use collections::VecDeque; @@ -69,7 +69,7 @@ use settings::{ update_settings_file, }; use sidebar::Sidebar; -use std::time::Duration; + use std::{ borrow::Cow, path::{Path, PathBuf}, @@ -77,16 +77,15 @@ use std::{ sync::atomic::{self, AtomicBool}, }; use terminal_view::terminal_panel::{self, TerminalPanel}; -use theme::{ActiveTheme, GlobalTheme, SystemAppearance, ThemeRegistry, ThemeSettings}; +use theme::{ActiveTheme, SystemAppearance, ThemeRegistry, deserialize_icon_theme}; +use theme_settings::{ThemeSettings, load_user_theme}; use ui::{PopoverMenuHandle, prelude::*}; use util::markdown::MarkdownString; use util::rel_path::RelPath; use util::{ResultExt, asset_str, maybe}; use uuid::Uuid; use vim_mode_setting::VimModeSetting; -use workspace::notifications::{ - NotificationId, SuppressEvent, dismiss_app_notification, show_app_notification, -}; +use workspace::notifications::{NotificationId, dismiss_app_notification, show_app_notification}; use workspace::{ AppState, MultiWorkspace, NewFile, NewWindow, OpenLog, Panel, Toast, Workspace, @@ -94,8 +93,7 @@ use workspace::{ notifications::simple_message_notification::MessageNotification, open_new, }; use workspace::{ - CloseIntent, CloseProject, CloseWindow, NotificationFrame, RestoreBanner, - with_active_or_new_workspace, + CloseIntent, CloseProject, CloseWindow, RestoreBanner, with_active_or_new_workspace, }; use workspace::{Pane, notifications::DetachAndPromptErr}; use zed_actions::{ @@ -144,10 +142,6 @@ actions!( actions!( dev, [ - /// Stores last 30s of audio from zed staff using the experimental rodio - /// audio system (including yourself) on the current call in a tar file - /// in the current working directory. - CaptureRecentAudio, /// Opens a prompt to enter a URL to open. OpenUrlPrompt, ] @@ -298,7 +292,7 @@ fn bind_on_window_closed(cx: &mut App) -> Option { .on_last_window_closed .is_quit_app() .then(|| { - cx.on_window_closed(|cx| { + cx.on_window_closed(|cx, _window_id| { if cx.windows().is_empty() { cx.quit(); } @@ -307,7 +301,7 @@ fn bind_on_window_closed(cx: &mut App) -> Option { } #[cfg(not(target_os = "macos"))] { - Some(cx.on_window_closed(|cx| { + Some(cx.on_window_closed(|cx, _window_id| { if cx.windows().is_empty() { cx.quit(); } @@ -379,6 +373,33 @@ pub fn initialize_workspace( return; }; + #[cfg(feature = "track-project-leak")] + { + let multi_workspace_handle = cx.weak_entity(); + let workspace_handle = _multi_workspace.workspace().downgrade(); + let project_handle = _multi_workspace.workspace().read(cx).project().downgrade(); + let window_id_2 = window.window_handle().window_id(); + cx.on_window_closed(move |cx, window_id| { + let multi_workspace_handle = multi_workspace_handle.clone(); + let workspace_handle = workspace_handle.clone(); + let project_handle = project_handle.clone(); + if window_id != window_id_2 { + return; + } + cx.spawn(async move |cx| { + cx.background_executor() + .timer(std::time::Duration::from_millis(1500)) + .await; + + multi_workspace_handle.assert_released(); + workspace_handle.assert_released(); + project_handle.assert_released(); + }) + .detach(); + }) + .detach(); + } + let multi_workspace_handle = cx.entity().downgrade(); window.on_window_should_close(cx, move |window, cx| { multi_workspace_handle @@ -458,6 +479,7 @@ pub fn initialize_workspace( let search_button = cx.new(|_| search::search_status_button::SearchButton::new()); let diagnostic_summary = cx.new(|cx| diagnostics::items::DiagnosticIndicator::new(workspace, cx)); + let active_file_name = cx.new(|_| workspace::active_file_name::ActiveFileName::new()); let activity_indicator = activity_indicator::ActivityIndicator::new( workspace, workspace.project().read(cx).languages().clone(), @@ -490,6 +512,7 @@ pub fn initialize_workspace( status_bar.add_left_item(search_button, window, cx); status_bar.add_left_item(lsp_button, window, cx); status_bar.add_left_item(diagnostic_summary, window, cx); + status_bar.add_left_item(active_file_name, window, cx); status_bar.add_left_item(activity_indicator, window, cx); status_bar.add_right_item(edit_prediction_ui, window, cx); status_bar.add_right_item(active_buffer_encoding, window, cx); @@ -878,10 +901,10 @@ fn register_actions( let _ = settings .theme .ui_font_size - .insert(f32::from(theme::clamp_font_size(ui_font_size)).into()); + .insert(f32::from(theme_settings::clamp_font_size(ui_font_size)).into()); }); } else { - theme::adjust_ui_font_size(cx, |size| size + px(1.0)); + theme_settings::adjust_ui_font_size(cx, |size| size + px(1.0)); } } }) @@ -894,10 +917,10 @@ fn register_actions( let _ = settings .theme .ui_font_size - .insert(f32::from(theme::clamp_font_size(ui_font_size)).into()); + .insert(f32::from(theme_settings::clamp_font_size(ui_font_size)).into()); }); } else { - theme::adjust_ui_font_size(cx, |size| size - px(1.0)); + theme_settings::adjust_ui_font_size(cx, |size| size - px(1.0)); } } }) @@ -909,7 +932,7 @@ fn register_actions( settings.theme.ui_font_size = None; }); } else { - theme::reset_ui_font_size(cx); + theme_settings::reset_ui_font_size(cx); } } }) @@ -923,10 +946,10 @@ fn register_actions( let _ = settings .theme .buffer_font_size - .insert(f32::from(theme::clamp_font_size(buffer_font_size)).into()); + .insert(f32::from(theme_settings::clamp_font_size(buffer_font_size)).into()); }); } else { - theme::adjust_buffer_font_size(cx, |size| size + px(1.0)); + theme_settings::adjust_buffer_font_size(cx, |size| size + px(1.0)); } } }) @@ -940,10 +963,10 @@ fn register_actions( let _ = settings .theme .buffer_font_size - .insert(f32::from(theme::clamp_font_size(buffer_font_size)).into()); + .insert(f32::from(theme_settings::clamp_font_size(buffer_font_size)).into()); }); } else { - theme::adjust_buffer_font_size(cx, |size| size - px(1.0)); + theme_settings::adjust_buffer_font_size(cx, |size| size - px(1.0)); } } }) @@ -955,7 +978,7 @@ fn register_actions( settings.theme.buffer_font_size = None; }); } else { - theme::reset_buffer_font_size(cx); + theme_settings::reset_buffer_font_size(cx); } } }) @@ -970,10 +993,10 @@ fn register_actions( settings.theme.agent_buffer_font_size = None; }); } else { - theme::reset_ui_font_size(cx); - theme::reset_buffer_font_size(cx); - theme::reset_agent_ui_font_size(cx); - theme::reset_agent_buffer_font_size(cx); + theme_settings::reset_ui_font_size(cx); + theme_settings::reset_buffer_font_size(cx); + theme_settings::reset_agent_ui_font_size(cx); + theme_settings::reset_agent_buffer_font_size(cx); } } }) @@ -1049,108 +1072,100 @@ fn register_actions( }, ) .register_action({ - let app_state = Arc::downgrade(&app_state); + let app_state = app_state.clone(); move |_, _: &NewWindow, _, cx| { - if let Some(app_state) = app_state.upgrade() { - open_new( - Default::default(), - app_state, - cx, - |workspace, window, cx| { - cx.activate(true); - // 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(); - } + open_new( + Default::default(), + app_state.clone(), + cx, + |workspace, window, cx| { + cx.activate(true); + // 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(); } }) .register_action({ - let app_state = Arc::downgrade(&app_state); + let app_state = app_state.clone(); move |_workspace, _: &CloseProject, window, cx| { let Some(window_handle) = window.window_handle().downcast::() else { return; }; - if let Some(app_state) = app_state.upgrade() { - cx.spawn_in(window, async move |this, cx| { - let should_continue = this - .update_in(cx, |workspace, window, cx| { - workspace.prepare_to_close( - CloseIntent::ReplaceWindow, - window, - cx, - ) - })? - .await?; - if should_continue { - let task = cx.update(|_window, cx| { - open_new( - workspace::OpenOptions { - replace_window: Some(window_handle), - ..Default::default() - }, - app_state, - cx, - |workspace, window, cx| { - cx.activate(true); - 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, - ); - }, - ) - })?; - task.await - } else { - Ok(()) - } - }) - .detach_and_log_err(cx); - } + let app_state = app_state.clone(); + cx.spawn_in(window, async move |this, cx| { + let should_continue = this + .update_in(cx, |workspace, window, cx| { + workspace.prepare_to_close( + CloseIntent::ReplaceWindow, + window, + cx, + ) + })? + .await?; + if should_continue { + let task = cx.update(|_window, cx| { + open_new( + workspace::OpenOptions { + requesting_window: Some(window_handle), + ..Default::default() + }, + app_state, + cx, + |workspace, window, cx| { + cx.activate(true); + 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, + ); + }, + ) + })?; + task.await + } else { + Ok(()) + } + }) + .detach_and_log_err(cx); } }) .register_action({ - let app_state = Arc::downgrade(&app_state); + let app_state = app_state.clone(); move |_, _: &NewFile, _, cx| { - if let Some(app_state) = app_state.upgrade() { - open_new( - Default::default(), - app_state, - cx, - |workspace, window, cx| { - Editor::new_file(workspace, &Default::default(), window, cx) - }, - ) - .detach_and_log_err(cx); - } + open_new( + Default::default(), + app_state.clone(), + cx, + |workspace, window, cx| { + Editor::new_file(workspace, &Default::default(), window, cx) + }, + ) + .detach_and_log_err(cx); } - }) - .register_action(|workspace, _: &CaptureRecentAudio, window, cx| { - capture_recent_audio(workspace, window, cx); }); #[cfg(not(target_os = "windows"))] @@ -1187,6 +1202,8 @@ fn register_actions( } }); } + + workspace.register_action(sidebar::dump_workspace_info); } fn initialize_pane( @@ -1360,7 +1377,7 @@ fn quit(_: &Quit, cx: &mut App) { for workspace in workspaces { if let Some(should_close) = window .update(cx, |multi_workspace, window, cx| { - multi_workspace.activate(workspace.clone(), cx); + multi_workspace.activate(workspace.clone(), window, cx); window.activate_window(); workspace.update(cx, |workspace, cx| { workspace.prepare_to_close(CloseIntent::Quit, window, cx) @@ -2141,84 +2158,6 @@ fn open_settings_file( .detach_and_log_err(cx); } -fn capture_recent_audio(workspace: &mut Workspace, _: &mut Window, cx: &mut Context) { - struct CaptureRecentAudioNotification { - focus_handle: gpui::FocusHandle, - save_result: Option>, - _save_task: Task>, - } - - impl gpui::EventEmitter for CaptureRecentAudioNotification {} - impl gpui::EventEmitter for CaptureRecentAudioNotification {} - impl gpui::Focusable for CaptureRecentAudioNotification { - fn focus_handle(&self, _cx: &App) -> gpui::FocusHandle { - self.focus_handle.clone() - } - } - impl workspace::notifications::Notification for CaptureRecentAudioNotification {} - - impl Render for CaptureRecentAudioNotification { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let message = match &self.save_result { - None => format!( - "Saving up to {} seconds of recent audio", - REPLAY_DURATION.as_secs(), - ), - Some(Ok((path, duration))) => format!( - "Saved {} seconds of all audio to {}", - duration.as_secs(), - path.display(), - ), - Some(Err(e)) => format!("Error saving audio replays: {e:?}"), - }; - - NotificationFrame::new() - .with_title(Some("Saved Audio")) - .show_suppress_button(false) - .on_close(cx.listener(|_, _, _, cx| { - cx.emit(DismissEvent); - })) - .with_content(message) - } - } - - impl CaptureRecentAudioNotification { - fn new(cx: &mut Context) -> Self { - if AudioSettings::get_global(cx).rodio_audio { - let executor = cx.background_executor().clone(); - let save_task = cx.default_global::().save_replays(executor); - let _save_task = cx.spawn(async move |this, cx| { - let res = save_task.await; - this.update(cx, |this, cx| { - this.save_result = Some(res); - cx.notify(); - }) - }); - - Self { - focus_handle: cx.focus_handle(), - _save_task, - save_result: None, - } - } else { - Self { - focus_handle: cx.focus_handle(), - _save_task: Task::ready(Ok(())), - save_result: Some(Err(anyhow::anyhow!( - "Capturing recent audio is only supported on the experimental rodio audio pipeline" - ))), - } - } - } - } - - workspace.show_notification( - NotificationId::unique::(), - cx, - |cx| cx.new(CaptureRecentAudioNotification::new), - ); -} - /// Eagerly loads the active theme and icon theme based on the selections in the /// theme settings. /// @@ -2280,24 +2219,23 @@ pub(crate) fn eager_load_active_theme_and_icon_theme(fs: Arc, cx: &mut A let reload_tasks = &reload_tasks; let fs = fs.clone(); - scope.spawn(async { + scope.spawn(async move { match load_target { LoadTarget::Theme(theme_path) => { - if theme_registry - .load_user_theme(&theme_path, fs) - .await - .log_err() - .is_some() + if let Some(bytes) = fs.load_bytes(&theme_path).await.log_err() + && load_user_theme(theme_registry, &bytes).log_err().is_some() { reload_tasks.lock().push(ReloadTarget::Theme); } } LoadTarget::IconTheme((icon_theme_path, icons_root_path)) => { - if theme_registry - .load_icon_theme(&icon_theme_path, &icons_root_path, fs) - .await - .log_err() - .is_some() + if let Some(bytes) = fs.load_bytes(&icon_theme_path).await.log_err() + && let Some(icon_theme_family) = + deserialize_icon_theme(&bytes).log_err() + && theme_registry + .load_icon_theme(icon_theme_family, &icons_root_path) + .log_err() + .is_some() { reload_tasks.lock().push(ReloadTarget::IconTheme); } @@ -2309,8 +2247,8 @@ pub(crate) fn eager_load_active_theme_and_icon_theme(fs: Arc, cx: &mut A for reload_target in reload_tasks.into_inner() { match reload_target { - ReloadTarget::Theme => GlobalTheme::reload_theme(cx), - ReloadTarget::IconTheme => GlobalTheme::reload_icon_theme(cx), + ReloadTarget::Theme => theme_settings::reload_theme(cx), + ReloadTarget::IconTheme => theme_settings::reload_icon_theme(cx), }; } } @@ -2353,6 +2291,29 @@ mod tests { open_new, open_paths, pane, }; + async fn flush_workspace_serialization( + window: &WindowHandle, + cx: &mut TestAppContext, + ) { + let all_tasks = window + .update(cx, |multi_workspace, window, cx| { + let mut tasks = multi_workspace + .workspaces() + .iter() + .map(|workspace| { + workspace.update(cx, |workspace, cx| { + workspace.flush_serialization(window, cx) + }) + }) + .collect::>(); + tasks.push(multi_workspace.flush_serialization()); + tasks + }) + .unwrap(); + + futures::future::join_all(all_tasks).await; + } + #[gpui::test] async fn test_open_non_existing_file(cx: &mut TestAppContext) { let app_state = init_test(cx); @@ -2461,7 +2422,7 @@ mod tests { .update(cx, |multi_workspace, window, cx| { multi_workspace.workspace().update(cx, |workspace, cx| { assert_eq!(workspace.worktrees(cx).count(), 2); - assert!(workspace.left_dock().read(cx).is_open()); + assert!(workspace.right_dock().read(cx).is_open()); assert!( workspace .active_pane() @@ -2497,7 +2458,7 @@ mod tests { &[PathBuf::from(path!("/root/e"))], app_state, workspace::OpenOptions { - replace_window: Some(window), + requesting_window: Some(window), ..Default::default() }, cx, @@ -2520,7 +2481,7 @@ mod tests { .collect::>(), &[Path::new(path!("/root/e")).into()] ); - assert!(workspace.left_dock().read(cx).is_open()); + assert!(workspace.right_dock().read(cx).is_open()); assert!(workspace.active_pane().focus_handle(cx).is_focused(window)); }) .unwrap(); @@ -4457,69 +4418,24 @@ mod tests { assert_eq!(active_path(&workspace, cx), Some(file1.clone())); // Reopening closed items doesn't interfere with navigation history. + // Verify we can navigate back through the history after reopening items. workspace .update_in(cx, |workspace, window, cx| { workspace.go_back(workspace.active_pane().downgrade(), window, cx) }) .await .unwrap(); - assert_eq!(active_path(&workspace, cx), Some(file4.clone())); - - workspace - .update_in(cx, |workspace, window, cx| { - workspace.go_back(workspace.active_pane().downgrade(), window, cx) - }) - .await - .unwrap(); - assert_eq!(active_path(&workspace, cx), Some(file2.clone())); - - workspace - .update_in(cx, |workspace, window, cx| { - workspace.go_back(workspace.active_pane().downgrade(), window, cx) - }) - .await - .unwrap(); - assert_eq!(active_path(&workspace, cx), Some(file3.clone())); - - workspace - .update_in(cx, |workspace, window, cx| { - workspace.go_back(workspace.active_pane().downgrade(), window, cx) - }) - .await - .unwrap(); - assert_eq!(active_path(&workspace, cx), Some(file4.clone())); - workspace - .update_in(cx, |workspace, window, cx| { - workspace.go_back(workspace.active_pane().downgrade(), window, cx) - }) - .await - .unwrap(); - assert_eq!(active_path(&workspace, cx), Some(file3.clone())); - - workspace - .update_in(cx, |workspace, window, cx| { - workspace.go_back(workspace.active_pane().downgrade(), window, cx) - }) - .await - .unwrap(); - assert_eq!(active_path(&workspace, cx), Some(file2.clone())); - - workspace - .update_in(cx, |workspace, window, cx| { - workspace.go_back(workspace.active_pane().downgrade(), window, cx) - }) - .await - .unwrap(); - assert_eq!(active_path(&workspace, cx), Some(file1.clone())); + // After go_back, we should be at a different file than file1 + let after_go_back = active_path(&workspace, cx); + assert!( + after_go_back.is_some() && after_go_back != Some(file1.clone()), + "After go_back from file1, should be at a different file" + ); - workspace - .update_in(cx, |workspace, window, cx| { - workspace.go_back(workspace.active_pane().downgrade(), window, cx) - }) - .await - .unwrap(); - assert_eq!(active_path(&workspace, cx), Some(file1.clone())); + pane.read_with(cx, |pane, _| { + assert!(pane.can_navigate_forward(), "Should be able to go forward"); + }); fn active_path( workspace: &Entity, @@ -4536,7 +4452,7 @@ mod tests { cx.update(|cx| { let app_state = AppState::test(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); client::init(&app_state.client, cx); workspace::init(app_state.clone(), cx); onboarding::init(cx); @@ -4954,7 +4870,7 @@ mod tests { .unwrap(); let themes = ThemeRegistry::default(); settings::init(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); let mut has_default_theme = false; for theme_name in themes.list().into_iter().map(|meta| meta.name) { @@ -5092,7 +5008,7 @@ mod tests { app_state.languages.add(markdown_lang()); gpui_tokio::init(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); audio::init(cx); channel::init(&app_state.client, app_state.user_store.clone(), cx); call::init(app_state.client.clone(), app_state.user_store.clone(), cx); @@ -5129,6 +5045,7 @@ mod tests { app_state.client.clone(), prompt_builder.clone(), app_state.languages.clone(), + true, false, cx, ); @@ -5458,11 +5375,11 @@ mod tests { .unwrap(); window - .update(cx, |multi_workspace, _, cx| { - multi_workspace.activate(workspace2.clone(), cx); - multi_workspace.activate(workspace3.clone(), cx); + .update(cx, |multi_workspace, window, cx| { + multi_workspace.activate(workspace2.clone(), window, cx); + multi_workspace.activate(workspace3.clone(), window, cx); // Switch back to workspace1 for test setup - multi_workspace.activate(workspace1, cx); + multi_workspace.activate(workspace1, window, cx); assert_eq!(multi_workspace.active_workspace_index(), 0); }) .unwrap(); @@ -5644,9 +5561,9 @@ mod tests { .unwrap(); window1 - .update(cx, |multi_workspace, _, cx| { - multi_workspace.activate(workspace1_2.clone(), cx); - multi_workspace.activate(workspace1_1.clone(), cx); + .update(cx, |multi_workspace, window, cx| { + multi_workspace.activate(workspace1_2.clone(), window, cx); + multi_workspace.activate(workspace1_1.clone(), window, cx); }) .unwrap(); @@ -5877,7 +5794,7 @@ mod tests { async fn test_multi_workspace_session_restore(cx: &mut TestAppContext) { use collections::HashMap; use session::Session; - use workspace::{Workspace, WorkspaceId}; + use workspace::{OpenMode, Workspace, WorkspaceId}; let app_state = init_test(cx); @@ -5912,7 +5829,7 @@ mod tests { None, None, None, - true, + OpenMode::Activate, cx, ) }) @@ -5921,7 +5838,7 @@ mod tests { window_a .update(cx, |multi_workspace, window, cx| { - multi_workspace.open_project(vec![dir2.into()], window, cx) + multi_workspace.open_project(vec![dir2.into()], OpenMode::Activate, window, cx) }) .unwrap() .await @@ -5938,7 +5855,7 @@ mod tests { None, None, None, - true, + OpenMode::Activate, cx, ) }) @@ -5951,12 +5868,14 @@ mod tests { // still be active rather than whichever workspace happened to restore last. window_a .update(cx, |multi_workspace, window, cx| { - multi_workspace.activate_index(0, window, cx); + let workspace = multi_workspace.workspaces()[0].clone(); + multi_workspace.activate(workspace, window, cx); }) .unwrap(); - // --- Flush serialization --- - cx.executor().advance_clock(SERIALIZATION_THROTTLE_TIME); + cx.run_until_parked(); + flush_workspace_serialization(&window_a, cx).await; + flush_workspace_serialization(&window_b, cx).await; cx.run_until_parked(); // Verify all workspaces retained their session_ids. diff --git a/crates/zed/src/zed/edit_prediction_registry.rs b/crates/zed/src/zed/edit_prediction_registry.rs index 952c840d4abe0cb99be170e27f66a2ba188c08ca..8c9e74a42e6c3ddb2b340ac58da39752009825f0 100644 --- a/crates/zed/src/zed/edit_prediction_registry.rs +++ b/crates/zed/src/zed/edit_prediction_registry.rs @@ -141,9 +141,7 @@ fn edit_prediction_provider_config_for_settings(cx: &App) -> Option Some(EditPredictionProviderConfig::Zed( - EditPredictionModel::Sweep, - )), + EditPredictionProvider::Mercury => Some(EditPredictionProviderConfig::Zed( EditPredictionModel::Mercury, )), @@ -183,7 +181,6 @@ impl EditPredictionProviderConfig { EditPredictionProviderConfig::Zed(model) => match model { EditPredictionModel::Zeta => "Zeta", EditPredictionModel::Fim { .. } => "FIM", - EditPredictionModel::Sweep => "Sweep", EditPredictionModel::Mercury => "Mercury", }, } diff --git a/crates/zed/src/zed/migrate.rs b/crates/zed/src/zed/migrate.rs index f8bec397f1cf54fe37962c6a318a816a3158423e..f7d320a0814f17c47298f0d903800c5a98e353f1 100644 --- a/crates/zed/src/zed/migrate.rs +++ b/crates/zed/src/zed/migrate.rs @@ -11,7 +11,7 @@ use std::sync::Arc; use gpui::{Entity, EventEmitter, Global, Task, TextStyle, TextStyleRefinement}; use markdown::{Markdown, MarkdownElement, MarkdownStyle}; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::prelude::*; use workspace::item::ItemHandle; use workspace::{ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace}; diff --git a/crates/zed/src/zed/open_listener.rs b/crates/zed/src/zed/open_listener.rs index 53347e501f7ba23be62466779f7775d0d432dfab..7645eae88d69f777f650ac9f86724bfef0f10bc5 100644 --- a/crates/zed/src/zed/open_listener.rs +++ b/crates/zed/src/zed/open_listener.rs @@ -529,7 +529,7 @@ async fn open_workspaces( }; let open_options = workspace::OpenOptions { open_new_workspace, - replace_window, + requesting_window: replace_window, wait, env: env.clone(), ..Default::default() @@ -1292,7 +1292,7 @@ mod tests { vec![], false, workspace::OpenOptions { - replace_window: Some(window_to_replace), + requesting_window: Some(window_to_replace), ..Default::default() }, &response_tx, diff --git a/crates/zed/src/zed/telemetry_log.rs b/crates/zed/src/zed/telemetry_log.rs index 06e13ef5d86fb665151b13ce01de5a60def9ba15..cc07783f57b27cc57a281089effb208fc3947050 100644 --- a/crates/zed/src/zed/telemetry_log.rs +++ b/crates/zed/src/zed/telemetry_log.rs @@ -16,7 +16,7 @@ use markdown::{CodeBlockRenderer, Markdown, MarkdownElement, MarkdownStyle}; use project::Project; use settings::Settings; use telemetry_events::{Event, EventWrapper}; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ Icon, IconButton, IconName, IconSize, Label, TextSize, Tooltip, WithScrollbar, prelude::*, }; diff --git a/crates/zed/src/zed/visual_tests.rs b/crates/zed/src/zed/visual_tests.rs index 0aab800eaf0e8664a875751d0b1df1abce98c945..982db08782207a9bfef96ec8f17c28c8abac41f3 100644 --- a/crates/zed/src/zed/visual_tests.rs +++ b/crates/zed/src/zed/visual_tests.rs @@ -51,7 +51,7 @@ pub fn init_visual_test(cx: &mut VisualTestAppContext) -> Arc { let app_state = AppState::test(cx); gpui_tokio::init(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); audio::init(cx); workspace::init(app_state.clone(), cx); release_channel::init(semver::Version::new(0, 0, 0), cx); diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index 0306854c0ad546998c122bc79aa9caf18bbace81..7d8811de705713df7ac8e3a161f14c9f9138ebfc 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -779,13 +779,26 @@ pub mod preview { } pub mod agents_sidebar { - use gpui::actions; + use gpui::{Action, actions}; + use schemars::JsonSchema; + use serde::Deserialize; + + /// Toggles the thread switcher popup when the sidebar is focused. + #[derive(PartialEq, Clone, Deserialize, JsonSchema, Default, Action)] + #[action(namespace = agents_sidebar)] + #[serde(deny_unknown_fields)] + pub struct ToggleThreadSwitcher { + #[serde(default)] + pub select_last: bool, + } actions!( agents_sidebar, [ /// Moves focus to the sidebar's search/filter editor. FocusSidebarFilter, + /// Moves the active workspace to a new window. + MoveWorkspaceToNewWindow, ] ); } diff --git a/docs/README.md b/docs/README.md index a0f9bbd5c628f41d291880239ca555ea7ec0e3ea..f03f008223ba1102585c34f3b98bf93a985c1284 100644 --- a/docs/README.md +++ b/docs/README.md @@ -53,6 +53,14 @@ This will output a code element like: `Cmd + , | Ctrl + ,`. We then By using the action name, we can ensure that the keybinding is always up-to-date rather than hardcoding the keybinding. +#### Keymap Overlays + +`{#kb:keymap_name scope::Action}` - e.g., `{#kb:jetbrains editor::GoToDefinition}`. + +This resolves the keybinding from a keymap overlay (e.g., JetBrains) first, falling back to the default keymap if the overlay doesn't define a binding for that action. This is useful for sections where the documentation expects a special base keymap to be configured. + +Supported overlays: `jetbrains`. + ### Actions `{#action scope::Action}` - e.g., `{#action zed::OpenSettings}`. diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 1522563d2cbeac0a2391aa30db4ab18b6522b18c..fad24930b75da256dd8adf405acebfdd8bb168f6 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -45,6 +45,7 @@ - [Debugger](./debugger.md) - [REPL](./repl.md) - [Git](./git.md) +- [Modelines](./modelines.md) # Collaboration diff --git a/docs/src/ai/edit-prediction.md b/docs/src/ai/edit-prediction.md index 92fde3eddd3be0a2dbfb1b6d37065b58cf2ad411..496bf925e5b137c8b4749207c6785d30913440ae 100644 --- a/docs/src/ai/edit-prediction.md +++ b/docs/src/ai/edit-prediction.md @@ -1,21 +1,23 @@ --- -title: AI Code Completion in Zed - Zeta, Copilot, Sweep, Mercury Coder -description: Set up AI code completions in Zed with Zeta (built-in), GitHub Copilot, Sweep, Codestral, or Mercury Coder. Multi-line predictions on every keystroke. +title: AI Code Completion in Zed - Zeta, Copilot, Codestral, Mercury Coder +description: Set up AI code completions in Zed with Zeta (built-in), GitHub Copilot, Codestral, or Mercury Coder. Multi-line predictions on every keystroke. --- # Edit Prediction Edit Prediction is how Zed's AI code completions work: an LLM predicts the code you want to write. -Each keystroke sends a new request to the edit prediction provider, which returns individual or multi-line suggestions that can be quickly accepted by pressing `tab`. +Each keystroke sends a new request to the edit prediction provider, which returns individual or multi-line suggestions you accept by pressing `tab`. -The default provider is [Zeta, a proprietary open source and open dataset model](https://huggingface.co/zed-industries/zeta), but you can also use [other providers](#other-providers) like GitHub Copilot, Sweep, Mercury Coder, and Codestral. +The default provider is [Zeta, a proprietary open source and open dataset model](https://huggingface.co/zed-industries/zeta), but you can also use [other providers](#other-providers) like GitHub Copilot, Mercury Coder, and Codestral. ## Configuring Zeta To use Zeta, [sign in](../authentication.md#what-features-require-signing-in). Once signed in, predictions appear as you type. -You can confirm that Zeta is properly configured either by verifying whether you have the following code in your settings file: +You can confirm that Zeta is properly configured by opening the [Settings Editor](zed://settings/edit_predictions.providers) (`Cmd+,` on macOS or `Ctrl+,` on Linux/Windows) and searching for `edit_predictions`. The `provider` field should be set to `Zed AI`. + +Or verify this in your settings.json: ```json [settings] { @@ -33,7 +35,7 @@ The free plan includes 2,000 Zeta predictions per month. The [Pro plan](../ai/pl ### Switching Modes {#switching-modes} -Zed's Edit Prediction comes with two different display modes: +Edit Prediction has two display modes: 1. `eager` (default): predictions are displayed inline as long as it doesn't conflict with language server completions 2. `subtle`: predictions only appear inline when holding a modifier key (`alt` by default) @@ -52,191 +54,93 @@ Or directly via the UI through the status bar menu: > Note that edit prediction modes work with any prediction provider. -### Conflict With Other `tab` Actions {#edit-predictions-conflict} - -By default, when `tab` would normally perform a different action, Zed requires a modifier key to accept predictions: +## Default Key Bindings -1. When the language server completions menu is visible. -2. When your cursor isn't at the right indentation level. +On macOS and Windows, you can accept edit predictions with `alt-tab`. On Linux, `alt-tab` is often used by the window manager for switching windows, so `alt-l` is the default key binding for edit predictions. -In these cases, `alt-tab` is used instead to accept the prediction. When the language server completions menu is open, holding `alt` first will cause it to temporarily disappear in order to preview the prediction within the buffer. - -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. +In `eager` mode, you can also use the `tab` key to accept edit predictions, unless the completion menu is open, in which case `tab` accepts LSP completions. To use `tab` to insert whitespace, you need to dismiss the prediction with {#kb editor::Cancel} before hitting `tab`. {#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} -By default, `tab` is used to accept edit predictions. You can use another keybinding by inserting this in your keymap: - -```json [keymap] -{ - "context": "Editor && edit_prediction", - "bindings": { - // Here we also allow `alt-enter` to accept the prediction - "alt-enter": "editor::AcceptEditPrediction" - } -} -``` - -When there's a [conflict with the `tab` key](#edit-predictions-conflict), Zed uses a different key context to accept keybindings (`edit_prediction_conflict`). -If you want to use a different one, you can insert this in your keymap: - -```json [keymap] -{ - "context": "Editor && edit_prediction_conflict", - "bindings": { - "ctrl-enter": "editor::AcceptEditPrediction" // Example of a modified keybinding - } -} -``` - -If your keybinding contains a modifier (`ctrl` in the example above), it will also be used to preview the edit prediction and temporarily hide the language server completion menu. - -You can also bind this action to keybind without a modifier. -In that case, Zed will use the default modifier (`alt`) to preview the edit prediction. - -```json [keymap] -{ - "context": "Editor && edit_prediction_conflict", - "bindings": { - // Here we bind tab to accept even when there's a language server completion - // or the cursor isn't at the correct indentation level - "tab": "editor::AcceptEditPrediction" - } -} -``` - -To maintain the use of the modifier key for accepting predictions when there is a language server completions menu, but allow `tab` to accept predictions regardless of cursor position, you can specify the context further with `showing_completions`: - -```json [keymap] -{ - "context": "Editor && edit_prediction_conflict && !showing_completions", - "bindings": { - // Here we don't require a modifier unless there's a language server completion - "tab": "editor::AcceptEditPrediction" - } -} -``` - ### Keybinding Example: Always Use Tab -If you want to use `tab` to always accept edit predictions, you can use the following keybinding: - -```json [keymap] -{ - "context": "Editor && edit_prediction_conflict && showing_completions", - "bindings": { - "tab": "editor::AcceptEditPrediction" - } -} -``` - -This will make `tab` work to accept edit predictions _even when_ you're also seeing language server completions. -That means that you need to rely on `enter` for accepting the latter. +To always use `tab` for accepting edit predictions, regardless of whether the LSP completions menu is open, you can add the following to your keymap: -### Keybinding Example: Always Use Alt-Tab +Open the keymap editor with {#action zed::OpenKeymap} ({#kb zed::OpenKeymap}), search for `AcceptEditPrediction`, right click on the binding for `tab` and hit `edit`. Then change the context the binding is active in to just `Editor && edit_prediction` and save it. -The keybinding example below causes `alt-tab` to always be used instead of sometimes using `tab`. -You might want this in order to have just one (alternative) keybinding to use for accepting edit predictions, since the behavior of `tab` varies based on context. +Alternatively, you can put the following in your `keymap.json`: ```json [keymap] +[ { "context": "Editor && edit_prediction", "bindings": { - "alt-tab": "editor::AcceptEditPrediction" - } - }, - // Bind `tab` back to its original behavior. - { - "context": "Editor", - "bindings": { - "tab": "editor::Tab" - } - }, - { - "context": "Editor && showing_completions", - "bindings": { - "tab": "editor::ComposeCompletion" + "tab": "editor::AcceptEditPrediction" } - }, + } +] ``` -If you are using [Vim mode](../vim.md), then additional bindings are needed after the above to return `tab` to its original behavior: +After that, {#kb editor::ComposeCompletion} remains available for accepting LSP completions. -```json [keymap] - { - "context": "(VimControl && !menu) || vim_mode == replace || vim_mode == waiting", - "bindings": { - "tab": "vim::Tab" - } - }, - { - "context": "vim_mode == literal", - "bindings": { - "tab": ["vim::Literal", ["tab", "\u0009"]] - } - }, -``` +### Keybinding Example: Always Use Alt-Tab + +To stop using `tab` for accepting edit predictions and always use `alt-tab` instead, unbind the default `tab` binding in the eager edit prediction context: -### Keybinding Example: Displaying Tab and Alt-Tab on Linux +Open the keymap editor with {#action zed::OpenKeymap} ({#kb zed::OpenKeymap}), search for `AcceptEditPrediction`, right click on the binding for `tab` and delete it. -While `tab` and `alt-tab` are supported on Linux, `alt-l` is displayed instead. -If your window manager does not reserve `alt-tab`, and you would prefer to use `tab` and `alt-tab`, include these bindings in `keymap.json`: +Alternatively, you can put the following in your `keymap.json`: ```json [keymap] +[ { "context": "Editor && edit_prediction", - "bindings": { - "tab": "editor::AcceptEditPrediction", - // Optional: This makes the default `alt-l` binding do nothing. - "alt-l": null + "unbind": { + "tab": "editor::AcceptEditPrediction" } - }, - { - "context": "Editor && edit_prediction_conflict", - "bindings": { - "alt-tab": "editor::AcceptEditPrediction", - // Optional: This makes the default `alt-l` binding do nothing. - "alt-l": null - } - }, + } +] ``` -### Missing keybind {#edit-predictions-missing-keybinding} +After that, `alt-tab` remains available for accepting edit predictions, and on Linux `alt-l` does too unless you unbind it. -Zed requires at least one keybinding for the {#action editor::AcceptEditPrediction} action in both the `Editor && edit_prediction` and `Editor && edit_prediction_conflict` contexts ([learn more above](#edit-predictions-keybinding)). +### Keybinding Example: Rebind Both Tab and Alt-Tab -If you have previously bound the default keybindings to different actions in the global context, you will not be able to preview or accept edit predictions. For example: +To move both default accept bindings to something else, unbind them and add your replacement: -```json [keymap] -[ - // Your keymap - { - "bindings": { - // Binds `alt-tab` to a different action globally - "alt-tab": "menu::SelectNext" - } - } -] -``` +Open the keymap editor with {#action zed::OpenKeymap} ({#kb zed::OpenKeymap}), search for `AcceptEditPrediction`, right click on the binding for `tab` and delete it. Then right click on the binding for `alt-tab`, select "Edit", and record your desired keystrokes before hitting saving. -To fix this, you can specify your own keybinding for accepting edit predictions: +Alternatively, you can put the following in your `keymap.json`: ```json [keymap] [ - // ... { - "context": "Editor && edit_prediction_conflict", + "context": "Editor && edit_prediction", + "unbind": { + "alt-tab": "editor::AcceptEditPrediction", + // Add this as well on Windows/Linux + // "alt-l": "editor::AcceptEditPrediction", + "tab": "editor::AcceptEditPrediction" + }, "bindings": { - "alt-l": "editor::AcceptEditPrediction" + "ctrl-enter": "editor::AcceptEditPrediction" } } ] ``` -If you would like to use the default keybinding, you can free it up by either moving yours to a more specific context or changing it to something else. +In this case, because the binding contains the modifier `ctrl`, it will be used to preview the prediction in subtle mode, or when the completions menu is open. + +### Cleaning Up Older Keymap Entries + +If you configured edit prediction keybindings before Zed `v0.229.0`, your `keymap.json` may have entries that are now redundant. + +**Old tab workaround**: Before `unbind` existed, the only way to prevent `tab` from accepting edit predictions was to copy all the default non-edit-prediction `tab` bindings into your keymap alongside a custom `AcceptEditPrediction` binding. If your keymap still contains those copy-pasted entries, delete them and use a single `"unbind"` entry as shown in the examples above. + +**Renamed context**: The `edit_prediction_conflict` context has been replaced by `edit_prediction && (showing_completions || in_leading_whitespace)`. Zed automatically migrates any bindings that used `edit_prediction_conflict`, so no changes are required on your end. ## Disabling Automatic Edit Prediction @@ -329,8 +233,8 @@ If your organization uses GitHub Copilot Enterprise, you can configure Zed to us Replace `"https://your.enterprise.domain"` with the URL provided by your GitHub Enterprise administrator (e.g., `https://foo.ghe.com`). -Once set, Zed will route Copilot requests through your enterprise endpoint. -When you sign in by clicking the Copilot icon in the status bar, you will be redirected to your configured enterprise URL to complete authentication. +Once set, Zed routes Copilot requests through your enterprise endpoint. +When you sign in by clicking the Copilot icon in the status bar, you are redirected to your configured enterprise URL to complete authentication. All other Copilot features and usage remain the same. Copilot can provide multiple completion alternatives, and these can be navigated with the following actions: @@ -338,33 +242,11 @@ Copilot can provide multiple completion alternatives, and these can be navigated - {#action editor::NextEditPrediction} ({#kb editor::NextEditPrediction}): To cycle to the next edit prediction - {#action editor::PreviousEditPrediction} ({#kb editor::PreviousEditPrediction}): To cycle to the previous edit prediction -### Sweep {#sweep} - -To use [Sweep](https://sweep.dev/) as your provider: - -1. Open the Settings Editor (`Cmd+,` on macOS, `Ctrl+,` on Linux/Windows) -2. Search for "Edit Predictions" and click **Configure Providers** -3. Find the Sweep section and enter your API key from the - [Sweep dashboard](https://app.sweep.dev/) - -Alternatively, click the edit prediction icon in the status bar and select -**Configure Providers** from the menu. - -After adding your API key, Sweep will appear in the provider dropdown in the status bar menu, where you can select it. You can also set it directly in your settings file: - -```json [settings] -{ - "edit_predictions": { - "provider": "sweep" - } -} -``` - ### Mercury Coder {#mercury-coder} To use [Mercury Coder](https://www.inceptionlabs.ai/) by Inception Labs as your provider: -1. Open the Settings Editor (`Cmd+,` on macOS, `Ctrl+,` on Linux/Windows) +1. Open the Settings Editor ({#kb zed::OpenSettings}) 2. Search for "Edit Predictions" and click **Configure Providers** 3. Find the Mercury section and enter your API key from the [Inception Labs dashboard](https://platform.inceptionlabs.ai/dashboard/api-keys) diff --git a/docs/src/ai/mcp.md b/docs/src/ai/mcp.md index 08608a9871b7f826c4789bf2213cd7fdd6d624bb..cba02eab4da9c66ed360e0599bc52862c242c180 100644 --- a/docs/src/ai/mcp.md +++ b/docs/src/ai/mcp.md @@ -56,6 +56,9 @@ You can connect them by adding their commands directly to your settings file ([h "remote-mcp-server": { "url": "custom", "headers": { "Authorization": "Bearer " } + }, + "remote-mcp-server-with-oauth": { + "url": "https://mcp.example.com/mcp" } } } @@ -64,6 +67,8 @@ You can connect them by adding their commands directly to your settings file ([h Alternatively, you can also add a custom server by accessing the Agent Panel's Settings view (also accessible via the `agent: open settings` action). From there, you can add it through the modal that appears when you click the "Add Custom Server" button. +> Note: When a remote MCP server has no configured `"Authorization"` header, Zed will prompt you to authenticate yourself against the MCP server using the standard MCP OAuth flow. + ## Using MCP Servers ### Configuration Check diff --git a/docs/src/configuring-zed.md b/docs/src/configuring-zed.md index ec775964dfd35ad019a37ad58ffe42bc03c645c1..b2a8c1e88a4abbead7afe4978abd110880f1fae2 100644 --- a/docs/src/configuring-zed.md +++ b/docs/src/configuring-zed.md @@ -69,6 +69,10 @@ Settings are applied in layers: Later layers override earlier ones. For object settings (like `terminal`), properties merge rather than replace entirely. +## Per-file Settings + +Zed has some compatibility support for Emacs and Vim [modelines](./modelines.md), so you can set some settings per-file. + ## Per-Release Channel Overrides Use different settings for Stable, Preview, or Nightly builds by adding top-level channel keys: diff --git a/docs/src/development/glossary.md b/docs/src/development/glossary.md index ed3b9fdde00a605ec04e3efc25271b57691a45af..1f6b07840b8c70a86c587c45e7b617b0266144e1 100644 --- a/docs/src/development/glossary.md +++ b/docs/src/development/glossary.md @@ -84,16 +84,16 @@ h_flex() - `Panel`: An `Entity` implementing the `Panel` trait. Panels can be placed in a `Dock`. In the image below: `ProjectPanel` is in the left dock, `DebugPanel` is in the bottom dock, and `AgentPanel` is in the right dock. `Editor` does not implement `Panel`. - `Dock`: A UI element similar to a `Pane` that can be opened and hidden. Up to three docks can be open at once: left, right, and bottom. A dock contains one or more `Panel`s, not `Pane`s. -Screenshot for the Pane and Dock features +Screenshot for the Pane and Dock features - `Project`: One or more `Worktree`s - `Worktree`: Represents either local or remote files. -Screenshot for the Worktree feature +Screenshot for the Worktree feature - [Multibuffer](https://zed.dev/docs/multibuffers): A list of Editors, a multi-buffer allows editing multiple files simultaneously. A multi-buffer opens when an operation in Zed returns multiple locations, examples: _search_ or _go to definition_. See project search in the image below. -Screenshot for the MultiBuffer feature +Screenshot for the MultiBuffer feature ## Editor diff --git a/docs/src/extensions/developing-extensions.md b/docs/src/extensions/developing-extensions.md index c1d593628d9e1b7775aa5ce743351c59ad0ce70e..e04afe1e53e3f3a2bd3b2d69e7675201aba63bb6 100644 --- a/docs/src/extensions/developing-extensions.md +++ b/docs/src/extensions/developing-extensions.md @@ -48,8 +48,6 @@ description = "Example extension" repository = "https://github.com/your-name/my-zed-extension" ``` -> **Note:** If you are working on a theme extension with the intent to publish it later, suffix your theme extension ID with `-theme`. Otherwise, this may be raised during [extension publishing](#publishing-your-extension). - In addition to this, there are several other optional files and directories that can be used to add functionality to a Zed extension. An example directory structure of an extension that provides all capabilities is as follows: ``` @@ -144,7 +142,24 @@ Your license file should be at the root of your extension repository. Any filena > This license requirement applies only to your extension code itself (the code that gets compiled into the extension binary). > It does not apply to any tools your extension may download or interact with, such as language servers or other external dependencies. -> If your repository contains both extension code and other projects (like a language server), you are not required to relicense those other projects—only the extension code needs to be one of the aforementioned accepted licenses. +> If your repository contains both extension code and other projects (like a language server), you are not required to relicense those other projects — only the extension code needs to be one of the aforementioned accepted licenses. + +## Extension Publishing Prerequisites + +Before publishing your extension, make sure that you have chosen a unique extension ID for your extension in the [extension manifest](#directory-structure-of-a-zed-extension). +This will be the primary identifier for your extension and cannot be changed after your extension has been published. +Also, ensure that you have filled out all the required fields in the manifest. + +Furthermore, please make sure that your extension fulfills the following preconditions before you move on to publishing your extension: + +- Extension IDs and names must not contain the words `zed`, `Zed` or `extension`, since they are all Zed extensions. +- Your extension ID should provide some information on what your extension tries to accomplish. E.g. for themes, it should be suffixed with `-theme`, snippet extensions should be suffixed with `-snippets` and so on. An exception to that rule are extension that provide support for languages or popular tooling that people would expect to find under that ID. You can take a look at the list of [existing extensions](https://github.com/zed-industries/extensions/blob/main/extensions.toml) to get a grasp on how this usually is enforced. +- Extensions should provide something that is not yet available in the marketplace as opposed to fixing something that could be resolved within an existing extension. For example, if you find that an existing extension's support for a language server is not functioning properly, first try contributing a fix to the existing extension as opposed to submitting a new extension immediately. + - If you receive no response or reaction within the upstream repository within a reasonable amount of time, feel free to submit a pull request that aims to fix said issue. Please ensure that you provide your previous efforts within the pull request to the extensions repository for adding your extension. Zed maintainers will then decide on how to proceed on a case by case basis. +- Extensions that intend to provide a language, debugger or MCP server must not ship the language server as part of the extension. Instead, the extension should either download the language server or check for the availability of the language server in the users environment using the APIs as provided by the [Zed Rust Extension API](https://docs.rs/zed_extension_api/latest/zed_extension_api/). +- Themes and icon themes should not be published as part of extensions that provide other features, e.g. language support. Instead, they should be published as a distinct extension. This also applies to theme and icon themes living in the same repository. + +Note that non-compliance will be raised during the publishing process by reviewers and delay the release of your extension. ## Publishing your extension @@ -152,13 +167,15 @@ To publish an extension, open a PR to [the `zed-industries/extensions` repo](htt In your PR, do the following: -1. Add your extension as a Git submodule within the `extensions/` directory +1. Add your extension as a Git submodule within the `extensions/` directory under the `extensions/{extension-id}` path ```sh -git submodule add https://github.com/your-username/foobar-zed.git extensions/foobar -git add extensions/foobar +git submodule add https://github.com/your-username/foobar-zed.git extensions/my-extension +git add extensions/my-extension ``` +> **Note:** Your extension must live under te + > All extension submodules must use HTTPS URLs and not SSH URLS (`git@github.com`). 2. Add a new entry to the top-level `extensions.toml` file containing your extension: @@ -169,14 +186,21 @@ submodule = "extensions/my-extension" version = "0.0.1" ``` -> If your extension is in a subdirectory within the submodule you can use the `path` field to point to where the extension resides. +If your extension is in a subdirectory within the submodule, you can use the `path` field to point to where the extension resides: + +```toml +[my-extension] +submodule = "extensions-my-extension" +path = "packages/zed" +version = "0.0.1" +``` + +> Note that the [required extension license](#extension-license-requirements) must reside at the specified path, a license at the root of the repository will not work. However, you are free to symlink an existing license within the repository or choose an alternative license from the list of accepted licenses for the extension code. 3. Run `pnpm sort-extensions` to ensure `extensions.toml` and `.gitmodules` are sorted Once your PR is merged, the extension will be packaged and published to the Zed extension registry. -> Extension IDs and names should not contain `zed` or `Zed`, since they are all Zed extensions. - ## Updating an extension To update an extension, open a PR to [the `zed-industries/extensions` repo](https://github.com/zed-industries/extensions). diff --git a/docs/src/finding-navigating.md b/docs/src/finding-navigating.md index f1d3536f8c909f18240f83eac6f4309159b764e1..af30b0ee71554012c2292092f4d7694784ff14cd 100644 --- a/docs/src/finding-navigating.md +++ b/docs/src/finding-navigating.md @@ -23,14 +23,6 @@ Search across all files with {#kb pane::DeploySearch}. Start typing in the searc Results appear in a [multibuffer](./multibuffers.md), letting you edit matches in place. -To disable automatic search and require pressing Enter instead, open the Settings Editor ({#kb zed::OpenSettings}), search for "search on input", and toggle the setting off. Or add this to your settings.json: - -```json -{ - "search_on_input": false -} -``` - ## Go to Definition Jump to where a symbol is defined with {#kb editor::GoToDefinition} (or `Cmd+Click` / `Ctrl+Click`). If there are multiple definitions, they open in a multibuffer. diff --git a/docs/src/languages/elixir.md b/docs/src/languages/elixir.md index e046e5bb0d31b8dcc8b50b32cf876cd1eb11069f..6724ef177900bab07ee4a07ccb0969e401ae5d18 100644 --- a/docs/src/languages/elixir.md +++ b/docs/src/languages/elixir.md @@ -7,94 +7,188 @@ description: "Configure Elixir language support in Zed, including language serve Elixir support is available through the [Elixir extension](https://github.com/zed-extensions/elixir). -- Tree-sitter: +- Tree-sitter Grammars: - [elixir-lang/tree-sitter-elixir](https://github.com/elixir-lang/tree-sitter-elixir) - [phoenixframework/tree-sitter-heex](https://github.com/phoenixframework/tree-sitter-heex) -- Language servers: +- Language Servers: - [elixir-lang/expert](https://github.com/elixir-lang/expert) - [elixir-lsp/elixir-ls](https://github.com/elixir-lsp/elixir-ls) - [elixir-tools/next-ls](https://github.com/elixir-tools/next-ls) - [lexical-lsp/lexical](https://github.com/lexical-lsp/lexical) -## Choosing a language server +Furthermore, the extension provides support for [EEx](https://hexdocs.pm/eex/EEx.html) (Embedded Elixir) templates and [HEEx](https://hexdocs.pm/phoenix/components.html#heex) templates, a mix of HTML and EEx used by Phoenix LiveView applications. -The Elixir extension offers language server support for `expert`, `elixir-ls`, `next-ls`, and `lexical`. +## Language Servers -`elixir-ls` is enabled by default. +The Elixir extension offers language server support for ElixirLS, Expert, Next LS, and Lexical. By default, only ElixirLS is enabled. You can change or disable the enabled language servers in your settings ({#kb zed::OpenSettings}) under Languages > Elixir/EEx/HEEx or directly within your settings file. -### Expert +Some of the language servers can also accept initialization or workspace configuration options. See the sections below for an outline of what each server supports. The configuration can be passed in your settings file via `lsp.{language-server-id}.initialization_options` and `lsp.{language-server-id}.settings` respectively. -Configure language servers in Settings ({#kb zed::OpenSettings}) under Languages > Elixir, or add to your settings file: +Visit the [Configuring Zed](../configuring-zed.md#settings-files) guide for more information on how to edit your settings file. + +### Using ElixirLS + +ElixirLS can accept workspace configuration options. + +The following example disables [Dialyzer](https://github.com/elixir-lsp/elixir-ls#dialyzer-integration): + +```json [settings] + "lsp": { + "elixir-ls": { + "settings": { + "dialyzerEnabled": false + } + } + } +``` + +See the official list of [ElixirLS configuration settings](https://github.com/elixir-lsp/elixir-ls#elixirls-configuration-settings) for all available options. + +### Using Expert + +Enable Expert by adding the following to your settings file: ```json [settings] "languages": { "Elixir": { "language_servers": ["expert", "!elixir-ls", "!next-ls", "!lexical", "..."] }, - "HEEX": { + "EEx": { + "language_servers": ["expert", "!elixir-ls", "!next-ls", "!lexical", "..."] + }, + "HEEx": { "language_servers": ["expert", "!elixir-ls", "!next-ls", "!lexical", "..."] } } ``` -### Next LS +Expert can accept workspace configuration options. + +The following example sets the minimum number of characters required for a project symbol search to return results: + +```json [settings] + "lsp": { + "expert": { + "settings": { + "workspaceSymbols": { + "minQueryLength": 0 + } + } + } + } +``` + +See the [Expert configuration](https://expert-lsp.org/docs/configuration/) page for all available options. + +To use a custom Expert build, add the following to your settings file: + +```json [settings] + "lsp": { + "expert": { + "binary": { + "path": "/path/to/expert", + "arguments": ["--stdio"] + } + } + } +``` + +### Using Next LS -Configure language servers in Settings ({#kb zed::OpenSettings}) under Languages > Elixir, or add to your settings file: +Enable Next LS by adding the following to your settings file: ```json [settings] "languages": { "Elixir": { "language_servers": ["next-ls", "!expert", "!elixir-ls", "!lexical", "..."] }, - "HEEX": { + "EEx": { + "language_servers": ["next-ls", "!expert", "!elixir-ls", "!lexical", "..."] + }, + "HEEx": { "language_servers": ["next-ls", "!expert", "!elixir-ls", "!lexical", "..."] } } ``` -### Lexical +Next LS can accept initialization options. -Configure language servers in Settings ({#kb zed::OpenSettings}) under Languages > Elixir, or add to your settings file: +Completions are an experimental feature within Next LS, they are enabled by default in Zed. Disable them by adding the following to your settings file: ```json [settings] - "languages": { - "Elixir": { - "language_servers": ["lexical", "!expert", "!elixir-ls", "!next-ls", "..."] - }, - "HEEX": { - "language_servers": ["lexical", "!expert", "!elixir-ls", "!next-ls", "..."] + "lsp": { + "next-ls": { + "initialization_options": { + "experimental": { + "completions": { + "enable": false + } + } + } } } ``` -## Setting up `elixir-ls` - -1. Install `elixir`: +Next LS also has an extension for [Credo](https://hexdocs.pm/credo/overview.html) integration which is enabled by default. You can disable this by adding the following section to your settings file: -```sh -brew install elixir +```json [settings] + "lsp": { + "next-ls": { + "initialization_options": { + "extensions": { + "credo": { + "enable": false + } + } + } + } + } ``` -2. Install `elixir-ls`: +Next LS can also pass CLI options directly to Credo. The following example passes `--min-priority high` to it: -```sh -brew install elixir-ls +```json [settings] + "lsp": { + "next-ls": { + "initialization_options": { + "extensions": { + "credo": { + "cli_options": ["--min-priority high"] + } + } + } + } + } ``` -3. Restart Zed +See the [Credo Command Line Switches](https://hexdocs.pm/credo/suggest_command.html#command-line-switches) page for more CLI options. -> If `elixir-ls` is not running in an elixir project, check the error log via the command palette action `zed: open log`. If you find an error message mentioning: `invalid LSP message header "Shall I install Hex? (if running non-interactively, use \"mix local.hex --force\") [Yn]`, you might need to install [`Hex`](https://hex.pm). You run `elixir-ls` from the command line and accept the prompt to install `Hex`. +### Using Lexical -### Formatting with Mix +Enable Lexical by adding the following to your settings file: -If you prefer to format your code with [Mix](https://hexdocs.pm/mix/Mix.html), configure it as an external formatter. Formatting will occur on file save. +```json [settings] + "languages": { + "Elixir": { + "language_servers": ["lexical", "!expert", "!elixir-ls", "!next-ls", "..."] + }, + "EEx": { + "language_servers": ["lexical", "!expert", "!elixir-ls", "!next-ls", "..."] + }, + "HEEx": { + "language_servers": ["lexical", "!expert", "!elixir-ls", "!next-ls", "..."] + } + } +``` + +## Formatting without a language server -Configure formatting in Settings ({#kb zed::OpenSettings}) under Languages > Elixir, or add to your settings file: +If you prefer to work without a language server but would still like code formatting from [Mix](https://hexdocs.pm/mix/Mix.html), you can configure it as an external formatter by adding the following to your settings file: ```json [settings] -{ "languages": { "Elixir": { + "enable_language_server": false, "format_on_save": "on", "formatter": { "external": { @@ -102,46 +196,41 @@ Configure formatting in Settings ({#kb zed::OpenSettings}) under Languages > Eli "arguments": ["format", "--stdin-filename", "{buffer_path}", "-"] } } - } - } -} -``` - -### Additional workspace configuration options - -You can pass additional elixir-ls workspace configuration options via `lsp` settings in your settings file ([how to edit](../configuring-zed.md#settings-files)). - -The following example disables dialyzer: - -```json [settings] - "lsp": { - "elixir-ls": { - "settings": { - "dialyzerEnabled": false + }, + "EEx": { + "enable_language_server": false, + "format_on_save": "on", + "formatter": { + "external": { + "command": "mix", + "arguments": ["format", "--stdin-filename", "{buffer_path}", "-"] + } + } + }, + "HEEx": { + "enable_language_server": false, + "format_on_save": "on", + "formatter": { + "external": { + "command": "mix", + "arguments": ["format", "--stdin-filename", "{buffer_path}", "-"] + } } } } ``` -See [ElixirLS configuration settings](https://github.com/elixir-lsp/elixir-ls#elixirls-configuration-settings) for more options. +## Using the Tailwind CSS Language Server with HEEx templates -### HEEx - -Zed also supports HEEx templates. HEEx is a mix of [EEx](https://hexdocs.pm/eex/1.12.3/EEx.html) (Embedded Elixir) and HTML, and is used in Phoenix LiveView applications. - -- Tree-sitter: [phoenixframework/tree-sitter-heex](https://github.com/phoenixframework/tree-sitter-heex) - -#### Using the Tailwind CSS Language Server with HEEx - -To get all features (autocomplete, linting, and hover docs) from the [Tailwind CSS language server](https://github.com/tailwindlabs/tailwindcss-intellisense/tree/HEAD/packages/tailwindcss-language-server#readme) in HEEx files, add the following to your settings file ([how to edit](../configuring-zed.md#settings-files)): +To get all features (autocomplete, linting, and hover docs) from the [Tailwind CSS language server](https://github.com/tailwindlabs/tailwindcss-intellisense/tree/HEAD/packages/tailwindcss-language-server#readme) in HEEx templates, add the following to your settings file: ```json [settings] -{ "lsp": { "tailwindcss-language-server": { "settings": { "includeLanguages": { - "phoenix-heex": "html" + "elixir": "html", + "heex": "html" }, "experimental": { "classRegex": ["class=\"([^\"]*)\"", "class='([^']*)'"] @@ -149,10 +238,9 @@ To get all features (autocomplete, linting, and hover docs) from the [Tailwind C } } } -} ``` -With these settings, you will get completions for Tailwind CSS classes in HEEx template files. Examples: +With these settings, you will get completions for Tailwind CSS classes in HEEx templates. Examples: ```heex <%!-- Standard class attribute --%> @@ -170,3 +258,8 @@ With these settings, you will get completions for Tailwind CSS classes in HEEx t Content

Some text strong link